Dummy cell을 이용하여, UICollectionViewCell의 동적 높이 구해보자.


들어가며

Github 모바일앱과 같은 이슈 관리 앱 개발 프로젝트를 진행하면서 이슈목록을 UICollectionView로 구현하면서, 이슈 데이이터에 따라 동적 높이를 가진 UICollectionViewCell을 구현하고 싶었습니다. 컬렉션뷰 셀의 동적 높이를 구현하는 것은 dynamic height라는 키워드로 검색해보면 쉽게 찾아서 구현할 수 있습니다. 하지만 대부분의 예제는 텍스트나 이미지 등과 같은 컨텐츠의 높이를 계산해서 적용해주는 것이었습니다.

저는 컬렉션뷰 셀이 하위 객체로 동적으로 높이가 변할 수 있는 UICollectionView를 가지는 구조를 구현하고 싶었습니다. 저는 Dummy cell을 사용한 사이즈 계산법을 사용했습니다. 주변의 좋은 조언과 삽질 끝에 완성을 했고 이를 어떻게 구현했는지 공유해보려고 합니다. 😀


구현 화면

Github처럼 이슈에 레이블을 붙일 수 있는 기능이 있으며, 이슈에 레이블이 많아져 레이블이 차지하는 공간이 많아지면 이에 따라 동적으로 셀 높이를 결정하도록 구현하였습니다.


IssueCell 레이아웃 구성

IssueCellIssuesCollectionView의 셀로 등록되어있으며 구성은 아래와 같습니다.

ContentsStackView

IssueCell의 주요 subview로는 커스텀 UIStackViewContentsStackView가 있으며, 이슈에 관한 정보들을 나타내는 뷰들을 담고 있습니다. 가장 상단에 이슈의 제목을 나타내는 TitleLabel과 바로 아래 작성자 정보를 나타내는 WriterLabel은 커스텀 UILabelIssueCell에서 항상 default로 존재하는 뷰 요소들입니다. (이 외의 뷰 요소들은 dynamic height를 구현하는데 있어서 큰 영향이 없기 때문에 설명하지 않겠습니다.)

IssueLabelsViewController

레이블을 나타내는 클래스는 IssueLabelsViewControllerUICollectionView가 아닌 UIViewController로 구현하였는데, 그 이유는 UICollectionView로 구현하게 되면, 레이블을 표시하기 위해서 UICollectionViewDataSource, UICollectionViewDelegateFlowLayout와 같은 프로토콜을 채택하여 데이터를 주입하는 것과 같은 동작을 구현해야 합니다. 뷰는 재사용을 고려하여 UI를 구성하고 데이터를 표시해주는 일만 처리하기 위해서 IssueLabelsViewController라는 컨트롤러가 일을 처리하도록 구현하였습니다.

IssueLabelsViewControllerUIStackView에 arrangedSubview로 추가하기 위해서는 issueLabelsViewController.viewaddArrangedSubview() 메소드를 통해 추가하면 됩니다.


IssueCell layout

이후에 더 자세하게 설명드리겠지만, 동적 높이를 조금 더 편하게 구현하기 위해서 UILayout을 모두 코드로 구현하였습니다. ContentsStackViewIssueCell의 높이를 결정하는 것에 영향을 가장 많이 주기 때문에 ContentsStackViewIssueCell에 간 오토레이아웃을 꼭 주어야합니다.

IssueCell이라는 UICollectionViewCell을 상속받아서 만든 클래스를 코드로 구현했습니다.


IssueCell 사이즈 결정 시점

동적 높이를 구현하기 위해서는 UICollectionviewCell의 사이즈가 결정되는 시점을 알아두면 좋습니다. 간단하게 아래와 같습니다.

  • 첫번째로, UICollectionViewDataSourcecollectionView(_:numberOfItemsInSection:) 메소드를 통해 먼저 몇개의 셀이 있는지 확인합니다.
  • 다음으로, item의 개수가 0개가 아니라면 UICollectionView는 셀의 사이즈를 알기 위해서 UICollectionViewDelegateFlowLayout를 채택한 객체에 구현된 collectionView(_:layout:sizeForItemAt:) 메소드에서 반환한 사이즈를 통해 셀의 사이즈를 결정합니다.
  • 마지막으로, DataSource는 collectionView(_:cellForItemAt:) 메소드를 통해 indexPath에 맞는 cell을 반환합니다.


