Tutorial로 알아보는 collection view (feat: Compositional Layout, Diffable Data Source)

Tutorial로 알아보는 collection view (feat: Compositional Layout, Diffable Data Source)
Photo by Clem Onojeghuo / Unsplash
Creating a list view | Apple Developer Documentation
In this tutorial, you’ll create your app’s root view, a collection view with a list layout that displays the user’s daily reminders.

Apple의 Creating a list view tutorial문서를 보며, list view를 구현해보자

먼저 iOS-App template을 이용해 프로젝트를 생성한다.

Add a collection view controller

CollectionView는 grid, columns, row, table등 다양한 형태의 cell을 표시할 수 있다.

인터페이스 빌더를 사용해 Collection View Controller를 만들어보자.

CollectionView에서 Cell template을 삭제하고,

Storyboard가 아닌 코드로 Collection view cell을 정의해보자

인터페이스빌더에서 만든 Collection View Controller Scene을 Initial View Controller로 지정해주자

Create a reminder model

Apple-Cocoa-MVC

Cocoa-MVC 에서  ViewController는 view와 model 간의 일종의 다리 역할을 한다.

View는 데이터의 시각적인 표현을 제공한다.

각 ViewController는 view 계층을 관리하고, view의 콘텐츠를 업데이트 하고, 사용자 인터페이스의 이벤트에 응답하는 일을 담당한다.

Model은 앱의 데이터와 비즈니스 로직을 관리한다.

결국 여러 패턴은 이 비즈니스 로직을 어디서 어떻게 관리하는가?에 대한 고민의 결과인 것 같다.

Model이 뷰를 직접 수정하지 않고, View가 모델에 직접 영향을 미치지 않도록 하게 하기 위해 ViewController를 이용하도록 하자

먼저 Model 객체들을 구분하기 위해 Models 그룹을 만들자

미리 알림을 나타내는 데이터 모델을 Reminder를 만들어보자

struct Reminder {
    var title: String
    var dueDate: Date
    var notes: String? = nil
    var isComplete: Bool = false
}

BuildConfiguration을 이용해 Dummy data를 만들어보자

tutorial에서 이런식으로 자연스럽게 플래그 변수를 사용해볼 수 있도록 가이드 하는 것 같다.
import Foundation

struct Reminder {
    var title: String
    var dueDate: Date
    var notes: String? = nil
    var isComplete: Bool = false
}

#if DEBUG
extension Reminder {
    static var sampleData = [
        Reminder(
            title: "Submit reimbursement report", dueDate: Date().addingTimeInterval(800.0),
            notes: "Don't forget about taxi receipts"
        ),
        Reminder(
            title: "Code review", dueDate: Date().addingTimeInterval(14000.0),
            notes: "Check tech specs in shared folder", isComplete: true
        ),
        Reminder(
            title: "Pick up new contacts", dueDate: Date().addingTimeInterval(24000.0),
            notes: "Optometrist closes at 6:00PM"
        ),
        Reminder(
            title: "Add notes to retrospective", dueDate: Date().addingTimeInterval(3200.0),
            notes: "Collaborate with project manager", isComplete: true
        ),
        Reminder(
            title: "Interview new project manager candidate",
            dueDate: Date().addingTimeInterval(60000.0), notes: "Review portfolio"
        ),
        Reminder(
            title: "Mock up onboarding experience", dueDate: Date().addingTimeInterval(72000.0),
            notes: "Think different"
        ),
        Reminder(
            title: "Review usage analytics", dueDate: Date().addingTimeInterval(83000.0),
            notes: "Discuss trends with management"
        ),
        Reminder(
            title: "Confirm group reservation", dueDate: Date().addingTimeInterval(92500.0),
            notes: "Ask about space heaters"
        ),
        Reminder(
            title: "Add beta testers to TestFlight", dueDate: Date().addingTimeInterval(101000.0),
            notes: "v0.9 out on Friday"
        )
    ]
}
#endif

Configure the collection as a list

Compositional Layout을 사용해 Collection view layout을 만들어보자

Compostional Layout을 이용하여 다양한 구성요소를 결합해 view를 구성할 수 있다.

Section은 item의 gruop을 둘러싸는 Outer Container view를 말한다.

인터페이스 빌더에서 만들어놓은 CollectionViewController를 연결하자

import UIKit

final class ReminderListViewController: UICollectionViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    private func listLayout() -> UICollectionViewCompositionalLayout {
	var listConfiguration = UICollectionLayoutListConfiguration(appearance: .grouped)
        listConfiguration.showsSeparators = false
        listConfiguration.backgroundColor = .clear
        return UICollectionViewCompositionalLayout.list(using: listConfiguration)
    }
}

우리가 만들고 있는 "Today" app은 미리알림을 목록으로 표시한다.

