본문 바로가기
Dev/WWDC 정리

[WWDC19] Advances in Collection View Layout (Compositional Layout 정리) - 1

by Mintta 2023. 4. 27.

Compositional Layout

  • Composable: 복잡한 것을 단순한 것들로 구성할 수 있는
  • Flexible: 어떠한 레이아웃도 작성할 수 있을 정도로 유연한
  • Fast: 빠른

Composing small layout groups together
Layout groups are line-based
Composition instead of subclassing (UICollectionViewFlowLayout)

  • subclassing

Item, Group, Section, Layout

let size = NSCollectionLayoutSize(
    widthDimension: .fractionalWidth(1),
    heightDimension: .absolute(44)
)

let item = NSCollectionLayoutItem(layoutSize: size)

let group = NSCollectionLayoutGroup
    .horizontal(
        layoutSize: size,
        subitems: [item]
    )

let section = NSCollectionLayoutSection(group: group)

let layout = UICollectionViewCompositionalLayout(section: section)

4가지 중요 키워드

  • item
  • group
  • section
  • layout

  • 화면 전체에 해당하는 것이 layout
  • layout은 section으로 구성되어있다. 기존의 것과 마찬가지.
  • section은 group으로 구성되어있다. group은 row와 같다.
  • group은 item으로 구성되어있다.

Core Concepts

1. NSCollectionLayoutSize

  • width와 height이 있지만 단순한 scalar값들이 아니라 NSCollectionLayoutDimension 타입을 가지고 있다.

NSCollectionLayoutDimension ?? 🤔

  • 특정 축이 얼마나 큰지를 설명하는 축 독립적인 방식입니다.

fractionalWidth, fractionalHeight

  • container(CollectionView)의 width의 0.5

width와 height를 같이 조합하면..

 

absolute

  • 이건 그냥 말그대로 절대값. 고정값을 갖게 해준다.

estimated

  • 얼마나 크게 될지 잘은 모르겠다…? 아마 200 정도??
  • 그리고 시간이 지남에 따라 item이 렌더링되고 해당 item의 콘텐츠에 대해 조금씩 더 많이 알게 되면 그 수가 증가한다.

실제 height

2. NSCollectionLayoutItem✨

이것보다 조금 더 있다. 공식문서 링크 첨부

  • cell 아니면 supplementary.
  • 화면에 렌더링하는 것들.
  • Size를 받는다.

3. NSCollectionLayoutGroup✨

Group이 핵심이다.

  • 함께 구성할 레이아웃의 기본단위

Group의 3가지 형태

  • horizontal
  • vertical
  • custom : 미니 flow layout이라고 생각하면 된다.

4. NSCollectionLayoutSection✨

  • CollectionView의 section별 레이아웃 정의.
  • 해당 섹션에 몇 개의 items이 있는지에 대한 dataSource 개념에 직접 매핑된다.
  • Group을 받는다.

5. UICollectionViewCompositionalLayout✨

https://developer.apple.com/documentation/uikit/uicollectionviewcompositionallayout

여기서 흥미로운 점은 플랫폼에 관계없이 이 모든 항목에 대한 정의가 동일하다는 점이다.

Code Examples

List

List

private func createLayout() -> UICollectionViewCompositionalLayout {
    let itemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1),
        heightDimension: .absolute(1)
    )

    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    let groupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1),
        heightDimension: .absolute(44)
    )

    let group = NSCollectionLayoutGroup
        .horizontal(
            layoutSize: groupSize,
            subitems: [item]
        )

    let section = NSCollectionLayoutSection(group: group)

    let layout = UICollectionViewCompositionalLayout(section: section)
    return layout
}
  • 여기서 주목해야할 점은 이 경우에서 궁극적으로 item size를 결정짓는 것은 group size이다. (container-relevant sizing)
  • 이 상황에서는 각각의 item이 각각의 group을 가지게 된다.
  • group은 row하나에 해당하게 된다. (늘 그런거 아님. 해당 예제에서 그렇다는 뜻.)
  • group의 사이즈를 우리는 CollectionView의 width에 44의 height를 갖게 한 다음, item size를 width와 height을 모두 100%로 해서 item의 container가 group이다 라고 정해주면 위의 사진과 같이 된다.

