오늘은 간략한 예제와 함께 DiffableDataSource의 실제 사용법에 대해 살펴보는 시간을 갖겠다.
Section, Item 클래스 생성하기
UICollectionViewDiffableDataSource는 Section identifier, Item identifier 이렇게 두 개의 generic 타입을 가진다.
우선 Section을 생성하겠다.
1. Section 생성하기
Section Type은 가변적이라면 Class 타입으로 생성하고, 고정된 섹션을 가진다면 enum 타입으로 생성해도 된다.
class Section: Hashable{
let id = UUID()
var title: String
var people: [Person]
init(title: String, people: [Person]){
self.title = title
self.people = people
}
// 2
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
// 3
static func == (lhs: Section, rhs: Section) -> Bool {
lhs.id == rhs.id
}
}
extension Section{
static var sections: [Section] = [
Section(title: "intj", people: [
Person(name: "Kang Hee Seon", age: 26),
Person(name: "Kim Eun Hae", age: 21)
]),
Section(title: "estp", people: [
Person(name: "Choi Hong Gyu", age: 32)
]),
Section(title: "istp", people: [
Person(name: "Choi Soo", age: 16),
Person(name: "Kim Jung Wook", age: 27),
Person(name: "Kim Dae Hyuck", age: 12)
]),
Section(title: "entj", people: [
Person(name: "Baek Eun Soo", age: 42)
])
]
}
앞서 말했다시피 Diffable Datasource의 타입은 Hashable이다.
그 이유는 데이터가 변경될 때 이전과 차이를 계산하는 과정에서 두 개의 요소가 같은지 다른지 알아봐야 하기 때문이다.
2번과 3번 과정은 Hashable이 Equatable 프로토콜을 채택하고 있기 때문에 ==과 hash 메소드를 구현해 주어야 한다고 한다(일단 Hashable 타입이면 해당 메서드 구현 해야 된다는 걸로 이해하고 넘어가자..)
struct나 enum과 같은 값타입 일 경우 자동으로 Hashable 프로토콜을 채택하지만 class와 같은 참조타입 일 경우 일일히 해당 메서드를 추가해주어야 한다.
예제에서는 class Section이기 때문에 hash, == 메서드를 구현해 준 것 같다.
그리고 hasher.combine()에 id를 인자 값으로 넘긴 것은 구분을 위해 필요한 값을 id로 설정했다고 생각하면 될 것 같다.
마지막으로 해당 예제는 이해를 위해서 미리 데이터를 정의해놨는데 추후에는 데이터를 받아와서 diffabledatasource를 구성하는 예제도 살펴볼 수 있도록 하겠다.
2. Item 생성하기(Person)
ItemIdentifier도 Hashable해야 하므로 Hashable프로토콜을 채택한다.
import UIKit
class Person: Hashable{
let id = UUID()
var name: String
var age: Int
init(name: String, age: Int){
self.name = name
self.age = age
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: Person, rhs: Person) -> Bool {
lhs.id == rhs.id
}
}
3. HeaderView 생성하기
HeaderView를 따로 만들지 않아도 오류가 발생하지 않지만 예제에서는 HeaderView에 대한 설명도 있을 뿐더러 다니고 있는 회사에서 대부분 HeaderView를 가지고 있는 CollectionView들을 사용하고 있어서 이 부분도 살펴보겠다.
import UIKit
// 1
class SectionHeaderReusableView: UICollectionReusableView{
static var identifier: String {
return String(describing: SectionHeaderReusableView.self)
}
// 2
lazy var titleLabel: UILabel = {
var label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .title1).pointSize, weight: .bold)
label.textColor = .label
label.textAlignment = .left
label.numberOfLines = 1
label.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
// 3
backgroundColor = .systemBackground
addSubview(titleLabel)
NSLayoutConstraint.activate([
titleLabel.leadingAnchor.constraint(
equalTo: readableContentGuide.leadingAnchor),
titleLabel.trailingAnchor.constraint(
lessThanOrEqualTo: readableContentGuide.trailingAnchor)
])
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(
equalTo: topAnchor,
constant: 10),
titleLabel.bottomAnchor.constraint(
equalTo: bottomAnchor,
constant: -10)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
HeaderView도 Cell과 마찬가지로 UICollectionReusableView를 상속받는데, Header View 또한 셀처럼 재사용이 가능하기 때문이다.
맨 위에 identifier 변수가 있는데, 내가 알기로는 나중에 어떠한 Headerview를 사용할지 선택할 때 저 identifier를 이용해서 해당 HeaderView를 사용하는 것으로 알고 있다.
나머지는 기존에 View 구성하는 방식과 동일하기 때문에 넘어가도록 하겠다.
4. DiffableDataSource 생성하기
private var sections = Section.sections
typealias DataSource = UICollectionViewDiffableDataSource<Section, Person>
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Person>
private lazy var dataSource = makeDataSource()
예제에서 typealias를 사용하고 있는데, typealias는 코드를 간결하고, 가독성 있게 작성하기 위해 사용한다.
typealias 간단설명:
위의 예제를 예시로 들면 다음과 같은 차이가 있다고 말할 수 있겠다.
해당 부분에 대한 자세한 내용은 다루지 않겠다.
// typealias 사용 x
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
//typealias 사용 o
typealias MyDataSource = UICollectionViewDiffableDataSource<Section, Item>
private var dataSource: MyDataSource!
이어서 말하면, DataSource는 제네릭 타입을 가진 클래스이고, 두 개의 타입은 모두 Hashable해야 한다.
(아까 우리는 Section과 Person 모두 hashable 프로토콜을 채택했음)
Snapshot = NSDiffableDataSourceSnapshot<Section, Person>
현재 Section과 item에 대한 정보를 가지고 있다. (현재 데이터 사진을 찍은 느낌)
그래서 DiffableDatasource가 이것을 보고 섹션과 아이템이 얼마나 있는지를 파악할 수 있다.
참고 블로그에서는 dataSource를 설정할 때 위에 있는 코드처럼 꼭 lazy로 설정해야 한다라고 적혀 있는데 아마 해당 코드에서 makeDataSource() 함수의 리턴값으로 DataSource를 받아오는데 리턴 받기 전에 초기화되어서 오류가 발생하는 것 같다.
언제 Dasource를 호출하냐에 따라서 다른 것 같긴(내 생각) 하지만 결론적으로 말하면 무조건 dataSource를 lazy로 선언하지 않아도 된다.
내가 구현한 코드는 데이터 소스 정의 함수를 viewDidLoad()에서 호출했는데 이렇게 할 경우 lazy를 사용하지 않아도 오류가 발생하지 않았다.
일단 계속해서 예제 코드를 보도록 하겠다.
위에서 private lazy var dataSource = makeDataSource() 라고 되어있는데,
makeDataSource()는 dataSource를 정의하는 함수라고 생각하면 되고 코드는 다음과 같다.
func makeDataSource() -> DataSource{
// 1
let dataSource = DataSource(collectionView: collectionView) { (collectionView, indexPath, person) -> UICollectionViewCell? in
// 2
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "PersonCollectionViewCell", for: indexPath) as? PersonCollectionViewCell
cell?.person = person
return cell
}
// 3
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
// 4
guard kind == UICollectionView.elementKindSectionHeader else { return nil }
// 5
let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: SectionHeaderReusableView.identifier, for: indexPath) as? SectionHeaderReusableView
// 6
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
view?.titleLabel.text = section.title
return view
}
return dataSource
}
1. dataSource를 생성할 때에는 collectionView와 cell provider를 파라미터로 넘겨줘야 한다.
- cell provider: diffable datasource에서 collectionview의 cell을 생성하고 리턴하는 클로저를 의미
2. 또한 cell provider에서는 실제 cell을 리턴하는 클로저를 파라미터로 넘겨준다.
여기서 생성한 셀은 우리가 화면에서 보는 셀을 의미한다.
3. 만약 HeaderView가 존재하는 경우(HeaderView가 없다면 해당 코드가 없어도 됨) dataSource.supplementaryViewProvider에 HeaderView를 생성하는 클로저를 할당해준다.
- supplementaryViewProvider: diffable datasource에서 collectionview의 supplementaryViewProvider(header or footer)를 생성하고 리턴하는 클로저를 의미
4. 참고 블로그에서는 헤더뷰를 원하기 때문에 kind가 UICollectionView.elementKindSectionHeader인지를 체크한다는데 이해가 잘 되진 않지만 일단은 넘어가겠다.
5. 앞에 만들었던 SectionHeaderReusableView 클래스를 가지고 SupplementaryView를 재사용하는 코드를 추가한다.
6. HeaderView의 titleLabel을 현재 section의 title로 설정
5. Snapshot apply
snapshot에 저장되어 있던 데이터를 datasource에 apply하는(적용하는) 작업이다.
// 1
func applySnapshot(animatingDifferences: Bool = true){
var snapshot = Snapshot()
// 2
snapshot.appendSections(sections)
sections.forEach { section in
snapshot.appendItems(section.people, toSection: section)
}
// 3
dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
1. snapshot apply함수이며 새로운 snapshot을 생성
2. snapshot에 섹션을 추가한 후, 섹션에 아이템들을 추가
3. datasource에 snapshot을 적용
주의할 점:
만약 다른 section이지만 동일한 item을 반영하고자 하는 경우, DiffableDataSource를 활용하면 컴파일은 되지만, 마지막 Section에만 Item이 나타나는 현상이 발생할 수 있다.
그러면 다음과 같은 문구가 출력된다.
< Diffable data source detected an attempt to insert or append 1 item identifier that already exists in the snapshot. The existing item identifier will be moved into place instead, but this operation will be more expensive. For best performance, inserted item identifiers should always be unique. Set a symbolic breakpoint on BUG_IN_CLIENT_OF_DIFFABLE_DATA_SOURCE__IDENTIFIER_ALREADY_EXISTS to catch this in the debugger. Item identifier that already exists>
대충 snapshot에 동일한 item이 들어갈 수 없다는 내용으로 이해하면 된다.
이전에 DiffableDataSource의 정의에서 살펴본 내용 중에 SectionIdentifier와 itemIdentifer 모두 Hashable 해야 하는 게 이것 때문이라고 할 수 있다.
그래서 DiffableDataSource model 에 UUID와 같은 프로퍼티를 추가해서 다른 item 인척 하는 경우가 많다.
참고링크: