[Popcorn] UICollectionViewCompositionalLayout을 적용한 메인화면 구현기

Minw·2025년 3월 1일

프로젝트-팝콘

목록 보기
5/5
post-thumbnail

배경

이미지1이미지2

Popcorn 팀은 위 사진과 같은 메인화면을 구현하기 위해 UICollectionViewCompositionalLayout을 사용하였습니다.

UICollectionViewCompositionalLayout에 관련된 내용은 아래로!

UICollectionViewCompositionalLayout 정리

메인화면에서의 Layout

메인화면에서 작성한 컬렉션 뷰 레이아웃 관련 코드는 아래와 같습니다.

1. 컬렉션 뷰 정의 부분

final class MainSceneViewController: UIViewController {
		...
    private lazy var mainCollectionView = UICollectionView(
        frame: .zero,
        collectionViewLayout: generateCollectionViewLayout()
    )
    ... 
}

generateCollectionViewLayout() 이라는 메서드를 호출하여 컬렉션 뷰의 레이아웃을 지정합니다.

2. generateCollectionViewLayout()

private func generateCollectionViewLayout() -> UICollectionViewCompositionalLayout {
    return UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
        guard let self else { return nil }

        switch sectionIndex {
        case 0:
            return generateHorizontalLayout(isPickSection: true)
        case 1..<(1 + self.mainViewModel.getDataSource().numbersOfInterest()):
            return generateHorizontalLayout()
        case self.mainViewModel.getDataSource().numbersOfInterest() + 1:
            return generateVerticalGridLayout()
        default:
            return generateHorizontalLayout()
        }
    }
}

UICollectionViewCompositionalLayout의 생성자 중 아래 사진의 주황색 박스 생성자를 이용하여, 섹션 별로 다른 레이아웃을 적용하는 메서드입니다.

  • UICollectionViewCompositionalLayout의 다양한 생성자
    생성자섹션 동적 제공전역설정사용 용도
    init(section:)XX단일 섹션, 기본 레이아웃
    `init(section:
    configuration)`XO단일 섹션, 스크롤 방향/간격 등 추가 설정 필요할 때
    init(sectionProvider:)OX다중 섹션, 섹션 별 레이아웃 분기 필요할 때
    `init(sectionProvider:
    configuration`OO다중 섹션, 섹션 별 레이아웃과 추가 설정 필요할 때
    • UICollectionViewCompositionalLayoutConfiguration
      • 스크롤 방향, 섹션 스페이싱, 헤더와 푸터의 레이아웃을 정의하는 객체.

3. generateHorizontalLayout()

private func generateHorizontalLayout(isPickSection: Bool = false) -> NSCollectionLayoutSection {
    let itemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: isPickSection ? .estimated(160) : .estimated(180)
    )
    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    let groupSize = NSCollectionLayoutSize(
        widthDimension: isPickSection ? .fractionalWidth(140/393) : .fractionalWidth(160/393),
        heightDimension: isPickSection ? .estimated(160) : .estimated(180)
    )
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = NSDirectionalEdgeInsets(top: 35, leading: 26, bottom: 25, trailing: 0)
    section.interGroupSpacing = 10

    section.orthogonalScrollingBehavior = .continuous

    let headerSize: NSCollectionLayoutSize

    if isPickSection {
        headerSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalWidth(446/393))
    } else {
        headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(44))
    }

    let headerSupplementary = NSCollectionLayoutBoundarySupplementaryItem(
        layoutSize: headerSize,
        elementKind: UICollectionView.elementKindSectionHeader,
        alignment: .top
    )
    section.boundarySupplementaryItems = [headerSupplementary]
    section.supplementaryContentInsetsReference = .none

    return section
}

1) 아이템과 그룹의 heightDimension

셀 구현 시 팝업 스토어의 제목 레이블의 numberOfLines 프로퍼티에 0을 할당하였으므로, 텍스트 길이에 따라 레이블의 줄 수가 동적으로 늘어나도록 구현하였습니다.

