WWDC19 - Advances in Collection View Layout

2023. 4. 21. 03:34IOS/WWDC

반응형

WWDC19 - Advances in Collection View Layout

  • 기존 UICollectionViewFlowLayout의 경우 일반적인 디자인에 매우 유용하며 라인 기반으로 작동을 하였습니다.
  • 그러나 최근 다양한 앱들은 각 레이아웃 별로 커스텀되어 있으므로 매우 복잡한 형태를 가지고 있습니다.
  • 다양한 레이아웃을 가진 앱을 만들기 위해서는 퍼포먼스와 뷰가 어떻게 꾸며져야할지 크기는 어느정도로 해야될지에 대한 여러 문제가 발생합니다.
  • 이를 해결하기 위해 콘크리트 레이아웃(고정되어 있는 레이아웃?)을 만들었으며 이것이 Compositional Layout입니다.

Compositional Layout

  • 여러 구성이 가능하며
  • 유연하고
  • 빠릅니다.
func createLayout() -> UICollectionViewLayout {
    let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), 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)

    return layout
}
  • 해당 코드는 자연스러운 연결을 보여줍니다. 아이템 -> 그룹 -> 섹션 -> 레이아웃

  • 우리가 구성한 레이아웃은 다음과 같은 모습으로 보여질것입니다.
  • 가장 큰 레이아웃에 섹션이 있고 섹션 안에 그룹, 그룹안에 아이템들이 들어있는 형태로 보여집니다.

NSCollectionLayoutSize

convenience init(
    widthDimension width: NSCollectionLayoutDimension,
    heightDimension height: NSCollectionLayoutDimension
)
  • 해당 타입은 사이즈를 쉽게 추론할 수 있도록 만들어져있는 타입입니다.
  • 여기서 너비와 높이는 단순한 Float과 같은 값이 아니라 Dimension타입이며
    1. fractionWidth
    2. fractionHeight
    3. Absolute
    4. Estimated
  • 4가지로 존재합니다.

fractionWidth fractionHeight

  • 특정 상위 유닛의 크기에 따른 비율로 줄 수 있는 타입입니다.
.fractionWidth(0.5)

  • 우리는 이렇게 전체 너비의 1/2라는 값을 줄 수 도 있고
.fractionHeight(0.3)

  • 전체 높이의 30%라는 값을 줄 수도 있습니다.

absolute

  • 가장 단순하고 강력한 형태입니다.
.absolute(200)
  • 만약 200이라는 높이 혹은 너비를 꼭 가져야하는 불변한 값이라면 absolute를 사용하면 됩니다.

estimated

  • 이름 그대로 추정치 입니다.
  • 해당 크기가 얼마나 커질지는 대략적인 값만을 확인하고 시간이 지남에 따라 렌더링 이후에 크기가 커지거나 작아지는 형태로 사용됩니다.
.estimated(200)

NSCollectionLayoutItem

class NSCollectionLayoutItem {
    convenience init(layoutSize: NSCollectionLayoutSize)
    var contentInsets: NSDirectionEdgeInsets
}
  • 말 그대로 아이템으로 하나의 셀이라고 볼 수 있습니다.

NSCollectionLayoutGroup

  • 레이아웃의 기본적인 유닛으로 Horizontal, Vertical, Custom 세가지 형태를 가지고 있습니다.
class NSCollectionLayoutGroup: NSCollectionLayoutItem {
    class func horizontal(layoutSize: NSCollectionLayoutSize, subitems: [NSCollectionLayoutItem]) -> Self
    class func vertical(layoutSize: NSCollectionLayoutSize, subitems: [NSCollectionLayoutItem]) -> Self
    class func custom(layoutSize: NSCollectionLayoutSize, itemProvider: NSCollectionLayoutGroupCustomItemProvider) -> Self
}

NSCollectionLayoutSection

  • CollectionView의 섹션별 레이아웃에 대한 정의이며 DataSource쪽에 직접 매핑됩니다.
class NSCollectionLayoutSection {
    convenience init(layoutGroup: NSCollectionLayoutGroup)
    var contentInsets: NSDirectionalEdgeInsets
}

UICollectionViewCompositionalLayout

  • 컬렉션뷰 레이아웃의 최종 타입이며, iOS와 tvOS에 존재하는 타입입니다.
  • macOS의 경우 NSCollectionViewCompositionalLayout이 존재합니다.
class UICollectionViewCompositionalLayout: UICollectionViewLayout {
    init(section: NSCollectionLayoutSection)
    init(sectionProvider: @escaping SectionProvider)
}

Basic Demo

  • 흥미로운 것, 필요한 것들만 정리했습니다.

TwoColumnViewController

  • 위와 같이 하나의 column에 두개의 아이템을 넣을려면 어떻게 해야할까요?
  • 단순히 아이템 사이즈를 .fractionWidth(0.5)로 주는 방법도 있겠지만 WWDC에서는 조금 다른 방법을 소개했습니다.
extension TwoColumnViewController {
    /// - Tag: TwoColumn
    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
    }
}
  • .fractionWidth(1.0)으로 주었지만 Group을 생성할 때 명시적으로 NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 2) 하나의 column 당 2개의 아이템을 가지게끔 group을 선언했습니다.
  • 이를 통해 itemSize에 정의된 .fractionalWidth는 새롭게 계산되어서 그룹 내에 두개의 아이템이 보이는 형태를 가지게 됩니다.

DistinctSectionsViewController

  • 해당 내용은 섹션마다 서로 다른 레이아웃을 가지는 형태를 보여줍니다.

