본문 바로가기

iOS/iOS

iOS) UICollectionView custom layout에 대한 고찰- 2 (UICollectionViewCompositionalLayout)

 

iOS) UICollectionView custom layout에 대한 고찰- 1 (UICollectionViewFlowLayout, UICollectionViewLayout)

 

iOS) UICollectionView custom layout에 대한 고찰- 1 (UICollectionViewFlowLayout, UICollectionViewLayout)

Collection View에서 복잡한 레이아웃을 다루기 위해선 Custom Layout을 적용시켜야 합니다. 오늘은 Custom Layout을 탐구해보려 합니다. 데이터 레이어와 프레젠테이션 레이어가 분리되어있고 레이아웃으

demian-develop.tistory.com

이전 글에 이어서 오늘은 iOS 13부터 사용 가능한 UICollectionViewCompositionalLayout에 대해서 다뤄보려 합니다.

복잡한 레이아웃

이 레이아웃을 UIKit으로 구현하려면 어떻게 해야 할까요? CollectionView의 중첩? 꽤 어려울 것입니다. 하지만 UICollectionViewCompositionalLayout를 쓰면 꽤나 간단해집니다.

 

UICollectionViewCompositionalLayout

 

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

UICollectionViewLayout을 상속받습니다. 이름처럼 여러 레이아웃을 결합하기 위해 사용하는 레이아웃입니다. 이를 위해 3가지의 컴포넌트로 구분하여 레이아웃을 구성합니다.

 

Section (NSCollectionLayoutSection)

 

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

 NSCollectionLayoutSection을 이용합니다. Group을 담는 Container입니다. Collection View는 하나 또는 여러 개의 Section을 가질 수 있습니다. Section은 Layout의 각각의 영역을 나타냅니다. Section은 NSCollectionLayoutGroup에 의해 결정됩니다. 각 Section은 고유의 배경, Header, Footer를 가질 수 있습니다.

 

Group (NSCollectionLayoutGroup)

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

NSCollectionLayoutGroup을 이용합니다. Item을 담는 Container입니다.  NSCollectionLayoutItem을 상속받았기 때문에 LayoutItem과 유사하게 동작합니다. Item을 특정 Path에 따라 배치하는 역할을 합니다. Group 자체는 레이아웃만 배치하고 렌더링은 하지 않습니다.  이 Group은 Section의 init(group:)에 주입됩니다. Group은 NSCollectionLayoutDimension을 이용하여 크기를 설정할 수 있습니다. 이에 대해선 아래에 설명을 적겠습니다.

 

Item (NSCollectionLayoutItem)

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

NSCollectionLayoutItem을 이용합니다. Collection View의 가장 기본 컴포넌트입니다. Item은 크기, 개별 content의 size, space, arragnge를 어떻게 할지에 대한 blueprint입니다. 일반적으로 Item은 Cell이지만 Headers, Footers, Decorations와 같은 Supplementary View도 될 수 있습니다.  마찬가지로 NSCollectionLayoutDimension로 크기를 결정합니다. 이 Item은 Group에 주입됩니다.

 

NSCollectionLayoutDimension

CollectinView의 Item의 크기를 결정합니다. 크기를 정하는 법은 3가지가 있습니다.

absoulte

let absoluteSize = NSCollectionLayoutSize(widthDimension: .absolute(44),
                                         heightDimension: .absolute(44))

말 그대로 절대 크기입니다. 항상 고정된 크기로 나타납니다.

estimated

let estimatedSize = NSCollectionLayoutSize(widthDimension: .estimated(200),
                                          heightDimension: .estimated(100))

런타임에 크기가 변할 가능성이 있는 경우(시스템의 글꼴 크기 변경과 같은) estimated를 사용합니다. 이는 시스템이 예상 크기를 기반으로 실제 크기를 계산합니다.

fractional

let fractionalSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),
                                           heightDimension: .fractionalHeight(0.2))

이 fractional이 굉장히 쓸만합니다. SwiftUI에서 geomtryGeometryReader와 유사하게, 현재 자신이 속한 컨테이너의 크기를 기반으로 비율로써 자신의 크기를 정합니다. 0.0~1.0 사이의 CGFloat값을 넣을  수 있습니다.

 

 

 

실습에 앞서...

UIKit의 View여도 SwiftUI의 Preview를 이용해 빠르게 레이아웃을 확인할 수 있습니다.

final class MyController: UICollectionViewController {
//.. My code
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        Container().edgesIgnoringSafeArea(.all)
    }
    struct Container: UIViewControllerRepresentable {
        func makeUIViewController(context: Context) -> UIViewController {
            return     UINavigationController(rootViewController: MyController())
        }
        func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
        }
        typealias  UIViewControllerType = UIViewController
    }
}

SwiftUI의 Preview를 이용해 빠르게 레이아웃 확인이 가능합니다.

 

실습

이전 프로젝트

실습 대상은 바로 이 프로젝트입니다. NAVER VIBE를 클론 했었는데, 이 프로젝트는 SwiftUI를 이용해 구현하였습니다. 이번엔 레이아웃을 UIKit의 CompostionaLayout을 이용해 구현해보겠습니다. 크게 헤더와 3가지 영역만 구현해보겠습니다. 이렇게 사용하는구나 정도로 참고하시면 좋을 것 같습니다.