이를 위해 itemheightDimension.estimated 로 설정하여 컨텐츠 크기(레이블의 텍스트 길이에 따른 줄 수)에 따라 높이를 동적으로 계산하도록 구현하였습니다.

구현하면서 발생한 문제

문제 상황
UICollectionViewCompositionalLayout은 섹션 → 그룹 → 아이템 순으로 크기를 계산합니다.

따라서 첫 시도에서는 group의 높이를 estimated(178)로, item의 높이를 fractionalHeight(1.0)로 설정해도 올바르게 동작할 것이라고 생각했습니다.

그러나, 레이블의 텍스트 길이가 한 줄 이상일 경우 레이블이 ...으로 잘리게 되는 문제가 발생했습니다.

원인 분석

  • estimated(178)은 레이블이 한 줄일 때의 높이로 설정된 값입니다.
  • 하지만 여러 줄의 텍스트가 포함된 셀이 들어오면, 공간이 부족하여 텍스트가 잘리게(truncated) 됩니다.

estimated는 콘텐츠 크기를 기반으로 실제 크기를 동적으로 계산합니다.
그러나, 위의 방법은 그룹의 높이를 먼저 설정한 뒤, 그룹 높이를 기준으로 아이템의 높이를 계산합니다.
이로 인해, 아이템이 동적으로 높이를 계산할 여지를 잃게 되었고, 결과적으로 레이블의 텍스트가 한 줄로 잘리는 문제가 발생했습니다.

해결책

  • itemgroup의 높이를 모두 estimated로 설정합니다.
  • 이렇게 하면, 레이블의 높이에 따라 아이템의 높이가 계산되고, 이후 아이템 높이를 기준으로 그룹의 높이가 계산됩니다.

이를 통해, 레이블의 텍스트가 여러 줄로 동적으로 표시되도록 구현할 수 있습니다.

2) 아이템과 그룹의 widthDimension

그룹 사이즈를 fractionalWidth로 줄 경우 어떤 기준으로 크기가 결정될까?

그룹의 widthDimensionfractionalWidth로 줄 경우 섹션의 크기를 기준으로 그룹의 크기가 잡히게 됩니다. generateHorizontalLayout 메서드의 경우 가로 스크롤을 설정하는 코드가 있으므로, 섹션의 가로 크기는 컨텐츠에 따라 동적으로 변하게 되어 기준이 무엇인지에 대한 궁금증이 생겼습니다.
아래와 같이 groupSize의 withDimension에 fractionalWidth(1)를 지정해보니, 아래와 같이 화면에 꽉 차는 크기였습니다.

    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(178))

따라서 그룹 사이즈를 결정짓는 컨테이너의 크기는 컨텐츠 영역이 아닌 화면에 보이는 컬렉션 뷰의 영역임을 알 수 있었습니다.

가로 스크롤이 활성화 된 경우에도 그룹 사이즈를 fractional로 줄 경우, 컬렉션 뷰의 보이는 영역의 크기가 기준이 되므로, fractionalWidth로 설정하여 셀의 크기를 결정하였습니다.

3) section.contentInsets

이미지1이미지2
contentInsets을 지정하지 않았을 때contentInset을 지정하였을 때

섹션과 섹션의 경계 사이의 여백을 주기 위해 contentInsets을 지정할 수 있습니다.
만약 contentInsets을 지정하지 않을 시 아래와 같이 헤더와 컬렉션 뷰에 착 달라붙게 됩니다.
따라서 디자인 요구사항을 반영하기 위해 contentInsets을 지정하였습니다.

4) section.interGroupSpacing

말 그대로 그룹간의 여백을 주기 위한 프로퍼티입니다.
현재 그룹은 팝업스토어 셀 하나이므로, 셀 간의 간격을 주기 위해 사용하였습니다.

