본문 바로가기

iOS/iOS (기본)

[iOS] iOS 14이상에서 UICollectionView 사용하기

 

iOS 14이상부터 UICollectionView를 구현하기 위해서 다양한 API를 제공하는데 해당 API들을 사용하면 장점들을 활용할 수 있습니다.

iOS 14이전방식으로 예시화면을 UICollectionView구현한 코드를 살펴보고 새로운 API들을 하나씩 적용하여 변경하면서 살펴보겠습니다.

👨🏻‍🏫 예제코드 설명


  1. 해당 예제코드를 살펴보기전에 레이아웃은 SnapKit 라이브러리를 활용하였고, Then 라이브러리를 활용하여 UIComponent을 구성하였습니다.
  2. 파일명은 iOS14이전으로 구현한 코드들은 앞에 Previous를 붙여주고 iOS 14이상방식으로 구현한 코드들은 New로 명명합니다.
  3. 아래 이미지를 참고하여 UICollectionView를 구현할 예정입니다.

 

 

 

iOS 14이상에서 UICollectionView 사용하기

iOS 14이상에서 UICollectionView 사용하기. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

 

🆓 iOS 14 이전방식으로 UICollectionView구현


 

1. CustomView 생성 및 등록


이미지를 참고하면 Section별로 Header가 존재하고 각각의 CustomCollectionViewCell이 존재하기 때문에 해당 CustomView들을 생성합니다.

  • CollectionView에서 Section별 HeaderView를 구현하기 위해서는 UICollectionReusableView를 상속받는 CustomView를 생성해야합니다.
  • CollectionView에서 CustomCell은 UICollectionViewCell을 상속받습니다.

 

PreviousCollectionViewCell.swift

더보기
import UIKit
import SnapKit
import Then

final class PreviousCollectionViewCell: UICollectionViewCell {

    static let reuseID = String(describing: PreviousCollectionViewCell.self)

    lazy var textLabel = UILabel().then {
        $0.font = .systemFont(ofSize: 14, weight: .bold)
        $0.textColor = .systemBrown
    }

    override init(frame: CGRect) {
        super.init(frame: frame)

        setupHierarchy()
        setupLayout()
        setupProperties()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupHierarchy() {
        self.contentView.addSubview(self.textLabel)
    }

    private func setupLayout() {
        self.textLabel.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
    }

    private func setupProperties() {
        self.backgroundColor = .systemGray5
    }

}

 

PreviousCollectionReusableView.swift

더보기
import UIKit
import SnapKit
import Then

final class PreviousCollectionReusableView: UICollectionViewCell {

    static let reuseID = String(describing: PreviousCollectionReusableView.self)

    lazy var textLabel = UILabel().then {
        $0.font = .systemFont(ofSize: 14, weight: .bold)
        $0.textColor = .systemBlue
    }

    override init(frame: CGRect) {
        super.init(frame: frame)

        setupHierarchy()
        setupLayout()
        setupProperties()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setupHierarchy() {
        self.contentView.addSubview(self.textLabel)
    }

    private func setupLayout() {
        self.textLabel.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
    }

    private func setupProperties() {
        self.backgroundColor = .systemGray3
    }

}

2가지 CustomView 모두 텍스트를 가지는 UILabel과 배경색만 지정해주었습니다.

생성을 완료하면 UICollectionView의 register메서드를 이용해 각각의 CustomView를 등록합니다.

 

 

PreviousCollectionViewController.swift

더보기
/// iOS 14 이전 방식 기본적인 구현방법 사용한 CollectionView 예제코드
final class PreviousCollectionViewController: UIViewController {

    lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()).then {
        $0.dataSource = self
        $0.register(PreviousCollectionViewCell.self, forCellWithReuseIdentifier: PreviousCollectionViewCell.reuseID)
        $0.register(PreviousCollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: PreviousCollectionReusableView.reuseID)
    }
    
    ...

 

 

2. UICollectionView의 Layout 설정


UICollectionView 내부의 Section Header와 Item들의 사이즈, 간격등을 정의하기 위해 Layout을 설정합니다.

 

PreviousCollectionViewController.swift

더보기
/// iOS 14 이전 방식 기본적인 구현방법 사용한 CollectionView 예제코드
final class PreviousCollectionViewController: UIViewController {

    lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()).then {
        $0.dataSource = self
        $0.register(PreviousCollectionViewCell.self, forCellWithReuseIdentifier: PreviousCollectionViewCell.reuseID)
        $0.register(PreviousCollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: PreviousCollectionReusableView.reuseID)
    }
    
