apple/iOS

[iOS] UICollectionView CompositionalLayout

lgvv 2022. 9. 18. 18:08

UICollectionView CompositionalLayout

 

공식문서를 열었는데 샘플코드가 14.0 이상으로 나옴. - 글 제일 하단에 참고 부분에 있음

취업 준비하면서 13.0을 기준으로 공부하고 있어서

 

샘플코드

Xcode를 통해 열어보니까 다행히도 13.0 이상에서도 사용이 가능했음

UICollectionViewCompositionalLayout

이전에는 DataSource만 SnapShot을 사용하고 레이아웃은 FlowLayout을 사용했었는데, 이 부분마저도 공부해보려고 함.

 

✅ SnapShot + FlowLayout 포스팅

2022.09.04 - [iOS] - [iOS] UICollectionView에 대해서 알아보기 7편 (UICollectionViewDiffableDataSource)

 

[iOS] UICollectionView에 대해서 알아보기 7편 (UICollectionViewDiffableDataSource)

 UICollectionView에 대해서 알아보기 7편  (UICollectionViewDiffableDataSource) iOS 13이상에서 사용하능하다. 결과 코드 7편에서는 이거 알아보자! UICollectionViewDiffableDataSource OverView 음,,..

rldd.tistory.com

 

목차

1. UI결과물

2. 레이아웃 코드

3. 레이아웃 이론 학습

4. 전체코드

 

 

 

✅ UI 결과물

오늘 구현해볼 UI

 

애니메이션 gif

 

 

✅ 레이아웃 코드 ✅

func createLayout() -> UICollectionViewCompositionalLayout {
        return UICollectionViewCompositionalLayout { section, env -> NSCollectionLayoutSection? in
            
            let section = MySection(rawValue: section)
            
            switch section {
            case .animation:
                let item = NSCollectionLayoutItem(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(0.25),
                        heightDimension: .fractionalWidth(0.25)
                    )
                )
                item.contentInsets = .init(top: 0, leading: 5, bottom: 0, trailing: 5)
                
                let group = NSCollectionLayoutGroup.horizontal(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(1),
                        heightDimension: .fractionalWidth(0.25)
                    ),
                    subitems: [item]
                )
                group.contentInsets = .init(top: 20, leading: 20, bottom: 20, trailing: 20)
                
                let section = NSCollectionLayoutSection(group: group)
                section.contentInsets = .init(top: 10, leading: 10, bottom: 10, trailing: 10)
                section.orthogonalScrollingBehavior = .continuous
                section.visibleItemsInvalidationHandler = { (items, offset, environment) in
                    items.forEach { item in
                        let distanceFromCenter = abs((item.frame.midX - offset.x) - environment.container.contentSize.width / 2.0)
                        let minScale: CGFloat = 0.7
                        let maxScale: CGFloat = 1.1
                        let scale = max(maxScale - (distanceFromCenter / environment.container.contentSize.width), minScale)
                        item.transform = CGAffineTransform(scaleX: scale, y: scale)
                    }
                }
                
                return section
            case .category:
                let item = NSCollectionLayoutItem(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(0.25),
                        heightDimension: .fractionalWidth(0.25)
                    )
                )
                item.contentInsets = .init(top: 0, leading: 5, bottom: 0, trailing: 5)
                
                let group = NSCollectionLayoutGroup.horizontal(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(1),
                        heightDimension: .fractionalWidth(0.25)
                    ),
                    subitems: [item]
                )
                group.contentInsets = .init(top: 20, leading: 20, bottom: 20, trailing: 20)
                let section = NSCollectionLayoutSection(group: group)
                section.contentInsets = .init(top: 10, leading: 10, bottom: 10, trailing: 10)
                
                return section
            case .custom:
                let inset: CGFloat = 2.5
                
                // Items
                let largeItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalHeight(1))
                let largeItem = NSCollectionLayoutItem(layoutSize: largeItemSize)
                largeItem.contentInsets = NSDirectionalEdgeInsets(top: inset, leading: inset, bottom: inset, trailing: inset)
                
                let smallItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(0.5))
                let smallItem = NSCollectionLayoutItem(layoutSize: smallItemSize)
                smallItem.contentInsets = NSDirectionalEdgeInsets(top: inset, leading: inset, bottom: inset, trailing: inset)
                
                // Nested Group
                let nestedGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.25), heightDimension: .fractionalHeight(1))
                let nestedGroup = NSCollectionLayoutGroup.vertical(layoutSize: nestedGroupSize, subitems: [smallItem])
                
                // Outer Group
                let outerGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(0.5))
                let outerGroup = NSCollectionLayoutGroup.horizontal(layoutSize: outerGroupSize, subitems: [largeItem, nestedGroup, nestedGroup])
                
                // Section
                let section = NSCollectionLayoutSection(group: outerGroup)
                section.contentInsets = NSDirectionalEdgeInsets(top: inset, leading: inset, bottom: inset, trailing: inset)
                
                // Supplementary Item
                //                let headerItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100))
                //                let headerItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerItemSize, elementKind: "header", alignment: .top)
                //                section.boundarySupplementaryItems = [headerItem]
                
                return section
            default:
                return nil
            }
            
        }
    }

 

