Danny의 iOS 컨닝페이퍼
article thumbnail

[TIL #13] 2023 / 04 / 12 ~ 2023 / 04 / 13

저번에 공부한 FlowLayout 에 이어서,

오늘은 복잡한 레이아웃도 쉽게 구현할 수 있도록 도와주는

CompositionalLayout에 대해서 알아봅시다.

 

공식문서 예제 를 참고해서 만들었습니다.

 

 

CompositionalLayout 기본

CompositionalLayout은 <Section + Group + Item>으로 구성돼 있습니다.

이 각각의 요소들로 조합하여, 원하는 레이아웃으로 구성할 수 있죠.

 

일단 CompositionalLayout의 레이아웃 생성 메서드들을 먼저 살펴봅시다.

UICollectionViewCompositionalLayout(section:)
UICollectionViewCompositionalLayout(section:, configuration:)

UICollectionViewCompositionalLayout(sectionProvider:)
UICollectionViewCompositionalLayout(sectionProvider:, configuration:)

 

section

단일 Section의 레이아웃 구성할 때 사용됩니다.

 

sectionProvider

클로저 형식으로 여러 Section에 대하여 레이아웃을 구성할 수 있습니다.

 

configuration

Section의 inset 및 스크롤 방향 등을 설정해 줄 수 있습니다.

(Section의 Header, Footer도 가능)

 

 

CompositionalLayout을 만들어보자

자, 일단 여러 섹션을 만들어줄 수 있는 sectionProvider를 사용해 만들어봅시다.

 

 

기본 세팅

오늘은 델리게이트로 dataSource를 설정하기 사용하기보단,

CollectionViewDiffableDataSource를 직접 만들어 dataSource를 구현해 주었습니다. 

enum Section: CaseIterable {
    case main
}

class ViewController: UIViewController {
    
    // MARK: - Properties
    
    private lazy var collectionView: UICollectionView = {
        // collectionViewLayout을 만들고 넣어줘야 한다. 아래 레이아웃 그리기 참고
        let view = UICollectionView(frame: view.bounds, collectionViewLayout: self.createLayout())
        view.showsHorizontalScrollIndicator = false
        view.register(CustomCell.self, forCellWithReuseIdentifier: CustomCell.Identifier)
        return view
    }()
    
    // dataSource 대신 DiffableDataSource로 직접 만들어 줬습니다.
    private var dataSource: UICollectionViewDiffableDataSource<Section, Int>!
    
    // MARK: - Life Cycles
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(collectionView)
        collectionView.snp.makeConstraints {
            $0.edges.equalTo(view.safeAreaLayoutGuide)
        }
        
        configureDataSource()
    }
    
    // MARK: - Configurations
    
    private func configureDataSource() {
        dataSource = UICollectionViewDiffableDataSource<Section, Int>(collectionView: collectionView) { (collectionView, indexPath, itemIdentifier) -> UICollectionViewCell in
            
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CustomCell.Identifier, for: indexPath) as? CustomCell else {
                fatalError("Faild to load CustomCell")
            }
            
            cell.setupTitle(text: "\(itemIdentifier)")
            return cell
        }
        
        // dataSource를 구성하기 위해 Snapshot으로 섹션 및 아이템의 정보를 업데이트
        var snapShot = NSDiffableDataSourceSnapshot<Section, Int>()
        snapShot.appendSections([Section.main])
        snapShot.appendItems(Array(1...24))
        dataSource.apply(snapShot, animatingDifferences: true)
    }
}

 

CusomCell도 만들어 줬고요.

class CustomCell: UICollectionViewCell {
    
    static let Identifier = "CustomCell"
    
    let titleLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.contentView.addSubview(titleLabel)
        titleLabel.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        // 랜덤으로 배경색 지정
        self.contentView.backgroundColor = UIColor(
            red: drand48(),
            green: drand48(),
            blue: drand48(),
            alpha: drand48()
        )
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        setupTitle(text: "")
    }
    
    func setupTitle(text: String) {
        titleLabel.text = text
    }
}

 

 

레이아웃 그리기 (단일 Section)