    ...
    
private func createLayout() -> UICollectionViewFlowLayout {
    let flowLayout = UICollectionViewFlowLayout()
    flowLayout.itemSize = CGSize(width: 60, height: 60)
    flowLayout.minimumInteritemSpacing = 4
    flowLayout.minimumLineSpacing = 30
    flowLayout.headerReferenceSize = CGSize(width: UIScreen.main.bounds.width, height: 100)

    return flowLayout
}

...

 

3. Item에 들어갈 Model 작성


텍스트로 제공할 값은 이름으로만 구성되어있고 static변수를 사용하여 미리 데이터를 정의합니다.

 

PreviousSampleModel.swift

더보기
import Foundation

struct PreviousSampleModel {
    var name: String
}

extension PreviousSampleModel {

    static var allDatas = [
        PreviousSampleModel(name: "김민준"),
        PreviousSampleModel(name: "이서연"),
        PreviousSampleModel(name: "박지호"),
        PreviousSampleModel(name: "최하은"),
        PreviousSampleModel(name: "정다현"),
        PreviousSampleModel(name: "윤도윤"),
        PreviousSampleModel(name: "장서아"),
        PreviousSampleModel(name: "한지우"),
        PreviousSampleModel(name: "송하린"),
        PreviousSampleModel(name: "권민재")
    ]

}

 

4. UICollectionDataSource 연결 및 UICollectionView 지정


UICollectionView를 UIViewController의 SuperView에 추가한 후 레이아웃을 정의합니다.

UICollectionViewDataSource에 표시할 셀과 섹션 수를 지정합니다. (numberOfItemsInSection, numberOfSections)

셀의 구성과 헤더뷰의 구성을 정의합니다. (cellForItemAt, viewForSupplementaryElementOfKind)

 

PreviousCollectionViewController.swift

더보기
/// iOS 14 이전 방식 기본적인 구현방법 사용한 CollectionView 예제코드
final class PreviousCollectionViewController: UIViewController {

    lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()).then {
        $0.dataSource = self
        $0.register(PreviousCollectionViewCell.self, forCellWithReuseIdentifier: PreviousCollectionViewCell.reuseID)
        $0.register(PreviousCollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: PreviousCollectionReusableView.reuseID)
    }

    private var datas: [PreviousSampleModel] = PreviousSampleModel.allDatas

    override func viewDidLoad() {
        super.viewDidLoad()

        setupHierarchy()
        setupLayout()
    }

    private func setupHierarchy() {
        self.view.addSubview(self.collectionView)
    }

    private func setupLayout() {
        self.collectionView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
    }

    private func createLayout() -> UICollectionViewFlowLayout {
        let flowLayout = UICollectionViewFlowLayout()
        flowLayout.itemSize = CGSize(width: 60, height: 60)
        flowLayout.minimumInteritemSpacing = 4
        flowLayout.minimumLineSpacing = 30
        flowLayout.headerReferenceSize = CGSize(width: UIScreen.main.bounds.width, height: 100)

        return flowLayout
    }

}

// MARK: - UICollectionViewDataSource
extension PreviousCollectionViewController: UICollectionViewDataSource {

    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 2
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return datas.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PreviousCollectionViewCell.reuseID, for: indexPath) as! PreviousCollectionViewCell

        let item = datas[indexPath.item]
        
        cell.textLabel.text = "\(item.name)"

        return cell
    }

    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {

        if kind == UICollectionView.elementKindSectionHeader {
            let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: PreviousCollectionReusableView.reuseID, for: indexPath) as! PreviousCollectionReusableView

            headerView.textLabel.text = "\(indexPath.section)번째 Section"

            return headerView
        }

        return UICollectionReusableView()
    }

}

 

🆕 iOS 14 이후방식으로 변경


1. UICompositional Layout으로 Layout 작성


iOS13에서 UICollectionViewCompositionalLayout이라는 레이아웃 객체가 추가되었습니다.

기존의 UICollectionViewFlowLayout에서 구현할 수 없는 다양한 레이아웃 설정이 가능하게 되었습니다.

가장 큰 특징은 하나 이상의 Section으로 구성되고 각각의 Item을 담고 있는 Group으로 구성되어 있습니다.

 

 

NewCollectionViewController.swiftPreviousCollectionViewController.swift의 createLayout메서드 변경

