[iOS] - iOS 13에서의 UICollectionViewCompositionalLayout 예시


Move your cells left to right, up and down on iOS 13


Move your cells left to right, up and down on iOS 13 — Part 1 Move your cells left to right, up and down on iOS 13 — Part 2

복잡한 데이터의 컬렉션을 아주 간단히 구성할 수 있는 두 가지 방법이 있다.

아래와 같은 화면을 보면, 화면 전체는 수직으로 스크롤이 가능한데, 맨 위에 있는 몇 행들은 가로로 스크롤이 가능한 것을 볼 수 있다.

1_ofe8nyZsPONYzgBvE0drSg

이렇게 가로와 세로로 스크롤이 되는 형태의 인터페이스를 구현하기 위해서는 iOS 13이전에는 UICollectionView를 중첩해야 했다. 이때 안의 UICollectionView를 밖의 UICollectionView의 셀로 지정해야 했을 것이다.(물론 밖의 뷰를 UITableView로 지정해줘도 된다.) 이런 구조는 괜찮지만, 각 collection view의 datasource / delegate를 지정해줘야 하기 때문에 코드가 지저분해 질 수 있다.

iOS 13은 이런 종류의 인터페이스를 구현할 수 있는 두 가지의 간단한 방법을 제시한다.

하나는 아주 멋진 SwiftUI를 쓰는 것이다. SwiftUI는 Swift로 사용자 인터페이스 코드를 작성할 수 있는 선언형 도메인 특화 언어이며, 우리가 오랜 시간동안 iOS 인터페이스를 구현하는 방법을 바꿨다고도 여겨진다.

다른 것은 덜 멋지지만(표현이 그렇다) 스코프가 크지 않은 상황에서도 적용하기 좋은 방법이다. iOS 13은 UICollectionViewComopositionalLayout이라는 UiCollectionView에 적용할 수 있는 새로운 레이아웃을 소개한다. 이 레이아웃은 각 섹션마다 다른 레이아웃을 쉽게 제공할 수 있게 해준다.

Sample Demo

아래와 같은 화면을 만들어볼 것이다. 마음에 드는 예시다.

cat

간단한 고양이 사료 광고 앱이다. 첫 번째 섹션에는 브랜드 이름의 리스트를 가로 스크롤로 보여줄 것이고, 두 번째 섹션에는 마찬가지로 가로 스크롤로 캔의 이미지들을 보여줄 것이다. 마지막 섹션은 고양이들이 음식을 먹고 있는 귀여운 사진들을 세로로 스크롤링 해서 보여줄 것이다.

UICollectionViewCompositionalLayout

먼저, UICollectionViewCompositionalLayout이 뭔지부터 알아보자. 이미 UICollectionView, UICollectionViewFlowLayout이 무엇인지는 알고 있다고 가정할 것이다. 위의 화면 예제에서는

  • 브랜드 이름을 담고 있는 배열
  • 고양이 음식 이미지를 담고 있는 배열
  • 고양이 이미지를 담고 있는 배열

이 있다. 그리고 datasource는 배열에 있는 아이템마다 하나의 UICollectionViewCell 인스턴스를 제공한다. 그러면 collection view는 셀들을 세로 축을 따라 레이어링 하기 위해 UICollectionViewFlowLayout을 사용한다. 그리고 결과는 아래와 같을 것이다.

cat2

전체 뷰는 세로 축으로 잘 스크롤링 되지만, 처음 두 섹션은 컨텐츠의 가로가 뷰의 가로와 일치하기 때문에 가로로 스크롤링 할 수 없는 상태다.

Constructor

이 레이아웃을 UICollectionViewCompositionalLayout으로 바꾸기 위해, 먼저 UICollectionViewCompositionalLayout 인스턴스를 만들어준다.