만드는 순서는 안쪽에서 바깥쪽으로 차근차근 구현한다고 생각하면 그리기 편합니다.

(Item -> Group -> Section)

 

일단 들어가기 전, 컬렉션뷰의 항목 크기를 쉽게 정할 수 있는

NSCollectionLayoutDimension 에 대해서 간단히 알아봅시다.

 

이렇게 총 4가지가 존재합니다.

absolute, estimated, (fractionalWidth, fractionalHeight)

// 절대값으로 크기를 표현 (50, 50)
let absoluteSize = NSCollectionLayoutSize(widthDimension: .absolute(50),
                                         heightDimension: .absolute(50))

// 크기를 모르거나 크기 변경이 자동으로 필요할 때 사용됩니다.
// 셀의 초기 예상 크기를 나타낸다. (200, 100) 렌더링 시 크기가 정해집니다.
let estimatedSize = NSCollectionLayoutSize(widthDimension: .estimated(200),
                                          heightDimension: .estimated(100))

// 컨테이너 크기에 따라 백분율로 크기를 설정해 줄 수 있다. (컨테이너 크기에 대해 20%, 20%의 크기)
let fractionalSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),
                                           heightDimension: .fractionalHeight(0.2))

이것들을 통해 원하는 모양으로 만들어 주시면 됩니다.

 

위의 그림과 함께 section, group, item을 생각하면서

CompositionalLayout를 잡아봅시다.

private func createLayout() -> UICollectionViewLayout {
    
    let numberOfRows = 1.0 / 4.0       // 행의 갯수
    let numberOfColumns = 1.0 / 6.0    // 열의 갯수
    let itemInset: CGFloat = 5.0
    
    // CompositionalLayout(sectionProvider:)
    let layout = UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in
        
        // item
        let itmeSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(numberOfRows),
            heightDimension: .fractionalHeight(1)
        )
        let item = NSCollectionLayoutItem(layoutSize: itmeSize)
        item.contentInsets = NSDirectionalEdgeInsets(top: itemInset, leading: itemInset, bottom: itemInset, trailing: itemInset)
        
        // group
        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1),
            heightDimension: .fractionalHeight(numberOfColumns)
        )
        // 정렬할 방향를 정할 수 있습니다. (horizontal, vertical) 이상하게 vertical은 한 줄로 밖에 안되네요.
        let group = NSCollectionLayoutGroup.horizontal(
            layoutSize: groupSize,
            subitems: [item]
        )
        // 이렇게 직접 행의 갯수로 지정도 가능하지만 복잡합니다. (값에 따라 셀이 화면에서 벗어날 수 있습니다.)
        // 이 메서드는 아마도 한 행 또는 한 열로 구성된 콜렉션뷰를 구현할 때 편하게 사용될 것 같습니다.
        // let group = NSCollectionLayoutGroup.horizontal(
        //     layoutSize: groupSize,
        //     repeatingSubitem: item,
        //     count: colunm
        // )
        
        // section
        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20)
        
        return section
    }
    
    return layout
}

 

완성

 

 

여러 섹션 만들기

sectionProvider는 여러 개의 Section을 구성할 수 있다고 말씀드렸죠?

어떻게 그려야 될까요?

 

일단 Section에서 case를 한 개 더 추가해 줍시다.

3행과 6행으로 구성된 셀을 만들 예정입니다

enum Section: CaseIterable {
    case grid3
    case grid6
}

 

section에 대해 분기처리를 하여,

원하는 모양으로 각 Section을 그려주시면 됩니다.