더보기
private func createLayout() -> UICollectionViewLayout {
    let compositionalLayout = UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in
        let columnCount = CGFloat(6)

        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1 / columnCount), heightDimension: .absolute(60))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        item.contentInsets = .init(top: 0, leading: 0, bottom: 0, trailing: 0)

        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(60))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        group.interItemSpacing = .fixed(4)

        let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(100))
        let headerView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
        let section = NSCollectionLayoutSection(group: group)

        section.boundarySupplementaryItems = [headerView]
        section.interGroupSpacing = 30

        return section
    }

    return compositionalLayout
}
  • 기존의 UICollectionFlowLayout생성하는 부분을 UICollectionCompositionalLayout으로 변경합니다. 화면 기준으로 6분할 하기 때문에 각각의 item을 전체너비의 6분할을 하였습니다.
  • 기본적으로 item과 group모두 NSCollectionLayoutSize을 이용해서 사이즈를 설정합니다. 사이즈를 설정하는 옵션은 3가지가 있습니다.
    • absolute: 고정 크기
    • fractional: 현재 속한 컨테이너에서 차지하는 비율
    • estimated: 크기가 변경될 가능성이 있는 경우 예상 크기
  • group을 생성할 때 item을 어떤 방향으로 배치하는 group인지 생성할 때 정의합니다.
    • horizontal : 수평 그룹
    • vertical : 수직 그룹
    • custom : 커스텀 그룹
  • 기존의 headerView는 NSCollectionLayoutBoundarySupplementaryItem 객체를 생성하여 section의 boundarySupplementaryItems배열에 추가합니다.

 

2. UICollectionViewDataSource를 UICollectionViewDiffableDataSource로 변경


iOS 13 이전, UICollectionViewDataSource프로토콜을 채택하여 컬렉션뷰에 표시할 셀, 표시할 셀 수, 셀을 표시할 섹션등을 알려주었습니다.

iOS 13 이후, UICollectionViewDiffableDataSource를 도입되었는데 해당 객체는 이전에 표시된 데이터와의 차이를 자동으로 계산해줍니다.

 

UICollectionViewDiffableDataSource를 사용했을 때 장점 3가지

  1. 데이터를 추가, 업데이트, 삭제할때마다 자동으로 데이터가 변경되는 애니메이션을 얻을 수 있습니다.
  2. 컬렉션뷰와 데이터소스간의 데이터변경사항을 수동으로 관리하고 동기화하지 않아도 됩니다. (기본 데이터소스를 사용하여 동기화작업 중 하나라도 정렬이 잘못되면 오류가 발생합니다.)
  3. 전반적으로 코드가 감소하여 가독성이 올라갑니다.

구조를 살펴보면 제네릭클래스이고 Section과 Item으로 두가지 타입이 있는데 각각의 타입은 Hashable, Sendable을 준수하는 타입이여야 합니다.

 

NewSampleModel.swift 

더보기
class NewSampleModel: Hashable {

    var id = UUID()
    var name: String

    init(name: String) {
        self.name = name
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func == (lhs: NewSampleModel, rhs: NewSampleModel) -> Bool {
        lhs.id == rhs.id
    }

}

extension NewSampleModel {

    static var allDatas = [
        NewSampleModel(name: "김민준"),
        NewSampleModel(name: "이서연"),
        NewSampleModel(name: "박지호"),
        NewSampleModel(name: "최하은"),
        NewSampleModel(name: "정다현"),
        NewSampleModel(name: "윤도윤"),
        NewSampleModel(name: "장서아"),
        NewSampleModel(name: "한지우"),
        NewSampleModel(name: "송하린"),
        NewSampleModel(name: "권민재")
    ]

    static var subDatas = [
        NewSampleModel(name: "김민준"),
        NewSampleModel(name: "이서연"),
        NewSampleModel(name: "박지호"),
        NewSampleModel(name: "최하은"),
        NewSampleModel(name: "정다현"),
        NewSampleModel(name: "윤도윤"),
        NewSampleModel(name: "장서아"),
        NewSampleModel(name: "한지우"),
        NewSampleModel(name: "송하린"),
        NewSampleModel(name: "권민재")
    ]
}
  • DiffableDataSource의 Item은 Hasable을 준수해야 하기 때문에 해당프로토콜을 준수합니다.

 

NewCollectionViewController.swift

더보기
final class NewCollectionViewController: UIViewController {
...

    enum Section {
        case first
        case second
    }

    typealias DataSource = UICollectionViewDiffableDataSource<Section, NewSampleModel>
    typealias SnapShot = NSDiffableDataSourceSnapshot<Section, NewSampleModel>
    
    private var dataSource: DataSource!
    
    
    private func setupProperties() {
        self.dataSource = makeDatasource()
        self.applySnapshot(animated: true)
    }
    
    private func makeDatasource() -> DataSource {
        let dataSource = DataSource(collectionView: self.collectionView) { collectionView, indexPath, item in
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NewCollectionViewCell.reuseID, for: indexPath) as! NewCollectionViewCell

            cell.textLabel.text = "\(item.name)"

            return cell
        }

        dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
            if kind == UICollectionView.elementKindSectionHeader {
                let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: NewCollectionReusableView.reuseID,
                    for: indexPath) as? NewCollectionReusableView
                view?.textLabel.text = "\(indexPath.section)번째 Section"

                return view
            }