private lazy var compositionalLayout: UICollectionViewCompositionalLayout = {
    let layout = UICollectionViewCompositionalLayout { [weak self]
        (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
        switch Section(rawValue: sectionIndex) {
        case .brandNames:
            return self?.setupBrandNamesSection()
        case .catFoods:
            return self?.setupCatFoodsSection()
        case .cats:
            return self?.setupCatsSection()
        case .none:
            fatalError("Should not be none ")
        }
    }
    return layout
}()

override func viewDidLoad() {
    super.viewDidLoad()

...
    collectionView.collectionViewLayout = compositionalLayout
...
}

Delegate 패턴을 사용하게끔 강제했던 전통적인 UITableVIew/UICollectionView API와는 다르게, 우리는 UICollectionViewCompositionalLayout을 생성할 때 클로저를 제공할 수 있다. 클로저는 섹션의 인덱스와 environment를 받고, 특정 섹션의 레이아웃을 리턴해준다. switch 구문에서 우리는 setup...Section() 메서드를 통해 레이아웃 정보(나 사이즈 정보)를 반환한다.

Brand names section

func setupBrandNamesSection() -> NSCollectionLayoutSection {
    // 1. Creating section layout. Item -> Group -> Section
    // Item
    let item = NSCollectionLayoutItem(
        layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                           heightDimension: .fractionalHeight(1.0)))
    item.contentInsets = NSDirectionalEdgeInsets(top: 0.0,
                                                 leading: 8.0,
                                                 bottom: 0.0,
                                                 trailing: 8.0)

    // Group
    let group = NSCollectionLayoutGroup.vertical(
        layoutSize: NSCollectionLayoutSize(widthDimension: .estimated(136),
                                           heightDimension: .absolute(44)),
        subitem: item,
        count: 1)

    // Section
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = NSDirectionalEdgeInsets(top: 16.0,
                                                    leading: 0.0,
                                                    bottom: 16.0,
                                                    trailing: 0.0)

    // 2. Magic: Horizontal Scroll.
    section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary

    // 3. Creating header layout
    section.boundarySupplementaryItems = [headerViewSupplementaryItem]

    return section
}
private lazy var headerViewSupplementaryItem: NSCollectionLayoutBoundarySupplementaryItem = {
    let headerViewItem = NSCollectionLayoutBoundarySupplementaryItem(
        layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                           heightDimension: .absolute(44)),
        elementKind: UICollectionView.elementKindSectionHeader,
        alignment: .top)
    headerViewItem.pinToVisibleBounds = true

    return headerViewItem
}()

setBrandNamesSection 메서드의 첫 부분에서 우리는 섹션의 레이아웃을 구성한다. 섹션은 항상 아이템을 포함한 그룹들을 포함하고 있다. 이 레이아웃 계층은 아래와 같이 나타낼 수 있다.

image

이 섹션에서 우리가 한 줄짜리 구조를 가지고 있기 때문에 그룹과 아이템은 단일할 것이다. Grouping에 대해서는 다음 부분에서 더 알아볼 것이다.

이 예제에서 주의해야 할 것은 바로 사이즈를 정의하는 부분이다. 아이템에는 NSCollectionLayoutDimension.fractionalWidth/Height을 이용해서 사이즈를 정의한다.

NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),       
                       heightDimension: .fractionalHeight(1.0))

이는 아이템이 부모 요소의 전체 가로와 세로 값을 채택한다는 것이다. 여기서 부모는 그룹이 될 것이다.

그리고 그룹은 NSCollectionLayoutDimension.estimated/absolute을 사용해서 사이즈를 정의한다.

NSCollectionLayoutSize(widthDimension: .estimated(136),
                       heightDimension: .absolute(44))

absolute(44)는 그룹의 높이를 정확히 44 포인트로 지정하겠다는 뜻이다. estimate(136)은 그룹의 초기 너비를 136 포인트로 하지만, 컨텐츠 사이즈에 따라 달라질 수도 있다는 뜻이다. (이 부분은 항상 예상한대로만 동작하지는 않는다.)

이제 가로 스크롤을 위해서 한 줄만 더해주면 된다.

section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary

이 한 줄이 컬렉션 뷰의 스크롤 방향에 직교 방향으로 스크롤 할 수 있게 해준다.(만약 컬렉션 뷰의 메인 스크롤 방향이 가로 방향이었다면, 이 섹션은 수직으로 스크롤 가능했을 것이다.) .continuousGroupLeadingBoundary은 아이템이 연속적으로 스크롤링되지만, 사용자의 손가락이 화면에서 떨어졌을 때 가장 가까운 그룹의 시작하는 엣지로 휙 지나간다는 뜻이다. 더 많은 동작을 보려면 여기를 참조하면 된다.