Grid

Grid

private func createLayout() -> UICollectionViewLayout {
    let itemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(0.2),
        heightDimension: .fractionalHeight(1.0)
    )

    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    let groupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .fractionalWidth(0.2)
    )

    let group = NSCollectionLayoutGroup
        .horizontal(
            layoutSize: groupSize,
            subitems: [item]
        )

    let section = NSCollectionLayoutSection(group: group)

    let layout = UICollectionViewCompositionalLayout(section: section)
    return layout
}
  • 위의 예제와 다른점은 item과 group 사이즈뿐이다.
  • group size
    • width = fractionalWidth(1) → 너비는 collectionview(conatiner)의 width만큼.
    • height = fractionalWidth(0.2) → 한 row에 5개가 들어가기 위해선 width는 0.2만큼의 비율을 가지고 있어야함. 해당 너비를 높이를 주어 정사각형을 만든다.
  • item Size
    • fractionalWidth(0.2) → 한 row에 5개가 들어가기 위해선 width는 0.2만큼의 비율을 가지고 있어야함
    • fractionalHeight(1) → group에서 높이를 지정해놨기 때문에 거기에 맞추면 된다.

이제 여기에 items들 사이에 여백을 두고자하면 어떻게 할까?

add inset between items (1) - item contentInsets

func createLayout() -> UICollectionViewLayout {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(0.2),
            heightDimension: .fractionalHeight(1.0)
        )

        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        item.contentInsets = NSDirectionalEdgeInsets(
            top: 5,
            leading: 5,
            bottom: 5,
            trailing: 5
        )

        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalWidth(0.2)
        )
        let group = NSCollectionLayoutGroup
            .horizontal(
                layoutSize: groupSize,
                subitems: [item]
            )

        let section = NSCollectionLayoutSection(group: group)
        let layout = UICollectionViewCompositionalLayout(section: section)
        return layout
    }
  • item.contentInsets에 inset값 부여
  • 이미 계산된 레이아웃에서 마지막 단계로 빼는 것과 비슷하다.

Two-Column Grid

func createLayout() -> UICollectionViewLayout {
    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
        )

    let spacing = CGFloat(10)
    group.interItemSpacing = .fixed(spacing)

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

    let layout = UICollectionViewCompositionalLayout(section: section)
    return layout
}
  • row를 대표하는 group을 여기서는 조금 다르게 구현하였다.

count가 포함된 생성자를 이용해서 width값 설정

let group = NSCollectionLayoutGroup
    .horizontal(
        layoutSize: groupSize,
        subitem: item,
        count: 2
    )
  • count가 포함된 생성자를 사용한다.
  • count만큼의 subitem이 group당 있었으면 좋겠어 !
  • 즉, 하나의 row당 2개의 items들이 있었으면 좋겠어!!
  • 이제 이렇게 하면 컴포지션 레이아웃이 자동으로 item width가 얼마인지 파악하여 이를 구현한다.

오잉? 전 위에 item width를 .fractionalWidth(1.0)로 정의해줬는데요..??🤔

만약 우리가 그룹당 item개수를 알려준다면 사실상 이 값들은 모두 컴포지셔널 레이아웃에 의해 overriden(재정의) 될 것이다.

add inset between items (2)

section contentInsets

앞에서는 item.contentInset으로 여백을 줬었는데 이번에는 다른 방법이다.

let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(
    top: 0,
    leading: 10,
    bottom: 0,
    trailing: 10
)
  • section의 contentInset에 값을 부여하기
  • 해당 예제에서는 섹션이 하나밖에 없기 때문에 이것은 모든 layout에 적용됨.

