Advances in UI Data Sources

Advances in UI Data Sources
Photo by Patrick Lindenberg / Unsplash

들어가며

Advances in UI Data Sources WWDC 세션을 보며 diffable datasource에 대해 알아보도록 하겠습니다 :)

Current State-of-the-Art

기존에는 UITableViewUICollectionView 에서 UI data source와 어떻게 상호작용했을까?

UICollectionViewDataSource 구현 예를 보자

UICollectionViewDataSource 프로토콜 선언부

위 코드에서는 UICollectionViewDatasource 프로토콜에서 필수(required)로 구현해야하는 두 가지 메서드와 다른 하나의 메서드(optional)가 구현되어있다.

프레임워크는 아래의 두 개의 메서드

optional func numberOfSections(in collectionView: UICollectionView) -> Int

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int

를 통해 section의 수와 각 section의 item수에 대해 물어본다.

그리고 contents가 렌더링 될 때 cell을 요청한다.

보통 이는 data source는 app 내부의 UICollectionViewDataSource와 같은 프로토콜을 채택한 data controller에 의해 지원된다.

그리고 이 controller는 Core Data와 상호 작용할 수 있고 web service와 상호작용할 수도 있다.

먼저 UI레이어와 data를 가져오기 위한 controller간의 소통방법을 살펴보자

UI레이어가 Controller에 Section에 item이 몇개인지, contents를 렌더링 할 때 cell을 제공하라고 한다.

이 Controller가 응답을 받는 웹 서비스 요청을 가지고 있는 경우에는 조금 더 복잡해진다.

만약 웹 서비스 응답을 통해 data의 변경사항을 알린다면 이 변경사항을 반영하기 위해 UI 레이어를 업데이트하여 변경사항을 반영할 수 있다.

하지만 가끔 업데이트가 잘못되면 이러한 에러를 만날 수 있다.

reloadData() 를 호출하여 문제를 해결할 수도 있다.

collection view의 모든 item을 다시 로드 해야하는 경우 이 메서드를 드물게 호출할 수 있다.
그러면 collection view가 현재 표시된 모든 item(place holder 포함)을 삭제하고, data source 객체의 현재 상태를 기반으로 item을 다시 만든다.
효율성을 위해 collection view에는 visible cell과 supplementary view만 표시된다.
reload한 결과 collection data가 축소되는 경우 collection view는 그에 따라 scroll offset을 조정한다.

item을 insert 및 delete하는 animation block 중간에는 이 메서드를 호출해서는 안된다.
item을 insert 및 delete하면 collection의 data가 자동으로 적절하게 업데이트 된다.

하지만 reloadData()를 호출하면 animation effect가 발생하지 않는다.

이는  사용자 경험을 좋지 않게 만든다.

뭐가 문제일까?

여기서 가장 큰 문제는  data controller(data source 역할을 하는)가 시간이 지남에 따라 변경되는 자신만의 truth(version)를 가지고 있다는 것이다.

그리고 UI역시 자신만의 truth (version)을 가지고 있다.

UI, Data 각각의 truth(version)를 맞추어 주어야하기 때문에 현재의 접근 방식은 오류가 발생하기 쉽다.

UI와 데이터가 각각의 버전을 가지고 있기 때문에 이를 동기화시켜줘야하는 문제가 발생한다.

A New Approach

Diffable Data Source

apply() 메서드는 뭘까?

알아보기에 앞서 Snapshot에 대한 개념을 먼저 알아보자

Snapshots

간단하게 말해 Snapshot은 현재 UI state의 truth(version)이다.

section과 item에 대해 unique identifier가 있으며, IndexPath 대신 identifier를 이용해 update한다.

예제를 통해 이해해보자

FOO, BAR, BIF가 화면에 표시된다고 하자

Controller(data source 역할을 하는)가 변경되면

적용하려는 새로운 Snapshot이 생긴다.

apply 함수를 호출하면 함수의 이름처럼 새로운 Snapshot이 적용된다.

Diffable Data Source는 모든 플랫폼에 걸쳐 4개의 클래스를 제공한다.