private func createLayout() -> UICollectionViewLayout {
    
    // CompositionalLayout(sectionProvider:)
    let layout = UICollectionViewCompositionalLayout { (sectionIndex, layoutEnvironment) in
        
        // section에 따라 레이아웃 그리기 (첫 번째 section)
        guard let section = Section(rawValue: sectionIndex) else { return nil }
        if section == .grid3 {
            // item
            let itmeSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1/3),
                heightDimension: .fractionalHeight(1)
            )
            let item = NSCollectionLayoutItem(layoutSize: itmeSize)
            item.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
            
            // group
            let groupSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalHeight(1/7)
            )
            let group = NSCollectionLayoutGroup.horizontal(
                layoutSize: groupSize,
                subitems: [item]
            )
            
            // section
            let section = NSCollectionLayoutSection(group: group)
            
            return section
            
        } else {
            // 두 번째 section

            // item
            let itmeSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1/6),
                heightDimension: .fractionalHeight(1)
            )
            let item = NSCollectionLayoutItem(layoutSize: itmeSize)
            item.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
            
            // group
            let groupSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalHeight(1/5)
            )
            let group = NSCollectionLayoutGroup.horizontal(
                layoutSize: groupSize,
                subitems: [item]
            )
            
            // section
            let section = NSCollectionLayoutSection(group: group)
            
            return section
        }
    }
    return layout
}

 

레이아웃을 잡았으니,

dataSource에서 각 Section 및 Item들을 업데이트시켜 줍시다.

private func configureDataSource() {
    dataSource = UICollectionViewDiffableDataSource<Section, Int>(collectionView: collectionView) { (collectionView, indexPath, itemIdentifier) -> UICollectionViewCell in
        
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CustomCell.Identifier, for: indexPath) as? CustomCell else {
            fatalError("Faild to load CustomCell")
        }
        
        cell.setupTitle(text: "\(itemIdentifier)")
        return cell
    }
    
    // dataSource를 구성하기 위해 Snapshot으로 섹션 및 아이템의 정보를 업데이트
    var snapShot = NSDiffableDataSourceSnapshot<Section, Int>()
    // 모든 케이스에 대한 forEach
    Section.allCases.forEach {
        snapShot.appendSections([$0])
        switch $0 {
        case .grid3:
            snapShot.appendItems(Array(1...12))
        case .grid6:
            snapShot.appendItems(Array(13...24))
        }
    }
    dataSource.apply(snapShot, animatingDifferences: true)
}

 

완성

이와 같이 계속 이어나가면서 레이아웃을 만들어주면, 여러 Section도 손쉽게 구현 가능합니다.

 

 

Header를 추가해 보자

바로 위의 예제에서 Header도 한번 추가해 볼까요? 

(보기 쉽게 👉 모양으로 주석처리 해뒀습니다.)

 

Header나 Footer 두 개 모두 그리는 방식은 위랑 비슷합니다.

 

일단 UICollectionReusableView로 HeaderView를 만들어주세요.

class CustomHeaderView: UICollectionReusableView {
    
    static let Identifier = "Header"
    
    let titleLabel: UILabel = {
        let label = UILabel()
        label.backgroundColor = .gray
        label.font = .preferredFont(forTextStyle: .title1)
        label.textAlignment = .center
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.addSubview(titleLabel)
        titleLabel.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        setupTitle(text: "")
    }
    
    func setupTitle(text: String) {
        titleLabel.text = text
    }
}

 

Header를 등록도 해주세요.

private lazy var collectionView: UICollectionView = {
    let view = UICollectionView(frame: .zero, collectionViewLayout: self.createLayout())
    view.showsHorizontalScrollIndicator = false
    view.register(CustomCell.self, forCellWithReuseIdentifier: CustomCell.Identifier)
    // 👉 header 등록
    view.register(CustomHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: CustomHeaderView.Identifier)
    return view
}()

 

Header 레이아웃 그리기 및 Section에 추가하기