interItemSpacing, interGroupSpacing

let spacing = CGFloat(10)
group.interItemSpacing = .fixed(spacing) // group안에서 item간의 간격 주기
section.interGroupSpacing = spacing // section에서 group간 간격 주기
// 해당 예제에서 group이 하나의 row이기 때문에 row간의 간격을 주는 효과를 가짐.ㅇ

section마다 다른 layout 주기✨

multiple sections having their own distinct layout !

Distinct Sections

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

      guard let sectionLayoutKind = SectionLayoutKind(rawValue: sectionIndex) else { return nil }
      let columns = sectionLayoutKind.columnCount

      // The group auto-calculates the actual item width to make
      // the requested number of columns fit, so this widthDimension is ignored.
      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
}
  • 언뜻 보기에는 크게 달라 보이지만 실제로는 레이아웃을 인스턴스화하는 가장 바깥쪽 컴포넌트가 마지막에 오는 것이 아니라 처음에 온다는 점만 다르다.

sectionProvider

sectionProvider 클로저를 작성하는 것이다.

UICollectionViewCompositionalLayout { <#Int#>, <#NSCollectionLayoutEnvironment#> in
    <#code#>
}
  • sectionIndex: Int → 몇번째 섹션인지
  • layoutEnvironment: NSCollectionLayoutEnvironment
  • 이제 컴포지션 레이아웃은 특정 섹션에 대한 새로운 description을 요청해야 할 때마다 이 클로저를 자동으로 호출한다.

  • escaping closure로 되어있는 모습

 

다시 코드로 돌아와서...

 

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
        }
    }
}
guard let sectionLayoutKind = SectionLayoutKind(rawValue: sectionIndex) else { return nil }
let columns = sectionLayoutKind.columnCount
  • 해당 섹션의 column count를 가져온다.
let group = NSCollectionLayoutGroup
    .horizontal(
        layoutSize: groupSize,
        subitem: item,
        count: columns
    )
  • count에 해당 열의 개수를 넘기면
  • 컴포지셔널 레이아웃이 자동으로 item의 width를 계산해낸다.

Adaptive rotating layout

  • 이전 예시

  • Adaptive Section예시
enum SectionLayoutKind: Int, CaseIterable {
    case list, grid5, grid3
    func columnCount(for width: CGFloat) -> Int {
        let wideMode = width > 700
        switch self {
        case .grid3:
            return wideMode ? 6 : 3

        case .grid5:
            return wideMode ? 10 : 5

        case .list:
            return wideMode ? 2 : 1
        }
    }
}

func createLayout() -> UICollectionViewLayout {
    let layout = UICollectionViewCompositionalLayout {
        (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
        guard let layoutKind = SectionLayoutKind(rawValue: sectionIndex) else { return nil }

        let columns = layoutKind.columnCount(for: layoutEnvironment.container.effectiveContentSize.width)

        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),
                                             heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = NSDirectionalEdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2)

        let groupHeight = layoutKind == .list
        ? 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
}
  • 다른 점은 columCount가 더이상 get-only property가 아니고 함수로 바뀐점이다.
  • 해당 함수에 인자로 넘기는 값을 살펴볼 필요가 있다.
  • layoutEnvironment에서 가져온 width를 전달하고 있다. 따라서 레이아웃 환경 유형에는 레이아웃이 작동해야 하는 전체 컨테이너 너비와 같은 정보가 포함되며, iOS에서는 특성 컬렉션(Trait collection) 정보도 내부에 있다. 따라서 이 모든 정보를 사용하여 현재 환경에 어떤 종류의 레이아웃이 적합한지 파악할 수 있다. 여기서는 너비만 사용한다.

UITraitCollection

  • 가로 및 세로 크기 클래스, 디스플레이 배율, 사용자 인터페이스 관용구 등의 특성을 포함한 앱의 iOS 인터페이스 환경입니다.

댓글