[iOS] Modern Collection Views

Han's·2023년 12월 8일
0
post-thumbnail

ModernCollectionView란?

Bring compositional layouts to your app and simplify updating your user interface with diffable data sources.

Apple 공식 문서의 설명을 보면
CompositionalLayoutDiffableDataSource를 같이 사용하는 것을 ModernCollectionView라고 하는 것을 알 수 있습니다.

그렇다면 CompositionalLayout과 DiffableDataSource가 무엇인지 알아봐야겠죠?


CompositionalLayout

CompositionalLayout을 사용하면 복잡한 레이아웃을 구현하고 유연하게 관리할 수 있습니다.

  • 구성
    CompositionalLayout은 Section, Group, Item으로 구성됩니다.
    Group은 한 화면에 들어가는 item들을 묶는 단위입니다.


DiffableDataSource

DiffableDataSource는 데이터를 관리하고 UI를 업데이트하는 역할을 합니다.
Data Source와 달리 데이터가 달라진 부분을 추적하여 자연스럽게 UI를 업데이트합니다.

DataSourceDiffableDataSource

출처: https://velog.io/@ellyheetov/UI-Diffable-Data-Source

DiffableDataSource는 다음 두 가지 generic type을 가집니다.

  • SectionIdentifier
  • ItemIdentifier

두 파라미터는 반드시 Hashable 해야 하므로 Hashable 프로토콜을 채택해야 합니다.

struct Section: Hashable {
    let id: String
}

enum Item: Hashable {
    case random(UserInfo)
    case rank(UserInfo)
    case near(UserInfo)
    case new(UserInfo)
    case pick(UserInfo)
}

private var dataSource: UICollectionViewDiffableDataSource<Section, Item>?

구현 목표

아래와 같은 복잡한 뷰를 ModernCollectionView를 사용하여 구현해 보겠습니다.

쉽게 설명하기 위해 UI(CompositionalLayout)와 Data(DiffableDataSource)로 나눠서 설명을 하겠습니다.


ModernCollectionView 구현하기

  • UI (CompositionalLayout)
final class MainPageView: UIView {
    lazy var collectionView: UICollectionView = {
        let view = UICollectionView(frame: .zero, collectionViewLayout: .init())
        view.register(RankSectionCell.self,
                      forCellWithReuseIdentifier: RankSectionCell.identifier)
        view.register(DefaultSectionCell.self,
                      forCellWithReuseIdentifier: DefaultSectionCell.identifier)
        view.register(SectionHeaderView.self,
                      forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
                      withReuseIdentifier: SectionHeaderView.identifier)
        view.setCollectionViewLayout(collectionViewLayout(), animated: true)
        view.refreshControl = refreshControl
        view.showsVerticalScrollIndicator = false
        return view
    }()
    
    init() {
        super.init(frame: .zero)
        backgroundColor = ThemeColor.backGroundColor
        setLayout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

위의 코드에서 collectionView의 각 Cell과 HeaderView를 등록해 주고 setCollectionViewLayout 메서드로 Layout을 설정해 줍니다

private extension MainPageView {
    func setLayout() {
        self.addSubview(collectionView)
        
        collectionView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
    
    func collectionViewLayout() -> UICollectionViewCompositionalLayout {
        UICollectionViewCompositionalLayout { sectionNum, env -> NSCollectionLayoutSection in
            switch sectionNum {
            case 0:
                return self.randomSection()
            case 1:
                return self.rankSection()
            case 2:
                return self.newSection()
            default:
                return self.defaultSection()
            }
        }
    }
}

위의 코드에서 collectionViewLayout메서드는 sectionNum에 따라 각각 다른 Layout을 return 해줍니다.

private extension MainPageView {
    func randomSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1), // group의 넓이와 동일
            heightDimension: .fractionalHeight(1)) // group의 높이와 동일
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = .init(top: 15, leading: 10, bottom: 0, trailing: 10)
        
        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1), // section의 넓이와 동일
            heightDimension: .estimated(400)) // 400이지만 변경 가능
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        
        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .groupPaging
        section.contentInsets = .init(top: 10, leading: 0, bottom: 10, trailing: 0)
        
        let sectionHeader = headerSection()
        section.boundarySupplementaryItems = [sectionHeader]
        return section
    }
    
    func rankSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1), // group의 넓이와 동일
            heightDimension: .fractionalHeight(1)) // group의 높이와 동일
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = .init(top: 10, leading: 5, bottom: 0, trailing: 5)
        
        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(0.45), // section의 넓이에 45%
            heightDimension: .estimated(250)) // 250이지만 변경 가능
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        
        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .continuous
        section.contentInsets = .init(top: 10, leading: 5, bottom: 10, trailing: 5)
        section.interGroupSpacing = 10 // group간 간격 10
        
        let sectionHeader = headerSection()
        section.boundarySupplementaryItems = [sectionHeader]
        return section
    }
    
    func newSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1), // group의 넓이와 동일
            heightDimension: .fractionalHeight(0.75)) // group의 높이에 75%
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = .init(top: 10, leading: 5, bottom: 0, trailing: 5)
        
        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(0.5), // section의 넓이에 50%
            heightDimension: .estimated(350)) // 350이지만 변경 가능
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        
        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .continuous
        section.contentInsets = .init(top: 10, leading: 5, bottom: 10, trailing: 5)
        section.interGroupSpacing = 10
        
        let sectionHeader = headerSection()
        section.boundarySupplementaryItems = [sectionHeader]
        return section
    }
    
    func defaultSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1), // group의 넓이와 동일
            heightDimension: .fractionalHeight(0.8)) // group의 넓이에 80%
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = .init(top: 10, leading: 5, bottom: 0, trailing: 5)
        
        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1/3), // section의 넓이에 1/3
            heightDimension: .estimated(200)) // 200이지만 변경 가능
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        
        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .continuous
        section.contentInsets = .init(top: 10, leading: 5, bottom: 10, trailing: 5)
        section.interGroupSpacing = 10
        
        let sectionHeader = headerSection()
        section.boundarySupplementaryItems = [sectionHeader]
        return section
    }
    
    func headerSection() -> NSCollectionLayoutBoundarySupplementaryItem {
        let layoutSectionHeaderSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1),
            heightDimension: .absolute(30)) // 30만큼
        let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: layoutSectionHeaderSize,
            elementKind: UICollectionView.elementKindSectionHeader,
            alignment: .top)
        return sectionHeader
    }
}