iOS, tvOS의 경우 UICollectionViewDiffableDataSource , UITableViewDiffableDataSource 가 있으며

Mac에는 NSCollectionViewDiffableDataSource 가 있다.

그리고 모든 플랫폼에서 공통적으로 사용되는 현재 UI 상태에 대한 책임을 가지고 있는 snapshot 클래스는 NSDiffableDataSourceSnapshot 이다.

예제

이곳 에서 예제 프로젝트를 다운받아 Diffable Data Source가 어떻게 작동 하는지  메커니즘을 학습해보자

다양한 예제를 살펴보며 반복되는 패턴을 찾아보자

총 3개를 살펴볼 예정

변경 혹은 새로운 data set을 전체 data source가 있는 collection view또는 table view에 반영하고 싶을 때마다 snapshot을 생성하기만 하면 된다.

먼저 해당 업데이트 주기에 표시할 item에 대한 configuration으로 해당 snapshot을 채운다.

그 다음 snapshot을 적용해(apply) 변경사항을 UI에 commit한다.

Snapshot 생성, Snapshot 설정, Snapshot apply
이 3단계의 프로세스가 앞으로 계속 반복될 것이니 유의해서 보자

자, 첫번째 예제를 살펴보자

우리가 살펴보려고 하는 예제는 일반적인 검색 UI이다.

extension MountainsViewController: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { self.performQuery(with: searchText) } }

searchBar에서 가져온 searchText를 이용해 performQuery  메소드를 호출한다.

func performQuery(with filter: String?) {
        let mountains = mountainsController.filteredMountains(with: filter).sorted { $0.name < $1.name }

        var snapshot = NSDiffableDataSourceSnapshot<Section, MountainsController.Mountain>()
        snapshot.appendSections([.main])
        snapshot.appendItems(mountains)
        self.dataSource.apply(snapshot, animatingDifferences: true)
    }

performQuery 메소드 자체는 매우 간단하다.

하는 일은 MountainsController에 와 일치하는 Mountain의 필터링 + 정렬된 목록을 요청하는 것이다.

여기서 MontainsController는 model layer객체입니다.

호출의 결과로 mountain list를 얻게된다.

앞서 말한 것 처럼 Snapshot을 적용하기 위해 3단계의 프로세스를 거친다.

먼저 새로운 NSDiffableDataSourceSnapshot을 만든다.

var snapshot = NSDiffableDataSourceSnapshot<Section, MountainsController.Mountain>()

이 snapshot은 처음에는 비어있다. 따라서 원하는 Section과 item으로 채워보자

enum Section: CaseIterable {
	case main
}
var snapshot = NSDiffableDataSourceSnapshot<Section, MountainsController.Mountain>()
snapshot.appendSections([.main])

Section을 추가하고, 임의로 이를 main section이라고 해보자

Section에 대한 부분은 뒤에서 좀 더 자세히 다루도록 하겠습니다.
let mountains = mountainsController.filteredMountains(with: filter).sorted { $0.name < $1.name }

var snapshot = NSDiffableDataSourceSnapshot<Section, MountainsController.Mountain>()
snapshot.appendSections([.main])
snapshot.appendItems(mountains)

다음으로 update에 표시하려는 item의 identifier(mountiains)를 추가한다.

appendItems의 시그니처를 보면 알 수 있듯 appendItems 메소드에는 identifier 배열을 전달한다.

Hashable과 같은 프로토콜을 채택한 객체를 전달할 수도 있습니다.

snapshot을 구성했으니, 이제 DiffableDataSource를 호출하고 snapshot을 적용하도록 요청해보자

let mountains = mountainsController.filteredMountains(with: filter).sorted { $0.name < $1.name }

var snapshot = NSDiffableDataSourceSnapshot<Section, MountainsController.Mountain>()
snapshot.appendSections([.main])
snapshot.appendItems(mountains)
self.dataSource.apply(snapshot, animatingDifferences: true)