            return UICollectionReusableView()
        }

        return dataSource
    }

    private func applySnapshot(animated: Bool) {
        var snapShot = SnapShot()
        snapShot.appendSections([.first, .second])
        snapShot.appendItems(NewSampleModel.allDatas, toSection: .first)
        snapShot.appendItems(NewSampleModel.subDatas, toSection: .second)

        dataSource.apply(snapShot, animatingDifferences: animated)
    }
    
    ...
  • DiffableDataSource의 Section으로 enum을 추가합니다. enum은 기본적으로 Hashable을 준수합니다.makeDatasource메서드를 작성하여 DataSource를 생성한 후 cell을 구성합니다.applySnapshot메서드를 사용하여 표시할 섹션 수와 아이템수를 제공하기 위해 Datasource가 참조하는 섹션과 아이템들을 저장합니다.
  • 모두 완료되면 기존의 UICollectionViewDataSource 프로토콜을 제거합니다.
  • 생성한 DataSource에 supplementaryViewProvider프로퍼티를 통해 커스텀헤더뷰를 구성합니다.
  • 타입별칭을 활용하면 타입을 간략하게 축약하여 사용할 수 있습니다.

 

3. CellRegistration, SupplementaryRegistration 활용하여 미리 구성


iOS14부터 UICollectionView.CellRegistrationUICollectionView.SupplementaryRegistration 을 사용하면 미리 UICollectionView에 등록하지 않아도 되고 미리 구성할 수 있는 장점이 있습니다.

 

NewCollectionViewController.swift

더보기
private func makeDatasource() -> DataSource {
    let cellRegistration = UICollectionView.CellRegistration<NewCollectionViewCell, NewSampleModel> { (cell, indexPath, item) in
        cell.textLabel.text = "\(item.name)"
    }

    let dataSource = DataSource(collectionView: self.collectionView) { collectionView, indexPath, item in
        return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
    }

    let headerRegistration = UICollectionView.SupplementaryRegistration<NewCollectionReusableView>(elementKind: UICollectionView.elementKindSectionHeader) { (header, elementKind, indexPath) in
        if elementKind == UICollectionView.elementKindSectionHeader {
            header.textLabel.text = "\(indexPath.section)번째 Section"
        }
    }

    dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
        return collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath)
    }

    return dataSource
}
  • 기존의 CustomView들의 reuseID와 collectionView의 then클로저의 register를 제거할 수 있습니다.

 

4. UIContentConfiguration을 활용


UITableViewCell, UICollectionViewCell을 사용할 때 커스텀셀을 사용하지 않고 기본Cell을 사용할 때, iOS14이전에는 간단하게 기본Cell의 textLabel, detailTextLabel, imageView를 변경하여 설정할 수 있었지만 iOS14이후에는 defaultContentConfiguration을 통해 설정하도록 변경되었습니다.

간단한 UICollectionViewCell 또는 UICollectionViewReusableView를 사용할 때 UICollectionViewListCell을 이용할 수 있고 UIContentConfiguration을 통해 구성할 수 있습니다.

 

NewCollectionViewController.swift

더보기
...

private func makeDatasource() -> DataSource {
    let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, NewSampleModel> { (cell, indexPath, item) in
        var configuration = cell.defaultContentConfiguration()
        configuration.text = "\(item.name)"
        configuration.textProperties.font = .systemFont(ofSize: 14, weight: .bold)
        configuration.textProperties.color = UIColor.systemBrown

        var backgroundConfiguration = cell.defaultBackgroundConfiguration()
        backgroundConfiguration.backgroundColor = .systemGray5

        cell.contentConfiguration = configuration
        cell.backgroundConfiguration = backgroundConfiguration
    }

    let dataSource = DataSource(collectionView: self.collectionView) { collectionView, indexPath, item in
        return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
    }

    let headerRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { (header, elementKind, indexPath) in
        if elementKind == UICollectionView.elementKindSectionHeader {
            var configuration = header.defaultContentConfiguration()
            configuration.text = "\(indexPath.section)번째 Section"
            configuration.textProperties.font = .systemFont(ofSize: 14, weight: .bold)
            configuration.textProperties.color = UIColor.systemBlue
            configuration.textProperties.alignment = .center

            var backgroundConfiguration = header.defaultBackgroundConfiguration()
            backgroundConfiguration.backgroundColor = .systemGray3

            header.contentConfiguration = configuration
            header.backgroundConfiguration = backgroundConfiguration
        }
    }

    dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
        return collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath)
    }

    return dataSource
}
  • 기존의 CustomCell, CustomReusableView를 UICollectionViewListCell로 변경합니다. 기존 커스텀뷰 파일들은 제거합니다.
  • defaultConfiguration을 통해 UIListConfiguration을 통해 각각의 커스텀뷰를 구성합니다. 그 후 다시 ContentConfiguration과 BackgroundConfiguration에 구성들을 저장합니다.

'iOS > iOS (기본)' 카테고리의 다른 글

[iOS] Preview (실시간 미리보기)  (43) 2024.04.01