-
Diffable DataSourceiOS 2022. 12. 29. 04:53
Diffable DataSource는 간단하고 효율적으로 UICollectionView, UITableView의 데이터 및 UI에 대한 업데이트를 관리할 수 있게 해줍니다. 둘 다 동일한 방식으로 적용하면 되며, 이 글에서는 테이블 뷰를 예로 들겠습니다. 구식 방식에서는 UITableViewDataSource 프로토콜의 구현 메서드에서 editingStyle을 다음과 같이 사용하면서, 아이템을 삭제할 때 애니메이션을 적용할 수 있었습니다.
extension CitiesViewController: UITableViewDataSource { func tableView(...) { ... } func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { if editingStyle == .delete { deleteCity(at: indexPath) tableView.deleteRows(at: [indexPath], with: .automatic) } } // 모델에서 삭제 private func deleteCity(at indexPath: IndexPath) { model.removeCity(at: indexPath.row) } }
하지만 네트워크 통신을 통해 모델 레이어에 데이터를 추가하여 테이블 뷰의 reloadData()를 사용할 때에는 애니메이션을 적용할 수 없었고, 테이블 뷰 전체를 갱신하여 비효율적이며 사용자 경험을 헤치는 작업을 했었습니다. 또한 하나의 데이터를 추가하는데 전체를 재적재 한다는 것은 조금 이상하죠. 이것을 Diffable DataSource를 적용하여 다음과 같이 개선해보았습니다.
UITableViewDataSource(Before)
UITableViewDiffableDataSource(After)
기존에는 아래와 같이 ViewController가 테이블 뷰의 DataSource가 될 수 있도록 설정하고 프로토콜을 준수할 수 있도록 메서드를 구현해줬습니다. 그리고 reloadData()로 갱신하면서 사용해왔죠.
// configure dataSource & delegate cityWeatherTableView.dataSource = self cityWeatherTableView.delegate = self extension CitiesViewController: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // configure and return cell guard let cell = tableView.dequeueReusableCell(withIdentifier: CityWeatherTableViewCell.identifier, for: indexPath) as? CityWeatherTableViewCell, let self = self else { return nil } let currentWeather = ... let forecastWeather = ... ... cell.cityNameLabel.text = currentWeather.name ... return cell } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { model.count } // Delegate code ... }
UITableViewDiffableDataSource
@preconcurrency @MainActor class UITableViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> : NSObject where SectionIdentifierType : Hashable, SectionIdentifierType : Sendable, ItemIdentifierType : Hashable, ItemIdentifierType : Sendable
Declaration을 보면 SectionIdentifierType과 ItemIdentifierType이 Hashable 프로토콜을 준수해야 한다는 것을 알 수 있죠.
- SectionIdentifierType: 테이블뷰의 섹션을 나누는 타입. 주로 enum 타입을 사용.
- ItemIdentifierType: 테이블뷰에 뿌려줄 데이터의 타입
Diffable DataSource를 사용할 때는 data source를 객체(인스턴스)로 만들어서 사용합니다.
DiffableDataSource는 쉽게 말해서 Snapshot을 찍어서 변경 사항을 data source 객체에 적용(apply)함으로써 UI를 업데이트하는 것입니다. 그러기 위해서는 비교할 수 있는 고유한 정보를 가지고 있어야겠죠? 그래서 사용하는 데이터는 Hashable 프로토콜을 준수해야 하는 것 같습니다(확인한 사실이 아닌 저의 짧은 생각입니다.).
Hashable
protocol Hashable: Equatable
- Swift 표준 라이브러리의 String, Int, Float, Bool 등의 많은 타입은 기본적으로 Hashable을 따름.
- associated value 없이 enumeration을 정의하면 자동으로 Hashable을 따름. 이 특성을 이용해서 SectionIdentifierType에 enum을 사용.
- 커스텀 타입에 hash(into:) 메서드를 구현함으로써 Hashable을 따르도록 만들 수 있음.
코드
class CitiesViewController: UIViewController { // 섹션을 위한 enum enum Section: CaseIterable { case main } // DiffableDataSource 인스턴스 private var dataSource: UITableViewDiffableDataSource<Section, City>! private let cityWeatherTableView: UITableView = { let tableView = UITableView() ... // register tableView.register(CityWeatherTableViewCell.self, forCellReuseIdentifier: CityWeatherTableViewCell.identifier) return tableView }() override func viewDidLoad() { super.viewDidLoad() ... // data source 초기 설정 // data source와 테이블 뷰 연결 configureDataSource() } ... } extension CitiesViewController: UITableViewDelegate { private func apply() { let cities = model.cities var snapshot = NSDiffableDataSourceSnapshot<Section, City>() snapshot.appendSections([.main]) snapshot.appendItems(cities) dataSource.apply(snapshot, animatingDifferences: true) } private func configureDataSource() { // 초기 설정 후, apply()에서 snapshot을 사용해 적용하며 데이터 및 UI를 갈아끼움. dataSource = UITableViewDiffableDataSource<Section, AnotherCity>(tableView: cityWeatherTableView) { [weak self] (tableView: UITableView, indexPath: IndexPath, itemIdentifier: AnotherCity) -> UITableViewCell? in // configure and return cell guard let cell = tableView.dequeueReusableCell(withIdentifier: CityWeatherTableViewCell.identifier, for: indexPath) as? CityWeatherTableViewCell, let self = self else { return nil } let currentWeather = ... let forecastWeather = ... cell.cityNameLabel.text = currentWeather.name ... return cell } } // Delegate code ... }
테이블 뷰를 데이터로 채우는 것은 4단계로 구성
1. 테이블 뷰에 DiffableDataSource를 연결
2. cell provider를 구현하여 cell 구성
3. cell의 현재 상태 생성
4. UI에 데이터 표시
init(tableView:cellProvider:) 이니셜라이저를 사용해서 DiffableDataSource 객체를 테이블 뷰에 연결해줄 수 있습니다. 이후 모델에 변화가 있을 때, apply()를 호출하여 NSDIffableDataSourceSnapshot을 이용해 DataSource에 적용해주면 됩니다.
apply()는 3단계로 구성
1. NSDiffableDataSourceSnapshot 생성
- 처음에는 비어 있음. 따라서 원하는 섹션과 항목으로 채워야 함.
2. Snapshot에 Sections과 Items 추가
- 위의 경우, 하나의 섹션을 가진 UICollectionView이므로, main 하나만 추가
- 업데이트에 표시하려는 항목들의 식별자를 추가
- Swift의 기본 타입 뿐만이 아니라, 고유한 타입(위의 경우, City)으로 작업할 수도 있음. 구조체같은 값 유형일 경우, 해당 타입을Hashable하게 만들면 Swift 구문 측면에서 고유한 기본 개체를 전달할 수 있음.
3. DiffableDataSource를 호출하고 차이점을 애니메이션화 하여 해당 Snapshot을 적용하도록 요청
- DiffableDataSource의 apply(_:animatingDifferences:completion:) 메서드 사용.
커스텀 타입 예시
struct City: Hashable { let name: String let identifier = UUID() let currentWeather: WeatherOfCity let forecastWeather: Forecast func hash(into hasher: inout Hasher) { hasher.combine(identifier) } static func == (lhs: AnotherCity, rhs: AnotherCity) -> Bool { lhs.identifier == rhs.identifier } }
UICollectionViewDiffableDataSource
컬렉션 뷰도 같은 방법으로 적용하면 됩니다. 다만, 다음과 같이 CellRegistration 인스턴스를 생성해서 data source 객체로 cell을 반환하면서 register와 configure를 함께 수행할 수 있더라고요. UICollectionViewDiffableDataSource는 아래 참고(WWDC2019)에서 제공하는 코드의 Mountain을 Drink로 바꾼 내용입니다.
class DrinksViewController: UIViewController { private var dataSource: UICollectionViewDiffableDataSource<Section, DrinksController.Drink>! override func viewDidLoad() { super.viewDidLoad() ... configureDataSource() performQuery(with: nil) } // 초기 DataSource 설정 private func configureDataSource() { let cellRegistration = UICollectionView.CellRegistration <LabelCollectionViewCell, DrinksController.Drink> { (cell, indexPath, drinks) in cell.label.text = drinks.name } dataSource = UICollectionViewDiffableDataSource<Section, DrinksController.Drink>(collectionView: drinksCollectionView) { (collectionView: UICollectionView, indexPath: IndexPath, identifier: DrinksController.Drink) -> UICollectionViewCell? in // Return the cell. return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier) } } private func performQuery(with filter: String?) { // 1. SearchBar text를 사용해서 filter 작업 let drinks = drinksController.filteredDrinks(with: filter).sorted{ $0.name < $1.name } // 2. snapshot 생성 후 filter 작업 거친 drinks를 append var snapshot = NSDiffableDataSourceSnapshot<Section, DrinksController.Drink>() snapshot.appendSections([.main]) snapshot.appendItems(drinks) // 3. 그 후 dataSource에 snapshot 적용 dataSource.apply(snapshot, animatingDifferences: true) } }
Simulator Recording
코드
UITableViewDiffableDataSource
UICollectionViewDiffableDataSource
참고
WWDC 2019 Advances in UI Data Sources
'iOS' 카테고리의 다른 글
UIKit에서 SwiftUI View 활용하기 (0) 2023.01.02 Actor in Swift concurrency (0) 2022.12.31 Localization in iOS (0) 2022.12.14 Operator, Combine Framework in Swift (0) 2022.12.11 Publisher와 Subscriber, Combine Framework in Swift (0) 2022.12.09