private func createLayout() -> UICollectionViewLayout {
    
    // CompositionalLayout(sectionProvider:)
    let layout = UICollectionViewCompositionalLayout { (sectionIndex, layoutEnvironment) in
        
        // 👉 Header
        let headerSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1),
            heightDimension: .absolute(60)
        )
        let header =  NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: headerSize,
            elementKind: UICollectionView.elementKindSectionHeader,
            alignment: .top
        )
        
        // section에 따라 레이아웃 그리기 (첫 번째 section)
        guard let section = Section(rawValue: sectionIndex) else { return nil }
        if section == .grid3 {
            // item
            let itmeSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1/3),
                heightDimension: .fractionalHeight(1)
            )
            let item = NSCollectionLayoutItem(layoutSize: itmeSize)
            item.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
            
            // group
            let groupSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalHeight(1/7)
            )
            let group = NSCollectionLayoutGroup.horizontal(
                layoutSize: groupSize,
                subitems: [item]
            )
            
            // section
            let section = NSCollectionLayoutSection(group: group)
            // 👉 Header를 Section에 추가해준다
            section.boundarySupplementaryItems = [header]
            
            return section
            
        } else {
            // 두 번째 section

            // item
            let itmeSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1/6),
                heightDimension: .fractionalHeight(1)
            )
            let item = NSCollectionLayoutItem(layoutSize: itmeSize)
            item.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
            
            // group
            let groupSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalHeight(1/5)
            )
            let group = NSCollectionLayoutGroup.horizontal(
                layoutSize: groupSize,
                subitems: [item]
            )
            
            // section
            let section = NSCollectionLayoutSection(group: group)
            // 👉 Header를 Section에 추가해준다
            section.boundarySupplementaryItems = [header]
            
            return section
        }
    }

    return layout
}

 

CollectionView의 dataSource에서 Header를 생성해 줍니다.

private func configureDataSource() {
    dataSource = UICollectionViewDiffableDataSource<Section, Int>(collectionView: collectionView) { (collectionView, indexPath, itemIdentifier) -> UICollectionViewCell in
        
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CustomCell.Identifier, for: indexPath) as? CustomCell else {
            fatalError("Faild to load CustomCell")
        }
        
        cell.setupTitle(text: "\(itemIdentifier)")
        return cell
    }
    
    // 👉 dataSource에서 Header, Footere들을 만들어 줍니다.
    // footer는 elementKindSectionFooter를 사용해서 분기처리 해주면 되겠죠!?
    dataSource.supplementaryViewProvider = { (collectionView, kind, indexPath) in
        guard kind == UICollectionView.elementKindSectionHeader else { return nil }
        let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CustomHeaderView.Identifier, for: indexPath) as? CustomHeaderView
        
        header?.setupTitle(text: "Section \(indexPath.section)")
        return header
    }
    
    
    
    // dataSource를 구성하기 위해 Snapshot으로 섹션 및 아이템의 정보를 업데이트
    var snapShot = NSDiffableDataSourceSnapshot<Section, Int>()
    Section.allCases.forEach {
        snapShot.appendSections([$0])
        switch $0 {
        case .grid3:
            snapShot.appendItems(Array(1...12))
        case .grid6:
            snapShot.appendItems(Array(13...24))
        }
    }
    dataSource.apply(snapShot, animatingDifferences: true)
}

 

완성

 

코드가 길게 보이는 이유는 위에 있던 Section만드는 코드도 전부 써줘서 그렇습니다.

주석에 손가락 마킹(👉) 된 곳을 중심으로 보세요.

 

 

Nested 하게 레이아웃 그리기

기본 세팅은 첫 번째 코드(단일 Section)와 같고 레이아웃만 Nested하게 잡아봅시다.

 

이런 모양이 바로 Nested 한 모양입니다.

 

처음 크기 계산하는 게 생각보다 어렵지만, 차근차근 따라 해 보세요.

 

그림과 함께 이해해 봅시다.

선의 색으로 Group을 나눠봤습니다.

 

1번 Item, 2번 Item, 3번 Item을 그려준 다음,

이 Item들을 Group으로 묶고 방향정해서 모양을 잡아 주는 형식입니다.

 

먼저 1, 2, 3번 Item을 크기에 맞게 만들어 줍니다.

Item을 만들 땐, Item이 포함될 Group과 함께 크기를 계산해서 만들어줘야 합니다.

(그래서 복잡하다고 느낄 수 있어요)

 

1. 👉 3번 Item을 갖고 수직방향의 Group을 통해 3번 반복하여 (3, 4, 5 Itme) Grop을 만들어 줍니다.

2. 🐳 2번 Itme과 위에서 만든 Group(3, 4, 5번 Item)을 갖고 수평방향으로 Group을 만들어 줍니다.