apply를 호출하면 사용자가 입력하기전에 이전 업데이트 주기에서 표시한 내용에 대해 파악해야하는 코드를 작성할 필요없이 이전 업데이트와 다음 업데이트 사이의 변경된 사항을 파악한다.

IndexPath와 같이 깨지기 쉽고 일시적인 것을 다루는 것이 아닌, identifier를 다룬다.

NSDiffableDataSourceSnapshot 에 대해 조금 더 살펴봅시다.

NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType> 은 Generic class이기 때문에 SectionIdentifierTypeItemIdentifierType 에 의해 parameterized된다.

enum Section: CaseIterable {
	case main
}

먼저 snapshot을 만들 때 사용한 SectionIdentifierType 을 살펴보자

var snapshot = NSDiffableDataSourceSnapshot<Section, MountainsController.Mountain>()

Swift의 enum type은 Hashable이기 때문에 SectionIdentifierType 에 enum type을 전달할 수 있다.

이제 ItemIdentifierType 으로 사용한 Mountain type에 대해 살펴보자

class MountainsController {
    struct Mountain: Hashable {
        let name: String
        let height: Int
        let identifier = UUID()
        
        func hash(into hasher: inout Hasher) {
            hasher.combine(identifier)
        }
        
        static func == (lhs: Mountain, rhs: Mountain) -> Bool {
            return lhs.identifier == rhs.identifier
        }
        
        func contains(_ filter: String?) -> Bool {
            guard let filterText = filter else { return true }
            if filterText.isEmpty { return true }
            let lowercasedFilter = filterText.lowercased()
            return name.lowercased().contains(lowercasedFilter)
        }
    }
    
    func filteredMountains(with filter: String?=nil, limit: Int?=nil) -> [Mountain] {
        let filtered = mountains.filter { $0.contains(filter) }
        if let limit = limit {
            return Array(filtered.prefix(through: limit))
        } else {
            return filtered
        }
    }
    
    private lazy var mountains: [Mountain] = {
        return generateMountains()
    }()
}

Mountain struct는 DiffableDataSource와 함께 사용할 수 있도록 Hashable 프로토콜을 채택했다.

그리고 DiffableDataSourceSnapshot의 IdenfitierType으로 사용하기 위한 중요한 요구사항은 각 Mountain이 hash 값으로 고유하게 식별될 수 있어야 한다는 것이다.

그래서 각 Mountain에 고유한 identifier를 부여함으로써 이를 달성했다.

또, identifier의 hash 값은  DiffableDataSource가 다음 업데이트에 무엇을 업데이트 해야하는지 알 수 있도록 고유하게 만들어야한다.

다시 돌아와서 DiffableDataSource에 대한 변경 사항을 발행하는 방법을 살펴보자

예제에서 data source를 구성하는 configureDataSource라는 함수를 살펴보자

collection view로 작업을 하고 있기 때문에 UICollectionViewDiffableDataSource 를 인스턴스화 한다.

다음으로 UICollectionViewDiffablaDataSource 가 Generic class이기 때문에 section 및 item type으로 parameterize한다.

self.dataSource = UICollectionViewDiffableDataSource<Section, MountainsControlelr.Mountain>(collectionView: self.mountainsCollectionView)

위와 같이 data source를 연결하려고 하는 Collection View에 대한 참조를 전달한다.

참조를 전달하면 Diffable DataSource는 해당 포인터를 가져와 해당 CollectionView의 data source로 연결한다.

다음으로 trailing closure 를 전달해야한다.

이 trailing closure는 UICollectionViewDiffableDataSource 의 init에서 확인할 수 있듯 CellProvider에 대해 작성해야한다.

@MainActor public init(collectionView: UICollectionView, cellProvider: @escaping UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType>.CellProvider)
UICollectionViewDiffableDataSource init 시그니처
public typealias CellProvider = (_ collectionView: UICollectionView, _ indexPath: IndexPath, _ itemIdentifier: ItemIdentifierType) -> UICollectionViewCell?

이곳에 작성할 코드는 data source를 구현하는 경우 일반적으로 cellForItemAtIndexPath 를 구현할 때의 내용과 같다.