Dummy cell을 통한 동적 높이 구하기

UICollectionViewCell의 동적 높이를 구할 수 있는 방법 중 하나로, 실제로는 사용하지 않을 dummy cell의 객체를 만들어서 size만 가져와서 사용하는 것입니다. Dummy cell의 객체를 직접 만들어서 사용해야하는 부분 때문에 IssueCell의 UI를 모두 코드로 구현하였습니다.


Dummy cell 만들기

먼저 dummy cell을 만들기 위해 IssueCell 객체를 생성해줍니다. 여기서 IssueCell을 생성할 때, CGRect 타입을 파라미터로 받는 생성자를 사용하여 생성하려는 셀의 높이는 늘어날 컨텐츠의 사이즈를 고려하여 넉넉하게 잡아주면 됩니다.

예제로는 제가 만들고 싶은 IssueCell의 width는 IssuesCollectionView 상위 뷰컨트롤러의 width의 0.9로 고정되어있으며, 높이는 300이 넘지 않을 것이기 때문에 estimatedHeight에 300 값을 저장하여 dummy cell의 사이즈를 설정해주었습니다.

func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        sizeForItemAt indexPath: IndexPath) -> CGSize {
  
        let width = view.frame.width * 0.9
        let estimatedHeight: CGFloat = 300.0
        let dummyCell = IssueHorizontalCell(
            frame: CGRect(x: 0, y: 0, width: width, height: estimatedHeight))


Dummy cell에 실제 데이터 넣기

이제 실제 cell보다 넉넉하게 만들어진 dummy cell에 실제 데이터를 넣어줘야합니다. 현재 IssuesCollectionView의 dataSource인 IssuesCollectionViewDataSource는 분리된 클래스로 뷰모델 역할을 하기 때문에 서버로부터 이슈 정보를 가져오면 IssuesCollectionView를 reload 하도록 바인딩되어있습니다.

이슈 데이터는 모두 dataSource가 가지고 있기 때문에 dataSource에 있는 실제 데이터를 가져오기 위해서 referIssue(at:handler:) 메소드를 사용합니다. handler라는 클로저 매개변수를 사용하여 dataSource의 특정 indexPath의 이슈 데이터를 사용할 수 있습니다.

func referIssue(at indexPath: IndexPath, handler: (Issue) -> Void) {
    let issue = issues[indexPath.item]
    handler(issue)
}


DataSource를 통해 데이터를 사용하는 구조는 다음과 같습니다.

가져온 이슈 데이터로 dummy cell을 구성해주기 위해 IssueCellconfigureCell(with:) 메소드를 호출합니다. 실제 정보를 기반으로 셀을 구성해주는 메소드입니다. ContentsStackView에 있는 Title, Writer를 업데이트하고, 가장 중요한 레이블들을 업데이트해주는 작업들을 실행하게 됩니다.


내부 IssueLabelsCollectionView height 잡기

Dummy cell을 실제 데이터로 업데이트 해줄 때에 가장 중요한 부분은 유동적으로 변하는 개수를 가진 레이블들을 담는 IssueLabelsCollectionView입니다. 레이블을 몇개를 가지고 있느냐에 따라서 높이가 변경되기 때문입니다.

IssueLabelsCollectionViewIssueLabelsViewController의 하위 객체로 전달받은 이슈 데이터를 기반으로 업데이트하여 정확한 높이를 결정해야합니다.

따라서 IssueCellconfigureCell(with:)에서 아래와 같이 두개의 메소드 호출을 통해 이슈 데이터의 레이블 정보를 전달해주고 IssueLabelsCollectionViewreload 해줍니다.

issueLabelsViewController.updateLabels(issue.labels)
issueLabelsViewController.reloadCollectionView()


위 과정을 마쳤다면 IssueLabelsViewControllerIssueLabelsCollectionView는 indexPath에 맞는 라벨들의 정보를 정상적으로 표시합니다. 이제 이 뷰컨트롤러를 ContentsStackView에 추가해줍니다.

contentsStackView.addArrangedSubview(issueLabelsViewController.view)


UIStackView에 추가되면 자동적으로 UIStackView 내부에서 auto layout이 적용되는데, 여기서 IssueLabelsCollectionView의 크기만큼 UIStackView에서 자리를 잡을 수 있도록 아래와 같이 issueLabelsViewController.view의 heightAnchor를 명시해줍니다.

layoutIfNeeded()
issueLabelsHeightConstraint = issueLabelsViewController.view.heightAnchor.constraint(
    equalToConstant: issueLabelsViewController.contentHeight)
issueLabelsHeightConstraint.isActive = true

issueLabelsViewController.contentHeight는 computed property로 IssueLabelsCollectionView의 contentSize.height를 반환합니다.

issueLabelsHeightConstraint라는 변수로 따로 프로퍼티로 사용하는 이유는 IssueCell이 재사용될 때 heightAnchor constraint를 중복으로 지정해주기 때문에 이를 방지하기 위해 사용하였습니다.


위와 같은 작업은 이슈 데이터의 레이블이 있을 때에만 적용되어야 합니다. 그렇기 때문에 guard 문으로 레이블이 있을 때에만 실행될 수 있도록 처리해주었습니다.

이슈의 레이블 뷰컨트롤러의 사이즈를 결정하는 전체 코드를 보면 아래와 같습니다.

private func configureIssueLabels(with issue: Issue) {
    guard issue.labels.count > 0 else { return }
    issueLabelsViewController.updateLabels(issue.labels)
    issueLabelsViewController.reloadCollectionView()
    contentsStackView.addArrangedSubview(issueLabelsViewController.view)
    layoutIfNeeded()
    issueLabelsHeightConstraint = issueLabelsViewController.view.heightAnchor.constraint(
        equalToConstant: issueLabelsViewController.contentHeight)
    issueLabelsHeightConstraint.isActive = true
}


EstimatedSize 만들기

이제 dummyCell의 내부 컨텐츠의 사이즈의 정확한 값도 정해졌기 때문에 실제로 반환할 cell의 사이즈를 결정해야합니다.

실제 이슈 데이터를 통해 업데이트된 dummy cell을 layoutIfNeeded()systemLayoutSizeFitting(_:) 메소드를 사용하여 예상 사이즈를 잡아줄 것입니다.

systemLayoutSizeFitting(_:)view의 현재 constraints를 기반으로 최적의 사이즈를 반환하는 메소드입니다. 그렇기 때문에 넉넉하게 생성되었던 dummy cell을 현재 constraints에 가장 딱 맞는 사이즈로 반환해줍니다. 파라미터에는 뷰에 희망하는 사이즈를 넣게 되어있습니다. 처음 dummy cell을 만들 때 설정해주었던 사이즈를 사용하면 됩니다. 반환된 CGSize값은 estimatedSize 지역변수로 저장합니다.

dummyCell.layoutIfNeeded()
let estimatedSize = dummyCell.systemLayoutSizeFitting(
    CGSize(width: width, height: estimatedHeight))


collectionView(_:layout:sizeForItemAt:)의 전체 코드는 아래와 같습니다.

func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        sizeForItemAt indexPath: IndexPath) -> CGSize {
  
        let width = view.frame.width * 0.9
        let estimatedHeight: CGFloat = 300.0
        let dummyCell = IssueCell(
            frame: CGRect(x: 0, y: 0, width: width, height: estimatedHeight))
        dataSource.referIssue(at: indexPath) { (issue) in
            dummyCell.configureCell(with: issue)
        }
        dummyCell.layoutIfNeeded()
        let estimatedSize = dummyCell.systemLayoutSizeFitting(
            CGSize(width: width, height: estimatedHeight))
        return CGSize(width: width, height: estimatedSize.height)
    }


마치며

UICollectionViewCell의 동적 높이를 구현해보았습니다. 사실 UICollectionViewLayout을 사용해서 구현해보고 싶었는데, 아직은 조금 더 공부를 해봐야할 것 같습니다. 😅

IssueLabelsCollectionView의 label cell도 텍스트 길이에 따라 동적으로 width가 변동되는데, 이 내용까지 넣으면 너무 길어질 것 같아서 넣지 않았습니다. 추후에 시간이 되면 추가 작성하여 링크를 걸 예정입니다. 피드백은 언제나 환영합니다! 긴 글 읽어주셔서 감사합니다. 👦🏻


도움 주신 감사한 분들

  • Mason
  • Jinie
  • Delma
  • Gangwoon


References

댓글남기기