앞에서 UICollectionView
의 dynamic height
를 구현하기 위해서 레이아웃을 커스텀으로 작성하고 이를 위해 딜리게이트와 더미 셀을 활용하는 방법을 정리해보았다. 그러면서 본인이 비효율성을 느끼고 다른 방법으로 삽질해보려고 했다라는 말로 해당 포스팅을 마무리했는데 이번에는 그 방법들을 정리해보고자 한다.
좀 더 효율적인 방식으로 바꾸고 싶다는 마음은 굴뚝같았지만 사실 어떤 방향으로 접근해야될지 감도 오지 않았었다. 그러던 중, 눈에 띄었던 것이 더미셀을 생성하는 로직에 있었던 layoutIfNeeded
함수였다. 분명 옛날에도 몇 번 본 적이 있지만 그다지 학습을 한 적 없었던 기억이 스쳐가는 녀석으로 직감적으로 내가 원하는 방향에 대한 키포인트가 될 것 같은 느낌이 들었다.
단순히 어떤 메서드인지 보고만 넘어가려 했지만, layoutIfNeeded
를 그래도 잘 이해하기 위해서는 필연적으로 UIView
가 언제 업데이트가 되는지에 대한 이해도 필요하였다.
이를 위해서는 MainRunLoop
라는 개념을 학습해야했다. MainRunLoop
는 유저로부터 오는 모든 input 이벤트를 받아 이에 맞는 적잘한 응답을 해주는 걸 담당한다. 유저가 발생시킨 상호작용은 EventQueue
에 추가가 되고, 이를 하나씩 어플리케이션 객체에 전달해주는 것이다.
그러면 어플리케이션 객체는 이를 해석해서 자신의 CoreObject
들 안에 있는 적절한 핸들러를 호출하고, 해당 핸들러가 이제서야 우리 개발자가 쓴 코드를 호출해준다. 이와 같은 복잡한 활동이 끝나 메서드들이 전부 반환되면, 다시금 MainRunLoop
로 돌아가서 UpdateCycle
을 실행한다.
바로 이 UpdateCycle
에서 우리의 UIView
들을 배치하고 다시 그리는 역할을 해준다.
앞서 말한 대로 여기 UpdateCycle
에서 UIView
들을 배치(layout)하고, 보여(display)주고, 또 제약(constraints) 해준다. 따라서 이벤트 핸들러의 처리 과정에서 UIView
에 대한 변화가 생긴다면 해당 UIView
는 다시 그려져야 한다.
이런 다시 그리기 과정은 다음 UpdateCycle
에서 수행될 것이며 iOS는 초당 60fp을 보여주므로 해당 과정은 1/60초 밖에 걸리지 않는다. 즉, 유저는 UI와의 상호작용에서 변화의 차이를 인식하기 쉽지 않다.
다만 이 지점에서 걸리는 것이 바로 UIView
의 변화 시점과 UpdateCycle
의 시점이 맞지 않아 RunLoop
의 특정 시점에서 원하는 UI로 업데이트되지 않을 수 있다는 점, 혹은 레이아웃 등의 최신 정보가 아닌 예전 정보로 UI의 조작이 이뤄지는 경우가 발생할 수 있다.
(시점이 맞지 않을 경우에는 다음 RunLoop
에서 반영된다)
결국 레이아웃이라는 것은 UIView
의 크기와 위치를 의미하며, 시스템에게 이러한 레이아웃이 변했다고 알려주거나 해당 레이아웃이 다시 계산되는 시점에 특정 작업을 취할 수 있게 해주는 오버라이딩 가능 콜백 메서드를 제공해준다.
layoutSubViews()
UIView
의 해당 메서드는 자신과 자식 뷰들의 위치와 크기를 재조정하며, 재귀적으로 모든 자식 뷰의 layoutSubViews
까지 호출해야 되어 실행 시에 부하가 꽤나 큰 메서드이다.layoutSubViews
가 완료되면 해당 뷰를 소유한 ViewController
의 viewDidLayoutSubviews
가 호출된다. 즉, 해당 뷰의 레이아웃 변화에 대한 유일한 콜백이므로 이와 관련된 작업이 필요한 로직은 viewDidLayoutSubviews
에서 호출해줘야 한다.viewDidLoad
나 viewDidAppear
등에서 제대로 된 값이 나오지 않는 이유가 이러한 이유이다.)이 같은 레이아웃은 이벤트를 통해 변화가 생겼다는 표시를 보낼 수 있는데 자동적 호출과 직접적 호출이 존재한다.
UIView
의 리사이징setNeedsLayout
가장 적은 부하로 layoutSubViews
를 호출하지만, 즉시 업데이트를 진행시키지는 않는다. 그저 다음 UpdateCycle
에서 호출되어 반영시키도록 해준다.
layoutIfNeeded
다음 UpdateCycle
까지 기다리는 게 아니라 호출 즉시, layoutSubViews
를 호출하여 반영해주도록 하는데 혹여 뷰를 재조정할 필요가 없을 경우에는 layoutSubViews
가 호출되지 않는다. 앞서 나온 setNeedsLayout
과는 달리 부하가 다소 있긴하지만 애니메이션 하는 상황에서 유용하다.
이처럼 layoutIfNeeded
를 알아보기 위해 더욱 깊고 많은 내용들을 이해할 필요가 있었다. 결국 레이아웃을 구현하는 데에 있어 어려움을 느꼈던 것은 이같은 배경을 모르고 접근해서 더욱 크게 와닿았던 것이었다. 이외에도 Display
나 Constraint
와 관련해서도 정리된 내용들이 많지만 갈 길이 멀다보니 이미지로 대체하고자 한다.
각 영역 별로 메서드의 작동 방식을 표로 정리한 이미지이다.
또한 앞서 설명한 메서드들이 하나의 사이클동안 어떻게 이뤄지는지 정리한 이미지이다.
Layout
외의 것들을 더 자세하게 보고 싶다면 여기의 포스팅을 참조하면 좋을 듯 하다.
위의 학습을 통해 전반적인 레이아웃의 흐름을 파악하였고 이제는 내가 생각한 방법대로 적용했을 때, 올바르게 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
를 호출하다보니 부하가 걸린다고 했다.
여기서 이 부하가 얼마나 걸리는지는 정확하게 알 수 없었지만 뭔가 나를 찝찝하게 만들기에는 충분하였다. 결국 이 방법에서 조금 더 효율적으로 다듬어볼 수 있지 않을까라는 고민을 하게 만들었고, 결국 마지막으로 한 번 더 삽질을 해보고자 결심했다.
위에 정리된 내용대로 작업을 진행하며 디버깅을 찍어보다보니 상위 ViewController
의 viewDidLayoutSubviews
가 호출된 이후에 하위 뷰의 layoutSubViews
가 호출되는 흐름을 확인할 수 있었다. 분명 여러 자료에서는 viewWillLayoutSubViews
-> layoutSubViews
-> viewDidLayoutSubViews
의 흐름이라고 명시되어 있는데 본인의 프로젝트에서 디버깅하니 layoutSubViews
가 마지막에 호출되고 있는데 무엇때문에 이런 흐름을 띄고 있는 건지 아직 파악되지 않았다.
콜백 함수라는 부분에 대해 잘못 이해한 건가 싶었지만, 그저 뷰의 레이아웃이 변화했다는 것에 대한 유일한 콜백이라는 거지 viewDidLayoutSubViews
의 콜백 함수로 layoutSubViews
가 존재하는 것은 아니었다. 혹시 이 부분에 대해 같은 고민이나 해결이 있었던 분이 있을 경우 알려주시면 좋을 것 같습니다..ㅠㅠ