✅ 레이아웃 이론

 

 

애니메이션의 경우에는 글 하단의 참고 링크를 확인할 것.

애니메이션은 엄청 어렵지는 않으나, 수학적 사고가 필요한 것 같음.

추가로 orthogonalScrollingBehavior를 사용하여 페이징을 처리할 수 있음

 

 

✅ 전체 코드

//
//  MuseumCollectionViewController.swift
//  AppleCollectionView
//
//  Created by Hamlit Jason on 2022/09/18.
//

import UIKit
import SnapKit
import Nuke
import RxSwift

final class MuseumCollectionViewController: UIViewController {
    var disposeBag = DisposeBag()
    var businesses = KmongAsset.비즈니스.toArray()
    
    enum MySection: Int {
        case animation
        case category
        case custom
    }
    
    enum Item: Hashable {
        case animationItem(UIImage)
        case categoryItem(UIImage)
        case customItem(UIImage)
    }
    
    var collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout())
    var dataSource: UICollectionViewDiffableDataSource<MySection, Item>!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupCollectionView()
        
        fetchDataSource()
        fetchSnapshot()
        
        bind()
    }
    
    func bind() {
        collectionView.rx.itemSelected
            .debug("✅")
            .bind { _ in }
            .disposed(by: disposeBag)
    }
    
    func setupCollectionView() {
        collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())
        
        view.addSubview(collectionView)
        collectionView.register(MuseumCategoryCVC.self, forCellWithReuseIdentifier: MuseumCategoryCVC.id)
        
        
        collectionView.snp.makeConstraints {
            $0.edges.equalTo(view.safeAreaLayoutGuide)
        }
    }
    
    func fetchDataSource() {
        dataSource = UICollectionViewDiffableDataSource<MySection, Item>(
            collectionView: collectionView) {
                collectionView, indexPath, itemIdentifier in
                
                let section = MySection(rawValue: indexPath.section)
                print("section: \(section): indexPath \(indexPath): item \(itemIdentifier) \(type(of: itemIdentifier))")
                
                var value: Any
                switch itemIdentifier {
                case .animationItem(let item):
                    value = item
                case .categoryItem(let item):
                    value = item
                case .customItem(let item):
                    value = item
                }
                
                switch section {
                case .animation:
                    guard let cell = collectionView.dequeueReusableCell(
                        withReuseIdentifier: MuseumCategoryCVC.id,
                        for: indexPath
                    ) as? MuseumCategoryCVC else { return UICollectionViewCell() }
                    
                    cell.configureCell(with: value as! UIImage)
                    
                    return cell
                case .category:
                    guard let cell = collectionView.dequeueReusableCell(
                        withReuseIdentifier: MuseumCategoryCVC.id,
                        for: indexPath
                    ) as? MuseumCategoryCVC else { return UICollectionViewCell() }
                    
                    cell.configureCell(with: value as! UIImage)
                    
                    return cell
                case .custom:
                    guard let cell = collectionView.dequeueReusableCell(
                        withReuseIdentifier: MuseumCategoryCVC.id,
                        for: indexPath
                    ) as? MuseumCategoryCVC else { return UICollectionViewCell() }
                    
                    cell.configureCell(with: value as! UIImage)
                    
                    return cell
                default:
                    return UICollectionViewCell()
                }
            }
    }
    
    func fetchSnapshot() {
        var snapshot = NSDiffableDataSourceSnapshot<MySection, Item>()
        snapshot.appendSections([.animation, .category, .custom])
        businesses.map { snapshot.appendItems([.animationItem($0)], toSection: .animation) }
        businesses.map { snapshot.appendItems([.categoryItem($0)], toSection: .category) }
        businesses.map { snapshot.appendItems([.customItem($0)], toSection: .custom) }
        
        dataSource.apply(snapshot, animatingDifferences: true)
    }
    
    func createLayout() -> UICollectionViewCompositionalLayout {
        return UICollectionViewCompositionalLayout { section, env -> NSCollectionLayoutSection? in
            
            let section = MySection(rawValue: section)
            
            switch section {
            case .animation:
                let item = NSCollectionLayoutItem(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(0.25),
                        heightDimension: .fractionalWidth(0.25)
                    )
                )
                item.contentInsets = .init(top: 0, leading: 5, bottom: 0, trailing: 5)
                
                let group = NSCollectionLayoutGroup.horizontal(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(1),
                        heightDimension: .fractionalWidth(0.25)
                    ),
                    subitems: [item]
                )
                group.contentInsets = .init(top: 20, leading: 20, bottom: 20, trailing: 20)
                
                let section = NSCollectionLayoutSection(group: group)
                section.contentInsets = .init(top: 10, leading: 10, bottom: 10, trailing: 10)
                section.orthogonalScrollingBehavior = .continuous
                section.visibleItemsInvalidationHandler = { (items, offset, environment) in
                    items.forEach { item in
                        let distanceFromCenter = abs((item.frame.midX - offset.x) - environment.container.contentSize.width / 2.0)
                        let minScale: CGFloat = 0.7
                        let maxScale: CGFloat = 1.1
                        let scale = max(maxScale - (distanceFromCenter / environment.container.contentSize.width), minScale)
                        item.transform = CGAffineTransform(scaleX: scale, y: scale)
                    }
                }
                
                return section
            case .category:
                let item = NSCollectionLayoutItem(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(0.25),
                        heightDimension: .fractionalWidth(0.25)
                    )
                )
                item.contentInsets = .init(top: 0, leading: 5, bottom: 0, trailing: 5)
                
                let group = NSCollectionLayoutGroup.horizontal(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(1),
                        heightDimension: .fractionalWidth(0.25)
                    ),
                    subitems: [item]
                )
                group.contentInsets = .init(top: 20, leading: 20, bottom: 20, trailing: 20)
                let section = NSCollectionLayoutSection(group: group)
                section.contentInsets = .init(top: 10, leading: 10, bottom: 10, trailing: 10)
                
                return section
            case .custom:
                let inset: CGFloat = 2.5
                
                // Items
                let largeItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalHeight(1))
                let largeItem = NSCollectionLayoutItem(layoutSize: largeItemSize)
                largeItem.contentInsets = NSDirectionalEdgeInsets(top: inset, leading: inset, bottom: inset, trailing: inset)
                
                let smallItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(0.5))
                let smallItem = NSCollectionLayoutItem(layoutSize: smallItemSize)
                smallItem.contentInsets = NSDirectionalEdgeInsets(top: inset, leading: inset, bottom: inset, trailing: inset)
                
                // Nested Group
                let nestedGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.25), heightDimension: .fractionalHeight(1))
                let nestedGroup = NSCollectionLayoutGroup.vertical(layoutSize: nestedGroupSize, subitems: [smallItem])
                
                // Outer Group
                let outerGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(0.5))
                let outerGroup = NSCollectionLayoutGroup.horizontal(layoutSize: outerGroupSize, subitems: [largeItem, nestedGroup, nestedGroup])
                
                // Section
                let section = NSCollectionLayoutSection(group: outerGroup)
                section.contentInsets = NSDirectionalEdgeInsets(top: inset, leading: inset, bottom: inset, trailing: inset)
                
                // Supplementary Item
                //                let headerItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100))
                //                let headerItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerItemSize, elementKind: "header", alignment: .top)
                //                section.boundarySupplementaryItems = [headerItem]
                
                return section
            default:
                return nil
            }
            
        }
    }
}

