UICollectionViewCompositionalLayout

나오게 된 이유

iOS6에서 출시된 UICollectionViewLayout을 통한 추상화로 인해 LineBasedLayout으로 레이아웃을 쉽게 할 수 있었으나, 시간이 지나면서 점점 복잡해짐. 따라서 기존 lineBasedLayout을 사용하지 않고 사용자 정의 레이아웃을 구축해야 하는데, 이를 구현하는 데는 까다로워 이를 위해 나온 레이아웃이 Compositional Layout임.

CompositionalLayout 기본 구성

Layout: 콜렉션 뷰의 레이아웃

Section: 각 섹션

Group: 각 섹션의 행. 앞으로 반복하게 될 반복 구조, 항목의 열 또는 행을 나타냄.

item: 콜렉션 뷰의 셀

크기 정의

각 요소의 크기를 지정하기 위해 Compositional Layout은 NSCollectionLayoutDimension이라는 타입을 이용해 지정한다.

NSCollectionLayoutDimension

특정 축의 크기를 정의하는 방식. 총 4가지 방식이 있다.

  • .fractionalWidth/Height: 콜렉션 뷰 크기의 종횡비로 크기를 지정
  • .absolute: 상수를 이용하여 크기를 지정. 고정된 크기
  • .estimated: 예측한 크기

NSCollectionLayoutItem

아이템의 크기를 정의하는 레이아웃

NSCollectionLayoutGroup

그룹의 사이즈와 레이아웃을 지정. horizontal, vertical을 이용해서 line-based처럼 정의하거나, Custom 하게 레이아웃을 잡을 수 있다.

NSCollectionLayoutSection

섹션별 레이아웃 정의.

그리드 레이아웃

이렇게 2개의 열을 가진 레이아웃을 잡으려고 했을 때 2가지 방식으로 구현 할 수 있다.

func createLayout() -> UICollectionViewLayout {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5),
                                              heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(44))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                       subitems: [item])
        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)
        let layout = UICollectionViewCompositionalLayout(section: section)
        return layout
    }

첫 번째 방식으로 item의 width를 0.5로 두어 group에 2개의 셀이 들어갈 수 있게 크기를 잡는 방법이 있고,

let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                             heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)

let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                              heightDimension: .absolute(44))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 2)

이런 식으로 group를 생성할 때 count를 이용해 그룹 내부에 들어갈 아이템의 개수를 정의한다. 이때 아이템의 사이즈를 0.5로 설정한다고 하더라도, group을 통해 재정의 한 것으로 쳐서 아이템의 사이즈는 그룹의 개수에 맞게 계산된다.

두 방식의 차이점

첫번째 방식으로 구현했을 경우 inset을 주려고 하면 item의 사이즈가 group의 0.5로 설정되어 있어 다음과 같이 화면이 출력된다.

let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                       subitems: [item])
let spacing = CGFloat(10)
group.interItemSpacing = .fixed(spacing)

이유는 width가 group을 기준으로 잡혀있기에 아이템을 하나의 그룹에 inset을 포함해 2개를 넣을 수 없기 때문이다.

하지만 두 번째 방식의 경우에는

이런 식으로 2개의 아이템이 하나의 그룹 내부에 들어간 것을 볼 수 있다. 앞서 말했듯이 그룹에 의해 재정의 됨으로써 아이템의 사이즈가 다시 계산되었기 때문이다.

섹션별 다른 레이아웃

이런 식으로 섹션별로 다른 레이아웃을 지정하고 싶을 때 UICollectionViewCompositionalLayoutSectionProvider 클로저를 이용하면 각 섹션별로 레이아웃을 을때 해당 클로저가 호출되면서 인자값으로 섹션 index 값이 들어오므로 더 쉽게 레이아웃을 잡을 수 있다.

enum SectionLayoutKind: Int, CaseIterable {
        case list, grid5, grid3
        var columnCount: Int {
            switch self {
            case .grid3:
                return 3

            case .grid5:
                return 5

            case .list:
                return 1
            }
        }
    }
let layout = UICollectionViewCompositionalLayout { (sectionIndex: Int,
            layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in

            guard let sectionLayoutKind = SectionLayoutKind(rawValue: sectionIndex) else { return nil }
            let columns = sectionLayoutKind.columnCount
            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                 heightDimension: .fractionalHeight(1.0))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            item.contentInsets = NSDirectionalEdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2)

            let groupHeight = columns == 1 ?
                NSCollectionLayoutDimension.absolute(44) :
                NSCollectionLayoutDimension.fractionalWidth(0.2)
            let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: groupHeight)
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: columns)

            let section = NSCollectionLayoutSection(group: group)
            section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20)
            return section
        }
        return layout
    }

Decoration Item

콜렉션 뷰 셀에 있는 배지 같은 셀에 표시를 할 때 사용한다.