cellForItemAtIndexPath 선언부

Collection View를 호출하고, 원하는 데이터를 표시할 적절한 type의 cell을 요청한다.

그리고 그 cell을  보여주고 싶은 내용으로 채운 다음 다시 반환한다.

따라서 방금 작성한 코드는 cellForItemAtIndexPath 코드를 data source를 인스턴스화 할 때 전달하는 closure로 이식한 것과 같다.

위 방식을 사용하면 해당 IndexPath를 이용해 Model layer에 있는 객체를 찾을 필요가 없다.

Collection View를 설정하고 구성하는 방법에 대한 다른 모든 것은 이전과 동일하다.

다른 예를 살펴보자

이번예제는 iOS 설정 앱의 Wi-Fi 설정 UI예제이다.

이 예제는 섹션이 두개로 나뉘어져있다는 특징을 가지고 있다.

상단에는 Wi-Fi 활성화 / 비활성화 스위치, 현재 연결된 현재 네트워크에 대한 정보가 있는 섹션이 있고,

감지된 네트워크 리스트를 보여주는 (동적으로 업데이트 되는) 또 다른 섹션이 있다.

Wi-Fi비활성화 스위치를 tap하거나 다시 tap하면  아래와 같이 애니메이션이 적용된다.

이러한 동적 UI를 어떻게 구현하는지 살펴보자

먼저 (예제)의  WiFiSettingsViewControllerupdateUI 함수를 살펴보자

    /// - Tag: WiFiUpdate
    func updateUI(animated: Bool = true) {
        guard let controller = self.wifiController else { return }

        let configItems = self.configurationItems.filter { ($0.type == .currentNetwork && (controller.wifiEnabled == false)) == false }

        self.currentSnapshot = NSDiffableDataSourceSnapshot<Section, Item>()

        self.currentSnapshot.appendSections([.config])
        self.currentSnapshot.appendItems(configItems, toSection: .config)

        if controller.wifiEnabled {
            let sortedNetworks = controller.availableNetworks.sorted { $0.name < $1.name }
            let networkItems = sortedNetworks.map { Item(network: $0) }
            self.currentSnapshot.appendSections([.networks])
            self.currentSnapshot.appendItems(networkItems, toSection: .networks)
        }

        self.dataSource.apply(self.currentSnapshot, animatingDifferences: animated)
    }

표시해야할 내용이 변경될 때 마다 이 함수가 호출되도록 되어있다.

위에서 살펴본 3단계 프로세스와 동일하다.

self.currentSnapshot = NSDiffableDataSourceSnapshot<Section, Item>()

먼저 snapshot을 생성한다.

snapshot을 보여주려고 하는 내용으로 채워보자

enum Section: CaseIterable {
	case config, networks
}

먼저 Section을 만들고, 맨 위에 표시되는 첫번째 섹션인 config 섹션을 추가하자

func updateUI(animated: Bool = true) {
	guard let controller = self.wifiController else { return }

	let configItems = self.configurationItems.filter { ($0.type == .currentNetwork && (controller.wifiEnabled == false)) == false }

	self.currentSnapshot = NSDiffableDataSourceSnapshot<Section, Item>()

	self.currentSnapshot.appendSections([.config])
    self.currentSnapshot.appendItems(configItems, toSection: .config)
}

그리고 config section에 item을 추가한다.

이제 Wi-Fi가 활성화되면 Model layer와 통신하여 사용가능한 네트워크의 리스트를 받아온다.

func updateUI(animated: Bool = true) {
	guard let controller = self.wifiController else { return }

	let configItems = self.configurationItems.filter { ($0.type == .currentNetwork && (controller.wifiEnabled == false)) == false }

	self.currentSnapshot = NSDiffableDataSourceSnapshot<Section, Item>()

	self.currentSnapshot.appendSections([.config])
    self.currentSnapshot.appendItems(configItems, toSection: .config)
    
    if controller.wifiEnabled {
            let sortedNetworks = controller.availableNetworks.sorted { $0.name < $1.name }
            let networkItems = sortedNetworks.map { Item(network: $0) }
            self.currentSnapshot.appendSections([.networks])
            self.currentSnapshot.appendItems(networkItems, toSection: .networks)
        }
}

그리고 잠시 후에 살펴볼 Item type으로 해당 list를 wrapping한다.

let networkItems = sortedNetworks.map { Item(network: $0) }

해당 network list에 대해 section을 추가하고, section에 item을 추가한다.

두 개의 서로 다른 section이 있기 때문에 각 Item set을 추가하는 section을 위와 같이 명시할 수 있다.

이제 Diffable DataSource에 이러한 변경 사항을 적용하도록 요청하고, 선택적으로 difference에 대해 animation을 적용할 수 있다.

예를 들어 UI를 처음 불러오고 초기 데이터 set을 표시할 때 animation적용을 원할 수도 있고, 원하지 않을 수도 있다.

self.dataSource.apply(self.currentSnapshot, animatingDifferences: animated)

따라서 위와 같이 옵션을 줄 수 있다.

이 세션에서는 enum type이 Hashable이기 때문에 Section을 enum으로 정의해 사용하는 것을 권장하고 있다.

자 이제 위에서 살펴보기로 했던 Item struct를 살펴보자

enum ItemType {
	case wifiEnabled, currentNetwork, availableNetwork
}

struct Item: Hashable {
	let title: String
    let type: ItemType
    let network: WiFiController.Network?

	init(title: String, type: ItemType) {
    	self.title = title
        self.type = type
        self.network = nil
        self.identifier = UUID()
	}
    
    init(network: WiFiController.Network) {
    	self.title = network.name
        self.type = .availableNetwork
        self.network = network
        self.identifier = network.identifier
	}
    var isConfig: Bool {
    let configItems: [ItemType] = [.currentNetwork, .wifiEnabled]
    return configItems.contains(type)
	}
    
    var isNetwork: Bool {
    	return type == .availableNetwork
	}

	private let identifier: UUID
    
    func hash(into hasher: inout Hasher) {
    	hasher.combine(self.identifier)
	}
}

Mountain 구조체와 마찬가지로 Hashable 프로토콜을 채택하고 있다.

이 type을 선언하는 이유는 list를 볼 때 대부분 네트워크 item인 list를 포함하기 때문이다.

하지만 그 외에도 맨 위에 Wi-Fi 활성화/비활성화 스위치를 포함하고 있다.

이는 network item이 아닌, Configuration item이다.

여기서 우리가 해야하는 일은 Item이라는 generic wrapper type에 각 item을 캡슐화 하는 것이다. 그러나 wrapper type은 Diffable Data Source를 전달할 Item type이므로 Hashable을 채택하고, Item이 Hash 값으로 고유하게 식별되는지 확인해야한다.

따라서 network item의 경우 아래와 같이 network list에서 identifier를 가져와 사용할 수 있다.

init(network: WiFiController.Network) {
	self.title = network.name
    self.type = .availableNetwork
    self.network = network
    self.identifier = network.identifier
}

config item의 경우 UUID를 아래와 같이 동적으로 생성한다.

init(title: String, type: ItemType) {
	self.title = title
    self.type = type
    self.network = nil
    self.identifier = UUID()
}
func hash(into hasher: inout Hasher) {
	hasher.combine(self.identifier)
}

Item type의 인스턴스 메소드 hash를 보면 identifier를 기반으로 hash 값을 계산한다.

이것이 Diffable Data Source가 한 업데이트 주기에서 다음 주기까지 동일한 item을 식별할 수 있는데 필요한 전부이다.

DataSource를 configure하는 함수인 configureDataSource 함수를 살펴보자