미리 정의된 configuration을 시작점으로 사용해 list가 표시되는 방식을 정의해보자

ViewController가 View계층 구조를 메모리에 로드한 후 시스템은 viewDidLoad() 를 호출한다.

이 시점에 생성한 list layout을 collection view의 레이아웃으로 할당해주자

import UIKit

final class ReminderListViewController: UICollectionViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let listLayout = self.listLayout()
        self.collectionView.collectionViewLayout = listLayout
        
    }

    private func listLayout() -> UICollectionViewCompositionalLayout {
        var listConfiguration = UICollectionLayoutListConfiguration(appearance: .grouped)
        listConfiguration.showsSeparators = false
        listConfiguration.backgroundColor = .clear
        return UICollectionViewCompositionalLayout.list(using: listConfiguration)
    }
}

Configure the data source

Compositional layout을 사용해 list section을 만들었으니, Collection View에 cell을 등록하고, content configuration을 사용해 cell의 모양을 정의하고, cell을 data source에 연결해보자

*개인적인 생각입니다.
우리는 지금 UIKit framework를 사용해 프로그래밍을 하고 있다.
이 말인즉슨 프레임워크가  제어권을 가지고, 나의 코드를 호출해 앱의 동작을 제어한다.

따라서 collection view를 내 의도대로 표시하기 위해 필요한 정보를 작성해 이를 프레임워크가 정상적으로 호출할 수 있도록 해야한다.

data가 변경될 때

사용자 인터페이스를 업데이트하고, 애니메이션을 적용해주는 collection view의 diffable data source(변경가능한 데이터 소스)를 활용해보자

import UIKit

final class ReminderListViewController: UICollectionViewController {
    typealias DataSource = UICollectionViewDiffableDataSource<Int, String>
    
    var dataSource: DataSource?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let listLayout = self.listLayout()
        self.collectionView.collectionViewLayout = listLayout
        let cellRegistration = UICollectionView.CellRegistration {
            (cell: UICollectionViewListCell, indexPath: IndexPath, itemIdentifier: String) in
            let reminder = Reminder.sampleData[indexPath.item]
            var contentConfiguration = cell.defaultContentConfiguration()
            contentConfiguration.text = reminder.title
            cell.contentConfiguration = contentConfiguration
        }
        
        self.dataSource = DataSource(collectionView: self.collectionView) { collectionView, indexPath, itemIdentifier in
            return collectionView.dequeueConfiguredReusableCell(
                using: cellRegistration,
                for: indexPath,
                item: itemIdentifier
            )
        }
    }
    
    private func listLayout() -> UICollectionViewCompositionalLayout {
        var listConfiguration = UICollectionLayoutListConfiguration(appearance: .grouped)
        listConfiguration.showsSeparators = false
        listConfiguration.backgroundColor = .clear
        return UICollectionViewCompositionalLayout.list(using: listConfiguration)
    }
}

위 코드를 하나하나 씩 작성해보며 살펴보자

Cell registration은 cell의 contents 및 모양을 configure하는 방법을 지정한다.

let cellRegistration = UICollectionView.CellRegistration {
            (cell: UICollectionViewListCell, indexPath: IndexPath, itemIdentifier: String) in
            let reminder = Reminder.sampleData[indexPath.item]
        }

해당 item에 해당하는  reminder model을 검색한다.

참고)
앞서 작성한 reminder model
struct Reminder {
    var title: String
    var dueDate: Date
    var notes: String? = nil
    var isComplete: Bool = false
}
let cellRegistration = UICollectionView.CellRegistration {
            (cell: UICollectionViewListCell, indexPath: IndexPath, itemIdentifier: String) in
            let reminder = Reminder.sampleData[indexPath.item]
            var contentConfiguration = cell.defaultContentConfiguration()
        }

 UICollectionViewListCell의 defaultContentConfiguration() 은 미리 정의된 시스템 스타일로 content configuration을 생성한다.

Reminder Model의 title을 content configuration의 text로 지정한다.

let cellRegistration = UICollectionView.CellRegistration {
            (cell: UICollectionViewListCell, indexPath: IndexPath, itemIdentifier: String) in
            let reminder = Reminder.sampleData[indexPath.item]
            var contentConfiguration = cell.defaultContentConfiguration()
            contentConfiguration.text = reminder.title
            cell.contentConfiguration = contentConfiguration
}

list에는 cell의 primary text로 configuration text가 표시된다.

cell의 content와 appeaerance를 설정했으므로,

이제 셀을 diffable data source에 연결해보자

type alias를 지정해 기존 타입을 조금 더 직관적으로 참조해보도록 하자

typealias DataSource = UICollectionViewDiffableDataSource<Int, String>