3. 🔥 1번 Item과 마지막에 만들어준 Group(2, 3, 4, 5번 Item)을 갖고 수직방향으로 Group을 만들어줍니다.

 

코드를 보시죠, 주석으로 간단히 정리해 놨습니다.

private func createLayout() -> UICollectionViewLayout {
    
    // CompositionalLayout(sectionProvider:)
    let layout = UICollectionViewCompositionalLayout { (sectionIndex, layoutEnvironment) in
        
        // 1번 Item
        let topItme = NSCollectionLayoutItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                               heightDimension: .fractionalHeight(1/2)))  // 절반 높이
        topItme.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 5, trailing: 10)
        
        // 2번 Item
        let leadingItem = NSCollectionLayoutItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(2/3),   // 2/3 크기의 너비
                                               heightDimension: .fractionalHeight(1)))  // 나중에 horizontalGroup에서 크기를 설정해줌
        leadingItem.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 10, bottom: 10, trailing: 5)
        
        // 3 Item
        let trailingItem = NSCollectionLayoutItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),       // 나중에 trailingGroup에서 너비를 설정
                                               heightDimension: .fractionalHeight(1/3)))  //  1/3 높이
        trailingItem.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 10, trailing: 10)
        
        // 👉 3 Item을 수직방향 Group을 통해 3번 반복하여 3, 4, 5 Item을 만들어준다.
        let trailingGroup = NSCollectionLayoutGroup.vertical(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3),   // trailingItem들은 1/3 너비
                                               heightDimension: .fractionalHeight(1)),
            repeatingSubitem: trailingItem, count: 3)
        
        // 🐳 2 Item + (3, 4, 5) Item을 수평방향 Group으로 묶어준다.
        let horizontalGroup = NSCollectionLayoutGroup.horizontal(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                               heightDimension: .fractionalHeight(1/2)),  // 2번 item 및 trailingGroup은 절반 높이
            subitems: [leadingItem, trailingGroup]
        )
        
        // 🔥 1 Item 과 마지막에 묶은 Group(2, 3, 4, 5 Itme)을 수직방향 Group으로 묶어 준다.
        let nestedGroup = NSCollectionLayoutGroup.vertical(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                               heightDimension: .fractionalHeight(1)),
            subitems: [topItme, horizontalGroup]
        )
        
        let section = NSCollectionLayoutSection(group: nestedGroup)
        
        return section
        
    }
    
    return layout
}

 

몇 번 사용해 보니, 크기를 정하는 팁으로

보이는 화면을 기준으로 레이아웃 한 세트를 만들어주는 느낌으로 만들면 관리가 편해집니다?!

(한 개의 화면에 레이아웃 한 세트 == 1~5까지 셀을 한 화면에 그려주기)

 

 

그럼 이와 같이, 전체가 포함된 그룹인 nestedGroup의 높이를 절반으로 줄여주면,

아래와 같이 쉽게 조절이 가능하죠.

let nestedGroup = NSCollectionLayoutGroup.vertical(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                       heightDimension: .fractionalHeight(0.5)),
    subitems: [topItme, horizontalGroup]
)

 

전체 코드는 GitHub 에 올려놨습니다.

 

 

마무리

CompositionalLayout의 설정 중 기본인 레이아웃 잡는 법을 알아봤습니다.

 

델리게이트를 통해 dataSource를 정의하면 조금 더 편하긴 한데,

오늘은 직접 UICollectionViewDiffableDataSource를 사용해서 구현해 봤습니다.

 

다음엔 UICollectionViewDiffableDataSource에 대해서 공부해봐야 겠습니다.

처음 적용하는데 애먹었네요. ㅠ.ㅠ

 

그리고 아직 Section에 visibleItemsInvalidationHandler을 통한 애니메이션 기능이나

페이지 형식 스크롤 기능, 또 BackgroundView, Badge 등

여기선 사용하지 않은 기능들이 많은데, 이건 시간이 될 때 차근차근 올려보도록 하겠습니다.

 

 

 

 

반응형
profile

Danny의 iOS 컨닝페이퍼

@Danny's iOS

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!