func configureDataSource() {
        self.wifiController = WiFiController { [weak self] (controller: WiFiController) in
            guard let self = self else { return }
            self.updateUI()
        }

        self.dataSource = UITableViewDiffableDataSource
        <Section, Item>(tableView: self.tableView) { [weak self]
                (tableView: UITableView, indexPath: IndexPath, item: Item) -> UITableViewCell? in
            guard let self = self, let wifiController = self.wifiController else { return nil }

            let cell = tableView.dequeueReusableCell(
                withIdentifier: WiFiSettingsViewController.reuseIdentifier,
                for: indexPath)
            
            var content = cell.defaultContentConfiguration()
            // network cell
            if item.isNetwork {
                content.text = item.title
                cell.accessoryType = .detailDisclosureButton
                cell.accessoryView = nil

            // configuration cells
            } else if item.isConfig {
                content.text = item.title
                if item.type == .wifiEnabled {
                    let enableWifiSwitch = UISwitch()
                    enableWifiSwitch.isOn = wifiController.wifiEnabled
                    enableWifiSwitch.addTarget(self, action: #selector(self.toggleWifi(_:)), for: .touchUpInside)
                    cell.accessoryView = enableWifiSwitch
                } else {
                    cell.accessoryView = nil
                    cell.accessoryType = .detailDisclosureButton
                }
            } else {
                fatalError("Unknown item type!")
            }
            cell.contentConfiguration = content
            return cell
        }
        self.dataSource.defaultRowAnimation = .fade

        self.wifiController.scanForNetworks = true
    }

이전 예제와는 다르게 UITableView로 작업하고 있다.

Snapshot 생성 및 commit의 관점에서 볼 때 API는 매우 유사하기 때문에 CollectionView, TableView의 여부가 이 예제에서는 중요하지는 않은 것 같다.
self.dataSource = UITableViewDiffableDataSource
            <Section, Item>(tableView: self.tableView)

UITableViewDiffableDataSource역시  Generic class 이기 때문에 Section, Item으로 해당 클래스 이름을 parameterized 한다.

그리고 tableView에 대한 참조를 전달하면 방금 생성한 Diffable DataSource가 연결된다.

그 다음 cell provider인 trailing closure를 살펴보자

언뜻 보기에 복잡해 보이지만 다양한 type의 item이 있기 때문에 그렇게 보일 뿐이다.

이 예제에서는 세 가지 type의 item이 있으며 서로 다르게 처리하고 있다.

 self.dataSource = UITableViewDiffableDataSource
        <Section, Item>(tableView: self.tableView) { [weak self]
                (tableView: UITableView, indexPath: IndexPath, item: Item) -> UITableViewCell? in
            guard let self = self, let wifiController = self.wifiController else { return nil }

            let cell = tableView.dequeueReusableCell(
                withIdentifier: WiFiSettingsViewController.reuseIdentifier,
                for: indexPath)
            
            var content = cell.defaultContentConfiguration()
            // network cell
            if item.isNetwork {
                content.text = item.title
                cell.accessoryType = .detailDisclosureButton
                cell.accessoryView = nil

            // configuration cells
            } else if item.isConfig {
                content.text = item.title
                if item.type == .wifiEnabled {
                    let enableWifiSwitch = UISwitch()
                    enableWifiSwitch.isOn = wifiController.wifiEnabled
                    enableWifiSwitch.addTarget(self, action: #selector(self.toggleWifi(_:)), for: .touchUpInside)
                    cell.accessoryView = enableWifiSwitch
                } else {
                    cell.accessoryView = nil
                    cell.accessoryType = .detailDisclosureButton
                }
            } else {
                fatalError("Unknown item type!")
            }
            cell.contentConfiguration = content
            return cell
        }

마지막 예제를 보자

예제를 통해 점점 반복되는 패턴이 보이는 것을 확인할 수 있다.

마지막 예제는 색상(Color) 견본이 표시되는 item을 표시하는 UICollectionView이다.

처음에는 random 순서로 색상이 지정된다.

sort button을 tap하면 스펙트럼 순서로 삽입 정렬이 진행 된다.

위 예제는 update를 config하고 commit하는 방식이 다른 예제와 살짝 다르다.

구체적으로 snapshot을 불러오는 부분이 다르다.

먼저 (예제)의  InsertionSortViewController 를 살펴보자

먼저 performSortStep 함수를 살펴보자

이번에도 위에서 진행했던 3단계의 cycle을 가지고 있다.

이번에도 snapshot을 생성하고, 채운 다음 적용한다.

    /// - Tag: InsertionSortStep
    func performSortStep() {
        if self.isSorting == false {
            return
        }

        var sectionCountNeedingSort = 0

        // Get the current state of the UI from the data source.
        var updatedSnapshot = self.dataSource.snapshot()

        // For each section, if needed, step through and perform the next sorting step.
        updatedSnapshot.sectionIdentifiers.forEach {
            let section = $0
            if section.isSorted == false {

                // Step the sort algorithm.
                section.sortNext()
                let items = section.values

                // Replace the items for this section with the newly sorted items.
                updatedSnapshot.deleteItems(items)
                updatedSnapshot.appendItems(items, toSection: section)

                sectionCountNeedingSort += 1
            }
        }

        var shouldReset = false
        var delay = 125
        if sectionCountNeedingSort > 0 {
            self.dataSource.apply(updatedSnapshot)
        } else {
            delay = 1000
            shouldReset = true
        }
        let bounds = insertionCollectionView.bounds
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delay)) {
            if shouldReset {
                let snapshot = self.randomizedSnapshot(for: bounds)
                self.dataSource.apply(snapshot, animatingDifferences: false)
            }
            self.performSortStep()
        }
    }