class MuseumCategoryCVC: UICollectionViewCell {
    static let id: String = "MuseumCategoryCVC"
    let categoryImage = UIImageView()
    
    // MARK: - Initialize
    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(categoryImage)
        categoryImage.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func configureCell(with item: UIImage) {
        categoryImage.nuke_display(image: item)
    }
}

class MuseumCategoryHeaderView: UICollectionReusableView {
    
}

 

 

 

 

✅ NOTE: - 다이나믹 헤잇!

item이랑 group 모두 estimated해야합니다 ㅠ 그래야 정상적으로 먹음!

 

https://medium.com/swiftblade/dynamic-cell-height-in-uicollectionviewcompositionallayout-9b46d2032890

 

Dynamic cell height in UICollectionViewCompositionalLayout

Continuing my UICollectionViewCompositionalLayout series from last time. Today we will discuss how to make cell hight dynamic!

medium.com

 

 

 

 

 

 

 

(아래 공식 문서를 참고하여 작성)

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

 

Apple Developer Documentation

 

developer.apple.com

 

(그냥 여기 다 있음 - 문서는 이게 최고)

https://lickability.com/blog/getting-started-with-uicollectionviewcompositionallayout/

 

Getting Started with UICollectionViewCompositionalLayout

At WWDC 2019, Apple introduced and later documented an unbelievable API for building complex layouts with ease. UICollectionViewCompositionalLayout promised to simplify collection view layouts using a more declarative approach without the need to subclass

lickability.com

 

(애니메이션)

https://ios-development.tistory.com/948

 

[iOS - swift] 4. UICollectionViewCompositionalLayout - 개념 (orthogonalScrollingBehavior, 수평 스크롤, visibleItemsInval

1. UICollectionViewCompositionalLayout - 개념 (section, group, item) 2. UICollectionViewCompositionalLayout - 개념 SupplementaryView, Header, Footer) 3. UICollectionViewCompositionalLayout - 개념..

ios-development.tistory.com