애플사이다의 iOS 개발 일지

[CollectionView] Diffable DataSource 이해하기 (1/3) - Advances in UI Data Sources (WWDC19) 본문

iOS/영문 공식문서 뜯어보기-iOS

[CollectionView] Diffable DataSource 이해하기 (1/3) - Advances in UI Data Sources (WWDC19)

Applecider 2022. 5. 27. 17:15

CollectionView / TableView와 관련해서 "Diffable DataSource" 개념이 등장했다.

러닝커브가 조금 있는 내용이라 포스팅을 남기려고 한다.

 

✏️ 새로운 기술을 습득하기 가장 좋은 방법은 Apple이 만든 WWDC 영상을 보는 것이다.

UIKit 담당 팀에서 직접 기술이 등장한 배경, 활용 방법에 대해 짜임새 있게 소개하기 때문이다.

그다음엔 공식문서의 예제를 따라 하면서 직접 View를 그려보면 된다.

 

Diffable DataSource가 뭔지 간단히 정리하고, 

WWDC 세션 내용을 살펴보자.


Diffable DataSource란?

Diffable = Different + Ability

Diffable은 "달라질 수 있는 능력이 있다"는 뜻이다.

 

CollectionView / TableView를 그리기 위해서는 DataSource가 필요하다.
기존에는 CollectionView DataSource Protocol을 채택하고, Protocol에 구현되어 있던 메서드를 사용하는 방법을 사용했다.

사용자 이벤트를 받아서 View를 업데이트해야 하는 상황을 가정해보자.

기존 방법으로는 데이터를 변경한 뒤에 전체 View를 다시 그리도록 reloadData() 또는 performBatchUpdates()를 호출해야 했다.

 

Diffable DataSource는 새로운 방식의 DataSource이다.

Cell에 어떤 종류의 데이터가 들어갈지만 정해놓고, 데이터를 Snapshot 형태로 사진을 찍어서 View에 반영한다.

데이터가 바뀌면 새로운 Snapshot을 만들어서 반영시키면 된다.

View를 그릴 때 이전의 Snapshot과 새로운 Snapshot 사이에 달라진 부분을 애니메이션으로 자연스럽게 연결시켜준다.

따라서 DataSource가 변경되었을 때 View를 보다 효율적이고 dynamic하게 업데이트할 수 있고, UX 관점에서 유리하다.

Diffable DataSource와 RxSwift

기존 방법에서 RxCocoa를 사용한다면 collectionView.rx.items(cellIdentifier:cellType:) 형태로 데이터를 binding 시킨다.

 

이와 달리, Diffable DataSource는 snapshot을 사용하므로 RxSwift처럼 reactive하게 동작한다.

따라서 위 코드가 필요 없다.

Snapshot을 apply() 하는 부분에만 Rx를 사용하면 된다.

 

개인적으로 Diffable DataSource 자체가 reactive해서 RxSwift와 잘 어울리지 않는 면이 많다고 느꼈다. 

예를 들어 특정 Cell을 탭하는 이벤트가 발생했을 때, 기존에는 collectionView.rx.modelSelected(CustomCellType.self).bind() 형태로 selectedCell을 받아올 수 있어서 매우 유용했는데, Diffable DataSource를 사용하면 이 메서드가 정상 작동하지 않았다.

이 부분은 다다음 포스팅에서 자세히 다뤄보려고 한다.


Advances in UI Data Sources (WWDC19)

기본적인 개념은 이렇다. 링크는 여기에

  • DiffableDataSource에서는 복잡한 performBatchUpdates 메서드 대신 apply 메서드를 사용한다.
  • 이제부터 UI를 업데이트할 때 indexPath 대신 Snapshot을 사용한다. Snapshot은 "현재 UI 상태의 truth"를 뜻한다.
    이때 Snapshot의 Section identifier 및 Item identifier은 모두 unique 해야 한다. (Hashable을 준수해야 한다.)
  • 기존DataSource는 "프로토콜" 타입, DiffableDataSource (UICollectionViewDiffableDataSource)는 "제네릭 클래스" 타입이다.
  • Snapshot (NSDiffableDataSourceSnapshot)은 "제네릭 구조체" 타입이다.

예제 코드

DiffableDataSource를 사용하는 Implementing Modern Collection Views 예제 코드를 보자.

상단의 SearchBar에 사용자가 문자를 입력할 때마다, 입력값을포함하는 검색결과만 필터링하여 CollectionView를 다시 그린다.

검색결과가 바뀔 때마다 애니메이션이 자동 적용된다.

Mountains Search 예제코드

CollectionView를 그리기 위해 DiffableDataSource 공식문서에 나와있는 4단계 STEP을 따른다.

  1. Connect a diffable data source to your collection view.
  2. Implement a cell provider to configure your collection view’s cells.
  3. Generate the current state of the data.
  4. Display the data in the UI.

먼저 DiffableDataSource를 생성하고, ViewController와 연결한다. (STEP 1~2번)

// Modern Collection Views 예제코드 > Diffable 폴더
class MountainsViewController: UIViewController {
    var mountainsCollectionView: UICollectionView!
    var dataSource: UICollectionViewDiffableDataSource<Section, MountainsController.Mountain>!
    
