UICollectionView cell dynamic height 구현하기(2)_Layout Life Cycle

Zeto·2023년 1월 18일
1

Swift_UIKit

목록 보기
10/12

앞에서 UICollectionViewdynamic height를 구현하기 위해서 레이아웃을 커스텀으로 작성하고 이를 위해 딜리게이트와 더미 셀을 활용하는 방법을 정리해보았다. 그러면서 본인이 비효율성을 느끼고 다른 방법으로 삽질해보려고 했다라는 말로 해당 포스팅을 마무리했는데 이번에는 그 방법들을 정리해보고자 한다.

좀 더 효율적인 방식으로 바꾸고 싶다는 마음은 굴뚝같았지만 사실 어떤 방향으로 접근해야될지 감도 오지 않았었다. 그러던 중, 눈에 띄었던 것이 더미셀을 생성하는 로직에 있었던 layoutIfNeeded 함수였다. 분명 옛날에도 몇 번 본 적이 있지만 그다지 학습을 한 적 없었던 기억이 스쳐가는 녀석으로 직감적으로 내가 원하는 방향에 대한 키포인트가 될 것 같은 느낌이 들었다.

😩 두번째 삽질

♻️ Main Run Loop

단순히 어떤 메서드인지 보고만 넘어가려 했지만, layoutIfNeeded를 그래도 잘 이해하기 위해서는 필연적으로 UIView가 언제 업데이트가 되는지에 대한 이해도 필요하였다.

이를 위해서는 MainRunLoop라는 개념을 학습해야했다. MainRunLoop는 유저로부터 오는 모든 input 이벤트를 받아 이에 맞는 적잘한 응답을 해주는 걸 담당한다. 유저가 발생시킨 상호작용은 EventQueue에 추가가 되고, 이를 하나씩 어플리케이션 객체에 전달해주는 것이다.
그러면 어플리케이션 객체는 이를 해석해서 자신의 CoreObject들 안에 있는 적절한 핸들러를 호출하고, 해당 핸들러가 이제서야 우리 개발자가 쓴 코드를 호출해준다. 이와 같은 복잡한 활동이 끝나 메서드들이 전부 반환되면, 다시금 MainRunLoop로 돌아가서 UpdateCycle을 실행한다.
바로 이 UpdateCycle에서 우리의 UIView들을 배치하고 다시 그리는 역할을 해준다.

🗯 Update Cycle

앞서 말한 대로 여기 UpdateCycle에서 UIView들을 배치(layout)하고, 보여(display)주고, 또 제약(constraints) 해준다. 따라서 이벤트 핸들러의 처리 과정에서 UIView에 대한 변화가 생긴다면 해당 UIView는 다시 그려져야 한다.
이런 다시 그리기 과정은 다음 UpdateCycle에서 수행될 것이며 iOS는 초당 60fp을 보여주므로 해당 과정은 1/60초 밖에 걸리지 않는다. 즉, 유저는 UI와의 상호작용에서 변화의 차이를 인식하기 쉽지 않다.

다만 이 지점에서 걸리는 것이 바로 UIView의 변화 시점과 UpdateCycle의 시점이 맞지 않아 RunLoop의 특정 시점에서 원하는 UI로 업데이트되지 않을 수 있다는 점, 혹은 레이아웃 등의 최신 정보가 아닌 예전 정보로 UI의 조작이 이뤄지는 경우가 발생할 수 있다.
(시점이 맞지 않을 경우에는 다음 RunLoop에서 반영된다)

🃏 Layout

결국 레이아웃이라는 것은 UIView의 크기와 위치를 의미하며, 시스템에게 이러한 레이아웃이 변했다고 알려주거나 해당 레이아웃이 다시 계산되는 시점에 특정 작업을 취할 수 있게 해주는 오버라이딩 가능 콜백 메서드를 제공해준다.

  • layoutSubViews()
    UIView의 해당 메서드는 자신과 자식 뷰들의 위치와 크기를 재조정하며, 재귀적으로 모든 자식 뷰의 layoutSubViews까지 호출해야 되어 실행 시에 부하가 꽤나 큰 메서드이다.
    따라서 이를 직접 호출하는 방법은 금지되어 있으며, 시스템에서 뷰의 frame을 계산할 때 호출하므로 오버라이딩을 통해 특정 위치나 크기를 조절하도록 할 수 있다. 물론 직접 호출이 금지된 것이지 해당 메서드를 시스템이 다시금 호출하도록 유도하는 방식은 여러가지가 존재한다.
    마지막으로 layoutSubViews가 완료되면 해당 뷰를 소유한 ViewControllerviewDidLayoutSubviews가 호출된다. 즉, 해당 뷰의 레이아웃 변화에 대한 유일한 콜백이므로 이와 관련된 작업이 필요한 로직은 viewDidLayoutSubviews에서 호출해줘야 한다.
    (Constraint에 따른 frame 값을 구할 때, viewDidLoadviewDidAppear 등에서 제대로 된 값이 나오지 않는 이유가 이러한 이유이다.)