이제 섹션에서 필요한 부분은 거의 다 됐지만, 헤더 부분을 추가해준다. 섹션 헤더와 푸터 레이아웃은 NSCollectionLayoutSupplementaryItem을 사용해서 정의해준다. 사이즈를 정의하기 위해서 이제 위에서 본 fractionalWidth/Height/estimated/absolute를 사용하면 된다.

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

위의 코드를 작성해서, 헤더가 섹션의 전체 가로의 길이를 채택하고, 높이는 44임을 명시적으로 나타낸 것이다. 보통과 다른 레이아웃을 원하지 않는 한 정렬은 헤더는 .top, 푸터는 .bottom이 되어야 한다.

headerViewItem.pinToVisibleBounds = true

pinToVisibleBounds를 사용해서 sticky 헤더를 만들 수도 있다.

최종적으로 헤더는 아래와 같이 동작한다.

cat3

스크롤링하는 걸 보면 우리가 continuousGroupLeadingBoundary를 명시했기때문에 그룹의 시작하는 경계선에서 멈춘다는 것을 확인할 수 있을 것이다.

다시 정리하자면 UICollectionViewCompositionLayout 에는 세 가지 다른 클래스, 개념이 있었다.

  • NSCollectionLayoutItem
  • NSCollectionLayoutGroup
  • NSCollectionLayoutSection

NSCollectionLayoutItem은 각 UICollectionViewCell의 사이즈를 나타내고, NSCollectionLayoutSection은 섹션의 사이즈를 나타낸다. NSCollectionLayoutGroup은 각 섹션 내에 유동적인 레이아웃을 생성할 수 있게 해준다.

Cat Foods Section

func setupCatFoodsSection() -> NSCollectionLayoutSection {
    // 1. Configuring Section Layout. Item -> Group -> Section
    // Item
    let item = NSCollectionLayoutItem(
        layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                           heightDimension: .fractionalHeight(0.5)))
    item.contentInsets = NSDirectionalEdgeInsets(top: 4.0,
                                                 leading: 0.0,
                                                 bottom: 4.0,
                                                 trailing: 0.0)

    // Group
    let group = NSCollectionLayoutGroup.vertical(
        layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5),
                                           heightDimension: .absolute(240)),
        subitem: item,
        count: 2)

    // Section
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = NSDirectionalEdgeInsets(top: 16.0,
                                                    leading: 0.0,
                                                    bottom: 16.0,
                                                    trailing: 0.0)

    // 2. Horizontal Scroll
    section.orthogonalScrollingBehavior = .groupPaging

    // 3. Header
    section.boundarySupplementaryItems = [headerViewSupplementaryItem]

    return section
}

첫 번째 섹션에서 했던 것과 굉장히 비슷한데, 두 가지 차이가 있다. 첫 번째는 그룹에서 두 아이템을 정의한다는 것이다.

let group = NSCollectionLayoutGroup.vertical(layoutSize: ...,
                subitem: item,
                count: 2)

이는 수직 방향으로 두 개의 인접한 아이템을 그룹으로 묶겠다는 뜻이다. 그래서 구조는 아래와 같이 될 것이다.

image

이렇게 라인 수를 늘리는 것이 그룹핑의 가장 기본적인 쓰임새일 것이다. 그룹은 컬렉션 뷰에서 굉장히 다양한 레이아웃으로 구성될 수 있다. 자세한 내용을 보려면 영상을 참고하자.

다음은 가로로 스크롤하는 행동에서 차이가 있다.

section.orthogonalScrollingBehavior = .groupPaging

이 섹션에서, 스크롤은 그룹의 시작하는 경계에서 멈춘다. 그래서 결과는 아래와 같이 나올 것이다.

cat4

Cats Section

마지막 고양이 section은 아래와 같이 구성한다.

func setupCatsSection() -> NSCollectionLayoutSection {
    // 1. Configuring Section. Item -> Group -> Section
    // Item
    let item = NSCollectionLayoutItem(
        layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                           heightDimension: .fractionalHeight(1.0)))
    item.contentInsets = NSDirectionalEdgeInsets(top: 6.7, leading: 10.0, bottom: 6.7, trailing: 10.0)

    // Group
    let group = NSCollectionLayoutGroup.vertical(
        layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                           heightDimension: .fractionalWidth(0.67)),
        subitem: item,
        count: 1)

    // Section
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = NSDirectionalEdgeInsets(top: 16.0,
                                                    leading: 0.0,
                                                    bottom: 16.0,
                                                    trailing: 0.0)

    // 2. Header
    section.boundarySupplementaryItems = [headerViewSupplementaryItem]

    return section
}