그러나 위의 경우 비어있는 새로운 snapshot을 생성하는 대신에

var updatedSnapshot = self.dataSource.snapshot()

Diffable DataSource에 현재 snapshot을 요청하는 기능을 활용한다.

이제 이 snapshot은 UICollectionView 에 표시되는 현재 상태로 snapshot이 미리 채워진다.

따라서 처음부터 다시 시작할 필요가 없고, 해당 상태에서 시작하여 다음 중간 상태를 계산 해낼 수 있다.

다음으로 snapshot을 채우는 부분에서

updatedSnapshot.deleteItems(items)
updatedSnapshot.appendItems(items, toSection: section)

deleteItems , appendItems가 호출되는 것을 볼 수 있다.

Snapshot API를 보면 이러한 종류의 작업을 수행할 때 기존 Snapshot을 수정할 수 있는 다양한 기능이 있음을 알 수 있다.

self.dataSource.apply(updatedSnapshot)

앞서 살펴봤던 방식과 같이 마지막으로 완료되면 해당 snapshot을 Diffable DataSource에 적용하기만 하면된다.

Diffable DataSource를 설정해주는 configureDataSource함수를 보자

self.dataSource = UICollectionViewDiffableDataSource<InsertionSortArray, InsertionSortArray.SortNode>(collectionView: self.insertionCollectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, sortNode: InsertionSortArray.SortNode) -> UICollectionViewCell? in
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: InsertionSortViewController.reuseIdentifier, for: indexPath)
            cell.backgroundColor = sortNode.color
            return cell
}

사용중인 type인 Collection View를 지정한 다음 cell provider closure를 작성한다.

이 예제에서는 color만 표시하기 때문에 매우 간단하다.

Considerations

위에서 살펴본 API를 활용하는 방법의 디테일적인 부분에 대해 조금 더 알아보자

앞서 코드예제에서 살펴보았듯 Diffable DataSource는 기본적으로 세 단계를 거친다.

  1. Snapshot을 만든다.
  2. 필요에 따라 configure한다.
  3. apply

따라서 변경사항이 생기면 항상 apply 메서드를 호출해야한다.

Snapshot을 만드는 두가지 방법이 있다.

가장 일반적인 방법은 빈 snapshot을 만들어 Section 및 item에 대한 type으로 snapshot을 구성한다.

두번째 방법은 Color 정렬 예제에서 본 것 처럼 현재 Collection View, Table View의 snapshot을 불러올 수 있다.

이 방법은 아주 작은 것 하나를 수정해야 하는 특정 작업이 발생할 때 유용하다.