사용방식은 decoration의 위치를 설정하고, 사이즈와 decoration을 구분할 고유한 식별자를 넣어주면 된다. 고유한 식별자는 공식문서에서는 이런 식으로 쉽게 추적할 수 있게 하는 것을 추천하고 있다.

struct ElementKind {
    static let badge = "badge-element-kind"
    static let background = "background-element-kind"
    static let sectionHeader = "section-header-element-kind"
    static let sectionFooter = "section-footer-element-kind"
    static let layoutHeader = "layout-header-element-kind"
    static let layoutFooter = "layout-footer-element-kind"
}
let badgeAnchor = NSCollectionLayoutAnchor(edges: [.top], fractionalOffset: CGPoint(x: 0.3, y: -0.3))
        let badgeSize = NSCollectionLayoutSize(widthDimension: .absolute(20),
                                              heightDimension: .absolute(20))
        let badge = NSCollectionLayoutSupplementaryItem(
            layoutSize: badgeSize,
            elementKind: ItemBadgeSupplementaryViewController.badgeElementKind,
            containerAnchor: badgeAnchor)

        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.25),
                                             heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize, supplementaryItems: [badge])

section header, footer

header와 footer 역시 사용법이 비슷하다.

let headerFooterSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                     heightDimension: .estimated(44))
        let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: headerFooterSize,
            elementKind: SectionHeadersFootersViewController.sectionHeaderElementKind, alignment: .top)
        let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: headerFooterSize,
            elementKind: SectionHeadersFootersViewController.sectionFooterElementKind, alignment: .bottom)
        section.boundarySupplementaryItems = [sectionHeader, sectionFooter]

섹션별 decorationView

각 섹션마다 커스텀한 뷰를 백그라운드로 표시 할 수 있다. 사용법은 각 뷰의 식별자를 이용하여 생성한 후, layout에 register 한다.

let section = NSCollectionLayoutSection(group: group)
        section.interGroupSpacing = 5
        section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
        
        let sectionBackgroundDecoration = NSCollectionLayoutDecorationItem.background(
            elementKind: SectionDecorationViewController.sectionBackgroundDecorationElementKind)
        sectionBackgroundDecoration.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)
        section.decorationItems = [sectionBackgroundDecoration]

        let layout = UICollectionViewCompositionalLayout(section: section)
        layout.register(
            SectionBackgroundDecorationView.self,
            forDecorationViewOfKind: SectionDecorationViewController.sectionBackgroundDecorationElementKind)

그룹 중첩

Compositional layout의 핵심은 group. group은 중첩이 가능하며 이를 이용해 복잡한 레이아웃을 만들 수 있다.

이런 식으로 그룹을 만들어서 파란색 그룹에 붉은색 그룹과 item을 추가한다.

let layout = UICollectionViewCompositionalLayout {
            (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in

            let leadingItem = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.7),
                                                  heightDimension: .fractionalHeight(1.0)))
            leadingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

            let trailingItem = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .fractionalHeight(0.3)))
            trailingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
            let trailingGroup = NSCollectionLayoutGroup.vertical(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.3),
                                                  heightDimension: .fractionalHeight(1.0)),
                subitem: trailingItem, count: 2)

            let nestedGroup = NSCollectionLayoutGroup.horizontal(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .fractionalHeight(0.4)),
                subitems: [leadingItem, trailingGroup])
            let section = NSCollectionLayoutSection(group: nestedGroup)
            return section

        }

콜렉션뷰 중첩

콜렉션 뷰 내부의 섹션을 다른 방향으로 스크롤 하려면, 각 섹션이나 셀에 콜렉션 뷰를 추가하여 각 콜렉션 뷰마다의 코드가 필요했지만, Compositional Layout을 사용하면 이를 최상위 뷰 하나로 병합 시킬 수 있음.

코드 역시 간단하게 각 섹션에 적용할 behavior을 적용해주기만 하면 됨.

func createLayout() -> UICollectionViewLayout {
        let layout = UICollectionViewCompositionalLayout {
            (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? 
.
.
.
.
            let containerGroup = NSCollectionLayoutGroup.horizontal(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.85),
                                                  heightDimension: .fractionalHeight(0.4)),
                subitems: [leadingItem, trailingGroup])
            let section = NSCollectionLayoutSection(group: containerGroup)
            section.orthogonalScrollingBehavior = .continuous //적용

            return section

        }
        return layout
    }

NSCollectionLayoutSectionOrthogonalScrollingBehavior

각 섹션을 어떤 식으로 스크롤을 할지 결정할 enum. 스크롤은 해당 CollectionVIew의 layout의 scrollDirection의 수직이 되게 자동으로 적용이 된다. 콜렉션 뷰가 vertical 하게 스크롤 하도록 되어 있다면, section은 자동으로 horizontal하게 스크롤하도록 된다. 즉 콜렉션뷰가 vertical하게 스크롤 되도록 하고, section도 vertical하게 스크롤하지 못한다.

 

참고: https://developer.apple.com/videos/play/wwdc2019/215, https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views