이 같은 레이아웃은 이벤트를 통해 변화가 생겼다는 표시를 보낼 수 있는데 자동적 호출과 직접적 호출이 존재한다.

- 자동적 호출 (Automatic refresh triggers)

  • UIView의 리사이징
  • SubView 추가
  • 스크롤할 때
  • 디바이스의 회전
  • Constraint 변경

- 직접적 호출

  • setNeedsLayout
    가장 적은 부하로 layoutSubViews를 호출하지만, 즉시 업데이트를 진행시키지는 않는다. 그저 다음 UpdateCycle에서 호출되어 반영시키도록 해준다.

  • layoutIfNeeded
    다음 UpdateCycle까지 기다리는 게 아니라 호출 즉시, layoutSubViews를 호출하여 반영해주도록 하는데 혹여 뷰를 재조정할 필요가 없을 경우에는 layoutSubViews가 호출되지 않는다. 앞서 나온 setNeedsLayout과는 달리 부하가 다소 있긴하지만 애니메이션 하는 상황에서 유용하다.

이처럼 layoutIfNeeded를 알아보기 위해 더욱 깊고 많은 내용들을 이해할 필요가 있었다. 결국 레이아웃을 구현하는 데에 있어 어려움을 느꼈던 것은 이같은 배경을 모르고 접근해서 더욱 크게 와닿았던 것이었다. 이외에도 DisplayConstraint와 관련해서도 정리된 내용들이 많지만 갈 길이 멀다보니 이미지로 대체하고자 한다.

각 영역 별로 메서드의 작동 방식을 표로 정리한 이미지이다.

또한 앞서 설명한 메서드들이 하나의 사이클동안 어떻게 이뤄지는지 정리한 이미지이다.

Layout 외의 것들을 더 자세하게 보고 싶다면 여기의 포스팅을 참조하면 좋을 듯 하다.

⏱ LayoutIfNeeded 활용

위의 학습을 통해 전반적인 레이아웃의 흐름을 파악하였고 이제는 내가 생각한 방법대로 적용했을 때, 올바르게 UI가 그려질 것인가에 대한 확인이 필요했다. 먼저 커스텀으로 작성한 레이아웃은 깔끔하게 걷어내고 CompositionalLayout을 적용하였다.

func createCalendarLayout() -> NSCollectionLayoutSection? {
	let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(50))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
        
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
        
    let section = NSCollectionLayoutSection(group: group)
    section.interGroupSpacing = 10
        
    return section
}

너비는 CollectionView의 너비를 그대로 사용할 것이니 비율을 1로 두었고, 이제 가장 필요한 가변적인 높이를 위해 .estimated로 높이 값을 주었다.

이와 함께 ViewController에서는 데이터가 들어와서 셀을 업데이트할 때, 해당 셀들의 layoutIfNeeded를 호출하여 즉시 셀의 높이 값을 다시 조정하도록 요청하였다.

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
	guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MessageCell.identifier, for: indexPath) as? MessageCell else { return .init() }
        
    cell.setCell(with: messages[indexPath.row].message, type: messages[indexPath.row].chatType)
    cell.layoutIfNeeded()
        
    return cell
}

이렇게 조정한 결과는 대성공이었다. 원하는 방향대로 UI가 출력되는 것이 확인되었고 이제 해당 방향으로 가변적 높이를 조정하도록 진행하면 될 듯 했다. 다만 앞서 설명한 내용에 따르면 layoutIfNeeded는 즉시 layoutSubViews를 호출하다보니 부하가 걸린다고 했다.
여기서 이 부하가 얼마나 걸리는지는 정확하게 알 수 없었지만 뭔가 나를 찝찝하게 만들기에는 충분하였다. 결국 이 방법에서 조금 더 효율적으로 다듬어볼 수 있지 않을까라는 고민을 하게 만들었고, 결국 마지막으로 한 번 더 삽질을 해보고자 결심했다.

🏓 여담

위에 정리된 내용대로 작업을 진행하며 디버깅을 찍어보다보니 상위 ViewControllerviewDidLayoutSubviews가 호출된 이후에 하위 뷰의 layoutSubViews가 호출되는 흐름을 확인할 수 있었다. 분명 여러 자료에서는 viewWillLayoutSubViews -> layoutSubViews -> viewDidLayoutSubViews의 흐름이라고 명시되어 있는데 본인의 프로젝트에서 디버깅하니 layoutSubViews가 마지막에 호출되고 있는데 무엇때문에 이런 흐름을 띄고 있는 건지 아직 파악되지 않았다.
콜백 함수라는 부분에 대해 잘못 이해한 건가 싶었지만, 그저 뷰의 레이아웃이 변화했다는 것에 대한 유일한 콜백이라는 거지 viewDidLayoutSubViews의 콜백 함수로 layoutSubViews가 존재하는 것은 아니었다. 혹시 이 부분에 대해 같은 고민이나 해결이 있었던 분이 있을 경우 알려주시면 좋을 것 같습니다..ㅠㅠ

profile
중2병도 iOS가 하고싶어

0개의 댓글