다음으로, collection view를diffable data source를 initializer에 전달해, diffable data datasource를 collection view에 연결하자

	self.dataSource = DataSource(collectionView: collectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, itemIdentifier: String) in
            return collectionView.dequeueConfiguredReusableCell(
                using: cellRegistration, for: indexPath, item: itemIdentifier)
        }
모든 item에 대해 새 cell을 만들 수 있지만 cell을 재사용하도록 하면, item 수가 많더라도 앱의 performance를 향상시킬 수 있다.

data source를 만들고 초기화했으니 아직 data가 변경되면 data source에 알리도록 구현하지는 않았다.

data가 변경되면 data source에 알리도록 해보자

Apply a snapshot

Diffable data source는 snapshot을 이용해 data의 state를 관리한다.

snapshot은 특정 시점의 data의 state를 나타낸다.

snapshot을 이용해 data를 나타내려면 snapshot을 만들고, 나타내려는 data의state로 snapshot을 채운다음, 사용자 인터페이스에 snapshot을 적용하면 된다.

위에서 만든 list view에 sample reminder  list를 나타내는 snapshot을 만들고, 적용하는 방법을 살펴보자

먼저 조금 더 직관적으로 사용하기 위해 diffable datasource snapshot의 typealias를 지정해주자

typealias Snapshot = NSDiffableDataSourceSnapshot<Int, String>

비어있는 snapshot을 만든 다음 section과 item을 추가해보자

var snapshot = Snapshot()
snapshot.appendSections([0])

snapshot에 single section을 추가하였다.

reminder의 title만 포함된 새 배열을 만들고 title을 snapshot의 item으로 추가해보자

var snapshot = Snapshot()
snapshot.appendSections([0])
snapshot.appendITems(Reminder.sampleData.map { $0.title })
self.dataSource?.apply(snapshot)

data source에 snapshot을 적용해보자

snapshot을 적용하면 사용자 인터페이스에 변경 사항이 반영된다.

이제 snapshot이 적용된 data soruce를 collection view의 data source에 할당해주자

var snapshot = Snapshot()
snapshot.appendSections([0])
snapshot.appendITems(Reminder.sampleData.map { $0.title })
self.dataSource?.apply(snapshot)

self.collectionView.dataSource = self.dataSource

지금까지 ReminderListViewController에 작성된 코드는 다음과 같다.

import UIKit

final class ReminderListViewController: UICollectionViewController {
    typealias DataSource = UICollectionViewDiffableDataSource<Int, String>
    typealias Snapshot = NSDiffableDataSourceSnapshot<Int, String>
    
    var dataSource: DataSource?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let listLayout = self.listLayout()
        self.collectionView.collectionViewLayout = listLayout
        let cellRegistration = UICollectionView.CellRegistration {
            (cell: UICollectionViewListCell, indexPath: IndexPath, itemIdentifier: String) in
            let reminder = Reminder.sampleData[indexPath.item]
            var contentConfiguration = cell.defaultContentConfiguration()
            contentConfiguration.text = reminder.title
            cell.contentConfiguration = contentConfiguration
        }
        
        self.dataSource = DataSource(collectionView: self.collectionView) { collectionView, indexPath, itemIdentifier in
            return collectionView.dequeueConfiguredReusableCell(
                using: cellRegistration,
                for: indexPath,
                item: itemIdentifier
            )
        }
        
        var snapshot = Snapshot()
        snapshot.appendSections([0])
        snapshot.appendItems(Reminder.sampleData.map { $0.title })
        self.dataSource?.apply(snapshot)
        
        self.collectionView.dataSource = self.dataSource
    }
    
    private func listLayout() -> UICollectionViewCompositionalLayout {
        var listConfiguration = UICollectionLayoutListConfiguration(appearance: .grouped)
        listConfiguration.showsSeparators = false
        listConfiguration.backgroundColor = .clear
        return UICollectionViewCompositionalLayout.list(using: listConfiguration)
    }
}

이제 앱을 빌드하고 실행해보자

마무리

여기까지 간단한 compositional layout과 diffable data source를 이용해 list를 표시해보았습니다.

이를 응용한다면 여러 레이아웃을 빠르게 구성해볼 수 있을 것 같습니다.

"UIKit이라는 프레임 워크를 사용한다"를 의식적으로 생각하면서 코드를 작성하니, 무엇이 무엇을 호출하는지 아는 것이 중요하다는 생각을 하게 되었습니다.

프레임워크를 사용할 때는 내 코드를 호출해 정상적으로 동작하기 위해 필요한 정보들이 무엇인지, 어느 시점에 불리는지 정확히 분석하고 코드를 작성하는 습관을 들여야겠습니다.

아직 모르는 것이 많고 알아가는 과정입니다. 잘못된 것이 있다면 댓글로 남겨주신다면 감사하겠습니다! 😊