apple/iOS, UIKit, Documentation

iOS CompositionalLayout (UICollectionView)

lgvv 2022. 9. 18. 18:08

iOS CompositionalLayout (UICollectionView)

 

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

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

 

샘플코드


CompositionalLayout은 다행히도 13.0 이상에서도 사용이 가능.

 

UICollectionViewCompositionalLayout


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

 

그동안 FlowLayout 만 사용했던 이유는 익숙해져서 Carousel 등 커스텀 구현이 용이하고 서비서적으로 막히는 부분이 없었기 때문인데, 새로운 부분도 알고 갖추면 더 좋을 것으로 기대됨.

 

 

목차

  • 1. UI결과물
  • 2. 레이아웃 코드
  • 3. 레이아웃 이론 학습
  • 4. 전체코드

 

 

 

스크린샷

결과 스크린샷

 

오늘 구현해볼 UI

 

 

GIF 영상

결과 GIF

애니메이션 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 {
    
}

 

 

 

 

다이나믹 Height (동적 높이)

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