    func configureDataSource() {
        // cell register
        let cellRegistration = UICollectionView.CellRegistration
        <LabelCell, MountainsController.Mountain> { (cell, indexPath, mountain) in
            // Populate the cell with our item description.
            cell.label.text = mountain.name
        }
        
        // ✅ DiffableDataSource 생성
        dataSource = UICollectionViewDiffableDataSource<Section, MountainsController.Mountain>(collectionView: mountainsCollectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, identifier: MountainsController.Mountain) -> UICollectionViewCell? in
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
        }
    }
}

// ✅ Section 타입 (SectionIdentifier)
enum Section: CaseIterable {
    case main
}

// ✅ Item 타입 (ItemIdentifier)
struct Mountain: Hashable {
    let name: String
    let height: Int
    let identifier = UUID()  // Item identifier는 unique해야 하므로
}
  • Cell을 등록하고, Cell 등록정보 (cellRegistration)를 활용해서 DiffableDataSource를 생성한다.
  • DiffiableDataSource의 이니셜라이저를 보자. 제네릭 클래스이므로 <SectionIdentifier, ItemIdentifier> 형태로 초기화하고, 매개변수로 collectionView, cellProvider를 전달한다.
    이때 cellProvider는 클로저 타입의 typealias인데, 대략 (collectionView, indexPath, itemIdentifier) 형태이다.
  • SectionIdentifier로 enum타입을 사용한 이유는 뭘까?
    연관값이 없는 enum은 항상 Hashable하기 때문이다. (모든 연관값이 Hashable하면 enum도 Hashable하다.)
  • ItemIdentifier 내부에 UUID 타입의 identifier 프로토콜을 만든 이유는 뭘까?
    마찬가지로 Hashable해야 하기 때문이다. UUID는 임의의 고유 값이다.

그다음, 전체 데이터를 전달해서 초기 Snapshot을 만들고 DiffableDataSource에 적용한다. (STEP 3~4번)

이 예제에서는 SearchBar 입력값을 받을 때마다 Snapshot을 다시 만들고 화면을 업데이트한다. 

func performQuery(with filter: String?) {
    // SearchBar 입력값을 포함하는 item만 필터링
    let mountains = mountainsController.filteredMountains(with: filter).sorted { $0.name < $1.name }

    // ✅ 새로운 snapshot 생성
    var snapshot = NSDiffableDataSourceSnapshot<Section, MountainsController.Mountain>() 
    snapshot.appendSections([.main])
    snapshot.appendItems(mountains)  // 필터링된 item만 snapshot에 반영시킴  
    
    // ✅ "DiffiableDataSource야, 업데이트한 snapshot을 apply해서 View를 다시 그려줘"
    dataSource.apply(snapshot, animatingDifferences: true) 
}
  • performQuery 메서드는 1) 화면을 띄울 때 (viewDidLoad), 2) SearchBar 입력값이 바뀔 때 (textDidChange) 호출된다.
  • Snapshot의 이니셜라이저를 보자. DiffableDataSource와 비슷하다.
    제네릭 구조체이므로 <SectionIdentifierType, ItemIdentifierType> 형태로 초기화한다.
  • appendSections(), appendItems()를 통해 각 Section마다 원하는 Item 데이터를 넣어준다.
    *이때 section이 여러 개라면?
    appendSections([.section1]) - appendItems(items1) - appendSections([.section2]) - appendItems(items2) 순으로 호출해야 한다. 
  • Snapshot을 만든 뒤에 apply 메서드를 호출하면 된다.

위 예제는 Snapshot을 생성할 때 empty snapshot을 만들고 section/item을 append하고 있다.

다른 방법으로, 현재 View의 snapshot을 그대로 가져오고 싶다면 이렇게 하면 된다.

let snapshot = dataSource.snapshot()

그리고 예제와 달리, 기존 item을 삭제하고, 새로운 item을 반영하고 싶다면 이렇게 하면 된다.

private func deleteAndApplySnapshot(newListProducts: [UniqueProduct]) {
    let previousProducts = snapshot.itemIdentifiers(inSection: .list)
    snapshot.deleteItems(previousProducts)  // 기존 item 삭제
    // snapshot.deleteAllItems()  // 이것도 가능
    snapshot.appendItems(listProducts, toSection: .list)

    dataSource.apply(snapshot, animatingDifferences: true)
}

Diffable DataSource의 장점

기존 DataSource를 쓰고 reloadData 메서드를 호출해도 되는데 뭐가 다를까?

reloadData를 쓰면 애니메이션이 끊겨서 UX에 안 좋다.

반면 DiffiableDataSource는 View를 다시 그릴 때 Snapshot의 변화를 스스로 파악하고, 그걸 똑똑하게 애니메이션으로 나타내준다!

(애니메이션이 싫다면 apply 메서드의 animatingDifferences에 false를 할당하면 된다.)

 

그리고 synchronization 버그, crash 발생을 예방하므로 안전하다.

기존 방법에서 자주 발생하는 synchronization 버그...

이외에도 Sectional Snapshot이라는 보완 기술을 소개한 WWDC 세션도 있는데, 나중에 다룰 예정이다.

 

다음 포스팅에서는 Implementing Modern Collection Views 예제 코드를 자세히 살펴보자.

웬일로 예제 코드가 다양하게 잘 나와있어서 이해하기 수월했다.

 

- Reference

 

🍎 포스트가 도움이 되었다면, 공감🤍 / 구독🍹 / 공유🔗 / 댓글✏️ 로 응원해주세요. 감사합니다.

 

Comments