class DistinctSectionsViewController: UIViewController {

    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
            }
        }
    }
}
  • 해당 섹션마다 서로 다른 형태의 레이아웃을 가지게끔 타입을 생성하며
extension DistinctSectionsViewController {
    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

            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
    }
}
  • 위와 같이 구현이 가능합니다.
  • 다만 위와 같이 구현했을 경우 가로모드일 때 조금 더 많은 양의 데이터를 하나의 행에 보여주고 싶을 경우가 있을 수 있고
  • 혹은, 더 적은 데이터를 보여주고 싶을 경우도 있을 수 있습니다.
  • 그럴 경우는 어떻게 해야할까요??
class AdaptiveSectionsViewController: UIViewController {

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

            case .grid5:
                return wideMode ? 10 : 5

            case .list:
                return wideMode ? 2 : 1
            }
        }
    }
}
  • 이전과 같은 섹션 타입이지만 하나 다른점이 있다면 width를 받아 값을 반환해준다는 점입니다.
  • 그러면 레이아웃을 구성할 떄 너비를 어떻게 알고 전달할 수 있을까요?
extension AdaptiveSectionsViewController {
    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
    }
}
let columns = layoutKind.columnCount(for: layoutEnvironment.container.effectiveContentSize.width)
  • NSCollectionLayoutEnviroment에는 현재 레이아웃이 작동되어야하는 전체 컨테이너의 크기와 같은 정보가 들어있습니다.
  • 따라서 해당 정보를 이용하여 현재 환경에 적합한 레이아웃을 구성할 수 있습니다.

NSCollectionLayoutSupplementaryItem

  • 지금까지 아이템 그룹 섹션과 같은 컬렉션뷰의 메인 뷰에 대해 이야기했다면 이번은 그 외의 아이템들에 관한 이야기입니다.
  • Badges, Headers, Footers 세가지 유형으로 구성되어있으며 NSCollectionLayoutSupplementaryItem을 통해 어떤 것을 사용할 지 어디에 위치할지를 지정해주면 됩니다.

NSCollectionLayoutAnchor

  • 해당 사진처럼 단순히 탑과 바텀에만 추가할 수도 있지만 [.trailing, .top]과 같이 몇가지의 관계를 섞어서 위치를 조정해줄수도 있습니다.

Header & Footer

  • 헤더와 푸터뷰를 만들 때 고려해야 될 점은 컨텐츠를 가리지 않는 것입니다.
  • 컨텐츠는 그대로 유지하고 그 외의 부분을 확장하여 뷰를 보이게하는 것이 중요합니다.
  • 따라서 Boundary supplementary item이라는 것이 존재하는데 이것은 경계를 토대로 뷰를 추가할 것입니다.
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
    layoutSize: headerFooterSize,
    elementKind: SectionHeadersFootersViewController.sectionHeaderElementKind, alignment: .top)
let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(
    layoutSize: headerFooterSize,
    elementKind: SectionHeadersFootersViewController.sectionFooterElementKind, alignment: .bottom)
section.boundarySupplementaryItems = [sectionHeader, sectionFooter]
  • 헤더와 풋터는 다음과 같이 만들 수 있으며 만약 콘텐츠 영역에서 특정 뷰를 고정시키고 싶을 때는 header.pinToVisibleBounds = true를 통해 고정 시킬수도 있습니다.
  • 그 외에는 `boundarySupplementaryItems에 추가하면 됩니다.

Estimated Self-Sizing

  • 우리는 위에서 구현한 header와 footer가 어느정도의 너비를 가진지는 정확하게 알고 있습니다.
  • 그러나, 가끔은 텍스트 크기에따라 혹은 뷰 안에 들어가는 이미지 크기에 따라 약간의 높이가 달라지는 것을 원할 때가 분명히 있을겁니다.
let headerFooterSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                             heightDimension: .estimated(44))
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
    layoutSize: headerFooterSize,
    elementKind: SectionHeadersFootersViewController.sectionHeaderElementKind, alignment: .top)
  • 우리는 단순히 headerSize의 높이를 estimated로 주는것 만으로 해결이 가능합니다.
  • 콘텐츠가 렌더링 될 경우 새로운 높이를 지정해줄 수 있습니다.

Advance Demo

NestedGroupsViewController

  • 만약 위와 같이 만들려면 어떻게 해야할까요?
  • 하나의 그룹에 좌측에는 화면을 가득채우는 아이템 하나와 우측에는 두개의 아이템으로 구성되어 있는 이 뷰를 만들려면 다음과 같이 만들 수 있습니다.
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])
  • 왼쪽의 아이템은 하나만을 생성하고 우측은 하나의 그룹으로 묶기 위해 우측 아이템을 따로 생성하고 하나의 그룹을 만들어 생성해 줍니다.
  • 이후 전체 그룹에 leadingItemtrailingGroup을 넣어주는 것으로 위 사진과 같이 구현할 수 있습니다.

OrthogonalScrollingViewController

  • 위와 같이 구현하기 위해서 페이징 방법에 대해서 6가지 방법이 구현되어 있습니다.

UICollectionLayoutSectionOrthogonalScrollingBehavior | Apple Developer Documentation

UICollectionLayoutSectionOrthogonalScrollingBehavior

  • continuous

  • continuousGroupLeadingBoundary

  • paging

  • groupPaging

  • groupPagingCentered

  • none

  • 위 구현으로 이전 스코롤뷰의 영역에 따라 offset을 다르게 주는 형태로 구현을 해야됐던 방법을 탈피했습니다.
반응형