해당 코드는 각각의 Section의 Layout에 대해 정의한 코드입니다.
위에서 언급했듯이 CompositionalLayout은 Section, Group, Item으로 구성됩니다.

여기서 Size를 정의하는 방법이 3가지가 있는데
1. .absolute - 고정 크기
2. .estimated - 런타임에 변경
3. .fractional - 비율

각 Cell의 Size에 따라 적절한 Layout을 설정해 줍니다.

  • Data (DiffableDataSource)
final class MainPageViewController: UIViewController {
    private var dataSource: UICollectionViewDiffableDataSource<Section, Item>?
	
    override func viewDidLoad() {
        super.viewDidLoad()
        setDataSource()
        setHeader()
    }
}
private extension MainPageViewController {
    func setDataSource() {
        dataSource = UICollectionViewDiffableDataSource<Section, Item>(
            collectionView: mainPageView.collectionView,
            cellProvider: { collectionView, indexPath, itemIdentifier in
                switch itemIdentifier {
                case .random(let item):
                    guard let cell = collectionView.dequeueReusableCell(
                        withReuseIdentifier: DefaultSectionCell.identifier,
                        for: indexPath) as? DefaultSectionCell else { return UICollectionViewCell() }
                    cell.configure(data: item, nickNameOn: true)
                    return cell
                    
                case .new(let item), .near(let item), .pick(let item):
                    guard let cell = collectionView.dequeueReusableCell(
                        withReuseIdentifier: DefaultSectionCell.identifier,
                        for: indexPath) as? DefaultSectionCell else { return UICollectionViewCell() }
                    cell.configure(data: item, nickNameOn: false)
                    return cell
                    
                case .rank(let item):
                    guard let cell = collectionView.dequeueReusableCell(
                        withReuseIdentifier: RankSectionCell.identifier,
                        for: indexPath) as? RankSectionCell else { return UICollectionViewCell() }
                    cell.configure(data: item, index: indexPath.item)
                    return cell
                }
            })
    }
    
    func applyItems(data: MainItems) {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        
        let randomSection = Section(id: SectionName.random.sectionID)
        let rankSection = Section(id: SectionName.rank.sectionID)
        let newSection = Section(id: SectionName.new.sectionID)
        let nearSection = Section(id: SectionName.near.sectionID)
        let pickSection = Section(id: SectionName.pick.sectionID)
        
        [randomSection, rankSection, newSection, nearSection, pickSection].forEach {
            snapshot.appendSections([$0])
        }
        
        snapshot.appendItems(data.random, toSection: randomSection)
        snapshot.appendItems(data.rank, toSection: rankSection)
        snapshot.appendItems(data.new, toSection: newSection)
        snapshot.appendItems(data.near, toSection: nearSection)
        snapshot.appendItems(data.pick, toSection: pickSection)
        
        dataSource?.apply(snapshot)
    }
}

데이터 적용을 위해 Snapshot을 생성하고 적용해야 합니다.
appendSections으로 Section을 등록하고
appendItems으로 Item을 등록한 후 apply 메서드로 Snapshot을 적용시키면 됩니다!


전체 코드

전체 코드: https://github.com/z-wook/Catcher

참고 자료: https://github.com/z-wook/Shopping


참고 자료

https://gyuios.tistory.com/153
https://ios-development.tistory.com/945
https://velog.io/@ellyheetov/UI-Diffable-Data-Source

profile
🍎 iOS Developer

0개의 댓글