let snapshot = self.dataSource.snapshot()

위처럼 코드를 작성하면 snapshot이라는 상수에는 copy(사본)가 저장된다.

따라서 self.dataSource에는 영향을 끼치지 않는다.

또, snapshot의 상태에 대해 알 수 있는 API역시 존재한다.

snapshot을 configure할 때 사용하는 API에서 볼 수 있듯 IndexPath를 사용하지 않는다.

지금까지 item을 추가하고 section을 추가하는 등의 매우 일반적인 패턴의 예제를 보았다.

세번째 함수의 시그니처를 보자

func appendItems(_ identifiers: [ItemIdentifierType], toSection sectionIdentifier: SectionIdentifierType? = nil)

이 함수 시그니처의 경우 sectionIdentifier 에 default parameter가 nil로 지정되어있다. nil로 지정될 경우, 마지막으로 알려진 section에 추가된다.

Identifiers

위에서 살펴보았던 것처럼 Model 데이터를 identifier로 가져올 수 있다는 것을 확인했다.

이를 위해선 Hashable을 채택하고 있어야 하며, 고유해야한다.

DiffableDataSource는 Idenfier를 기반으로 Model을 식별했는데 IndexPath기반의 API에서는 identifier를 어떻게 사용해야할까?

예를 보자

SDK에는 수많은 IndexPath기반의 API가 있다.

+대부분 delegate 메서드에 많이 있다.

따라서 사용자가 콘텐츠와 interaction하고, 항목을 tap하면  delegate메시지 didSelectItemAtIndexPath 가 호출된다.

그렇다면 이 메시지가 불리면 IndexPath를 어떻게 사용해야할까?

이는 새로운 API를 이용해 해결하면 된다.

위 API를 이용하면 Identifier를 IndexPath로 변환하고, IndexPath를 Identifier로 변환할 수 있다.

예제를 보자

이 예제는 indexPath에 해당하는 identifier로 변환하는 예제이다.

위 처럼 IndexPath를 identifier로 변환해 사용할 수 있다.

Performance

Snapshot API에서 diff는 O(N)의 시간복잡도를 가지는 선형 연산이다.

간단히 말해서, item이 많을수록 diff를 찾는 데 더 오래 걸린다.

Main Queue는 사용자 이벤트에 반응해야하기 때문에 가능한 한 항상 자유로워야한다.

많은 수의 item이 있는경우 Background Queue에서 Apply 메서드를 호출하는 것이 안전하다.

Improving app responsiveness | Apple Developer Documentation
Create a more immediate user experience by removing hangs and hitches from your app’s user interface.
위 문서를 참고해보자면 Main Queue에서 작업할 때 100ms보다 작업시간이 길어진다면 사용자의 Interaction에 즉각적으로 반응할 수 없기 때문에 Background Queue에서 Apply를 호출하는게 합리적 일 것 같다.

Background Queue에서 Apply를 호출하면, 프레임워크는 diff가 계산되면 메인 큐로 돌아가서 diff의 결과를 적용한다.

Backgrund Queue에서 Apply를 호출하기 위해 이 model을 선택하는 경우 일관성을 유지해야한다.

Background Queue에서 호출했으면 항상 Backround Queue에서 호출해야한다.

Background Queue 또는 Main Queue에서 호출된 결과가 mix and match가 될 수 있기 때문이다.

마무리

이상 Advances in UI Data Source 세션에 대한 정리였습니다.

위에서 살펴본 것처럼 Diffable DataSourec는 Model의 데이터를 CollectionView, TableView로 가져오기 위해 수행해야하는 작업들을 단순화 시킨다는 느낌을 받았습니다.

UI와, data의 sync가 안맞아 발생할 수 있는 문제를 UI를 반영하고 있는 하나의 data source를 둠으로써 해결한다는 점 역시 인상 깊었습니다.

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

WWDC 세션 링크

Advances in UI Data Sources - WWDC19 - Videos - Apple Developer
Use UI Data Sources to simplify updating your table view and collection view items using automatic diffing. High fidelity, quality…