5) 헤더 레이아웃 지정하기

  1. 헤더의 크기 설정

    let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(44))

    NSCollectionLayoutSize를 이용하여, 아이템, 그룹의 사이즈를 잡는 것과 같이 크기를 설정합니다.

  2. 헤더 아이템 생성

    let headerSupplementary = NSCollectionLayoutBoundarySupplementaryItem(
        layoutSize: headerSize,
        elementKind: UICollectionView.elementKindSectionHeader,
        alignment: .top
    )

    NSCollectionLayoutBoundarySupplementaryItem로 헤더 아이템을 생성합니다.

    • layoutSize: 위에서 정의한 헤더의 사이즈를 사용합니다.
    • elementKind: 헤더를 구별하기 위한 식별자입니다. 이 값은 데이터 소스에서 헤더를 등록하거나 반환할 때 사용합니다.
      UICollectionView.elementKindSectionHeader는 시스템에서 기본적으로 제공하는 헤더의 kind입니다.
    • alignment: .top: 헤더가 섹션의 상단에 배치되도록 설정합니다.
  3. 헤더를 섹션에 추가

    section.boundarySupplementaryItems = [headerSupplementary]

    boundarySupplementaryItems: 섹션에 헤더와 푸터같은 추가적인 decorationView를 정의하는 배열입니다. 이 배열에 위에서 정의한 headerSupplementary를 추가하여, 이 섹션이 헤더를 포함하도록 설정합니다.

  4. 헤더의 컨텐츠 인셋 참조 방식

    section.supplementaryContentInsetsReference = .none

    supplementaryContentInsetsReference: 헤더 및 푸터에 적용되는 인셋 참조 방식을 정의합니다.

    .none으로 설정 시 섹션의 contentInsets이 헤더에 영향을 미치지 않습니다.
    기본값은 .automatic 으로, 이 경우 섹션의 contentInsets이 헤더에 동일하게 적용됩니다.

    디자인 상 섹션에는 인셋이 필요하지 않으므로 .none으로 설정하였습니다.

4. generateVerticalGridLayout()

아래 코드는 지금 놓치면 안 될 팝업스토어의 레이아웃을 위한 코드입니다.

private func generateVerticalGridLayout() -> NSCollectionLayoutSection {
    let itemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(0.5),
        heightDimension: .estimated(260)
    )
    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(260))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, item])
    group.interItemSpacing = NSCollectionLayoutSpacing.fixed(9)

    let section = NSCollectionLayoutSection(group: group)
    section.interGroupSpacing = 35
    section.contentInsets = NSDirectionalEdgeInsets(top: 35, leading: 25, bottom: 0, trailing: 25)

    let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(44))
    let headerSupplementary = NSCollectionLayoutBoundarySupplementaryItem(
        layoutSize: headerSize,
        elementKind: UICollectionView.elementKindSectionHeader,
        alignment: .top
    )
    section.boundarySupplementaryItems = [headerSupplementary]
    section.supplementaryContentInsetsReference = .none

    return section
}

1) group의 subitems

위와 같은 디자인을 구현하기 위해, 그룹의 서브 아이템을 명시적으로 두 개로 설정하였습니다.

그룹의 서브 아이템을 하나로 두어도 크기에 따라 자동으로 2개로 설정되지만, 명시적으로 설정하는 것이 안전하다고 생각되었습니다.

2) group.interItemSpacing

그룹 내의 아이템들의 간격을 지정하는 프로퍼티입니다.
피그마에 나온 간격에 따라 고정된 간격 9로 설정하였습니다.

후기

UICollectionViewCompositionalLayout을 이용해 메인화면을 구현하면서, 아이템과 그룹의 크기 설정이 레이아웃 동작에 미치는 영향을 깊이 이해할 수 있었습니다. 특히 estimated 값을 활용하여 동적으로 크기를 조정하는 방식과, fractionalWidth가 컬렉션 뷰의 보이는 영역을 기준으로 결정된다는 점등을 알 수 있었습니다.

또한, 각 섹션의 contentInsets, interGroupSpacing, interItemSpacing 설정 변경 시 어떤 일이 일어나는지 직접 확인할 수 있어 좋은 기회였습니다.

0개의 댓글