구조는 아래와 같이 된다.

image

여기서 주의할 점은 원래 이미지의 aspect ratio(3:2)를 유지하고 싶어서 그룹의 높이를 fractionalWidth를 사용해서 지정한 점이다.

NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                       heightDimension: .fractionalWidth(0.67))

이제 SwiftUI를 사용한 방법도 볼 것이다.

SwiftUI

SwiftUI 의 장점으로 말 할 수 있는 것은 코드 자체가 뷰 계층을 굉장히 잘 설명할 수 있다는 것이다. 구조는 아래와 같이 된다.

image

전체 뷰는 VSStack이고 안에 ScrollView를 가지고 있다. 기본 아이디어는 ScrollViewVStack을 넣으면 가로로 스크롤 할 수 있다는 것이고, HStack을 넣으면 세로로 스크롤 할 수 있다는 것이다.

ScrollView {
    VStack(alignment: .leading, spacing: 16) {
        // MARK: - Brand Names Section
        HeaderView(headerText: "Brand Names")
        ScrollView(showsHorizontalIndicator: false) {
            HStack {...}
        }
            .frame(height: 64)


        // MARK: - Cat Foods Section
        HeaderView(headerText: "Cat Foods")
        ScrollView(showsHorizontalIndicator: false) {
            HStack {...}
        }
            .frame(height: 120)

        // MARK: - Cats Section
        HeaderView(headerText: "Cats")
        ForEach(cats.identified(by: \.self)) {...}
    }
}

위의 코드를 읽는 것만으로도 구조를 파악하기 쉽다.

브랜드 이름을 정의한 HStack 부분은 아래와 같다.

HStack {
    ForEach(brandNames.identified(by: \.self)) {
        Text("\($0)")
            .frame(width: UIScreen.main.bounds.width / 3 - 30)
            .padding(10)
            .border(Color.black, width: 1, cornerRadius: 22)
    }
}.padding(10)

ForEach는 데이터 리스트에서 리스트 뷰를 생성하는 UI 요소이다. 이 안에서 데이터의 구분자로 설정할 수 있는 프로퍼티를 설정해주기만 하면 된다. 여기에서 데이터가 문자열이기 때문에, 문자열 자체를 구분자로 설정했다.

Text 요소는 UILabel과 같은 UI요소를 생성하고, border(...) 메서드를 호출해서, 라벨에 둥근 모서리를 적용할 수 있다.

고양이 음식을 나타낸 HStack은 아래와 같다.

HStack {
    ForEach(stride(from: 0, to: catFoods.count, by: 2).map{ $0 }) { i in
        VStack {
            Image(catFoods[i])
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: UIScreen.main.bounds.width / 2 - 40, height: 100)
                .padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
            Image(catFoods[i + 1])
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: UIScreen.main.bounds.width / 2 - 40, height: 100)
                .padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
        }
    }
}.padding(10)

ForEach안에서 (i, i+1) 번째 이미지 데이터를 받아 VStack에 넣어서 한 열에 두 이미지를 가질 수 있게 했다.

resizable().aspectRatio(contentMode: .fit)는 이미지가 프레임 사각형에 적절히 맞게 배치될 수 있게 프레임을 설정하기 전에 호출되어야 한다. SwiftUI에서는 보통 이런 요소를 배치하는 순서가 차이를 만들어낸다.

마지막 섹션은 비슷하게 구현하면 된다.

하지만 UIKit을 이용하면 가로로 스크롤링할 때 컬렉션 아이템의 시작하는 경계에서 멈추지 않는다는 것을 확인할 수 있다. 여기에는 두 가지 요소가 이를 방해하고 있다. 먼저, UICollectionViewCompositionalLayout가 하는 것처럼 SwiftUI에서 ScrollView가 할 수 있는 편리한 방법이 제공되지 않는다. 그리고 우리가 ScrollView의 offset을 얻을 수 없고, scroll을 방해할 onScroll 타입의 콜백을 ScrollView에 설정할 수 없기 때문이다.

이런 단점들도 있지만, 코드가 간결해지면서 같은 동작을 구현할 수 있다는 것이 장점이다.