헤더

final class Header: UICollectionReusableView {

    let label = UILabel()
    private let stackView = UIStackView()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        label.text = "Categories"
        addSubview(stackView)
        stackView.addArrangedSubview(label)
        let button = UIButton(type: .system)
        button.setTitle("더보기", for: .normal)
        button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        stackView.addArrangedSubview(button)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        stackView.frame = bounds
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

헤더

 

 

큰 아이템 섹션

큰 아이템 섹션

섹션 모두 스크롤이 가능함을 알리기 위해 다음 Item의 끄트머리가 보여야 합니다. 이 조건은 이후의 모든 섹션에 적용됩니다.

let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1)))
item.contentInsets = .init(top: 0, leading: 5, bottom: 16, trailing: 5)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(0.9), heightDimension: .estimated(200)), subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.boundarySupplementaryItems = [.init(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50)), elementKind: catgoryHeaderId, alignment: .topLeading)]
section.orthogonalScrollingBehavior = .groupPaging
section.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
return section

큰 아이템 섹션 레이아웃 구현

일반 아이템 섹션

일반 아이템 섹션

크기만 수정해주면 쉽게 구현할 수 있습니다. Group Paging을 사용하기 위해 Item이 아닌 Group의 크기를 줄여줍니다.

let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1)))
item.contentInsets = .init(top: 0, leading: 5, bottom: 16, trailing: 5)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(0.45), heightDimension: .estimated(200)), subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.boundarySupplementaryItems = [.init(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50)), elementKind: catgoryHeaderId, alignment: .topLeading)]
section.orthogonalScrollingBehavior = .groupPaging
section.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
return section

 

일반 아이템 섹션 레이아웃 구현

노래 목록 섹션

노래 목록 섹션

노래목록 섹션은 한 섹션의 5개의 노래 목록이 있습니다. horizontal을 vertical로 바꿔주고 count를 5로 변경합니다.

let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
item.contentInsets = .init(top: 0, leading: 5, bottom: 16, trailing: 5)
let group = NSCollectionLayoutGroup.vertical(layoutSize: .init(widthDimension: .fractionalWidth(0.9), heightDimension: .estimated(300)), subitem: item, count: 5)
let section = NSCollectionLayoutSection(group: group)
section.boundarySupplementaryItems = [.init(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50)), elementKind: catgoryHeaderId, alignment: .topLeading)]
section.orthogonalScrollingBehavior = .groupPaging
section.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
return section

노래 목록 섹션 레이아웃 구현

조합

 static func createLayout() -> UICollectionViewCompositionalLayout {
        return UICollectionViewCompositionalLayout { (sectionNumber, env) -> NSCollectionLayoutSection? in
            if sectionNumber == 0 {
                let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1)))
                          let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(0.9), heightDimension: .estimated(200)), subitems: [item])
                          group.contentInsets = .init(top: 0, leading: 5, bottom: 16, trailing: 5)
                          let section = NSCollectionLayoutSection(group: group)
                          section.boundarySupplementaryItems = [.init(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50)), elementKind: catgoryHeaderId, alignment: .topLeading)]
                          section.orthogonalScrollingBehavior = .groupPaging
                          section.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
                          return section
            } else if sectionNumber == 1 {
                let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1)))
                           item.contentInsets = .init(top: 0, leading: 5, bottom: 16, trailing: 5)
                           let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(0.45), heightDimension: .estimated(200)), subitems: [item])
                           let section = NSCollectionLayoutSection(group: group)
                           section.boundarySupplementaryItems = [.init(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50)), elementKind: catgoryHeaderId, alignment: .topLeading)]
                           section.orthogonalScrollingBehavior = .groupPaging
                           section.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
                           return section
            } else {
                let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
                item.contentInsets = .init(top: 0, leading: 5, bottom: 16, trailing: 5)
                let group = NSCollectionLayoutGroup.vertical(layoutSize: .init(widthDimension: .fractionalWidth(0.9), heightDimension: .estimated(300)), subitem: item, count: 5)
                let section = NSCollectionLayoutSection(group: group)
                section.boundarySupplementaryItems = [.init(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50)), elementKind: catgoryHeaderId, alignment: .topLeading)]
                section.orthogonalScrollingBehavior = .groupPaging
                section.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
                return section
            }
        }
    }

이제 3가지 섹션을 합쳐줍니다. SectionNumber에 따라 분기 처리를 하면 됩니다.

 

 

완성

고찰

이전에 SwiftUI로 했던 프로젝트를 UIKit으로 구현하려면 어려울 수 있을 거라 생각했는데, iOS 13부턴 CompositionalLayout을 쓰면 생각보다 편하게 구현할 수 있을 것 같습니다. 이 외에도 다양한 레이아웃 구현이 가능한데, developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views

이곳을 참고하시면 좋을 것  같습니다.

 

 

References

www.raywenderlich.com/5436806-modern-collection-views-with-compositional-layouts

developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views

www.youtube.com/watch?v=y1uXXVUu43o

developer.apple.com/documentation/uikit/uicollectionlayoutlistconfiguration

developer.apple.com/documentation/uikit/nscollectionlayoutsection

developer.apple.com/documentation/uikit/nscollectionlayoutgroup