UICollectionView From Scratch [3] - Data Diffing | Batch Updates | Multiple Sections
import Foundation
struct SectionCharacters: Hashable, Equatable {
let category: String
let characters: [Character]
func hash(into hasher: inout Hasher) {
hasher.combine(category)
}
static func ==(lhs: SectionCharacters, rhs: SectionCharacters) -> Bool {
lhs.category == rhs.category
}
}
Hashable
프로토콜을 따름==
은 현재 서로 같은 카테고리에 속한지로만 판단 중 var sectionsToInsert = IndexSet()
var sectionsToRemove = IndexSet()
var indexPathsToInsert = [IndexPath]()
var indexPathsToDelete = [IndexPath]()
let sectionDiff = newSectionItems.difference(from: oldSectionItems)
sectionDiff.forEach { change in
switch change {
case let .remove(offset, _, _):
sectionsToRemove.insert(offset)
case let .insert(offset, _, _):
sectionsToInsert.insert(offset)
}
}
(0..<newSectionItems.count).forEach { index in
let newSectionCharacter = newSectionItems[index]
if let oldSectionIndex = oldSectionItems.firstIndex(of: newSectionCharacter) {
let oldSectionCharacter = oldSectionItems[oldSectionIndex]
let diff = newSectionCharacter.characters.difference(from: oldSectionCharacter.characters)
diff.forEach { change in
switch change {
case let .remove(offset, _, _):
indexPathsToDelete.append(IndexPath(item: offset, section: oldSectionIndex))
case let .insert(offset, _, _):
indexPathsToInsert.append(IndexPath(item: offset, section: index))
}
}
}
}
newSectionCharacter
가 섹션이며 그 내부의 characters
가 곧 컬렉션 뷰의 섹션 내 아이템 로우로 사용될 셀 데이터 소스라는 데 주의indexPathsToDelete
, indexPathsToInsert
를 업데이트oldSectionIndex
와 index
를 통해 새로운 인덱스 패스를 만들고 각 IndexSet
에 추가.collectionView.performBatchUpdates { [weak self] in
guard let self = self else { return }
self.collectionView.deleteSections(sectionsToRemove)
self.collectionView.deleteItems(at: indexPathsToDelete)
self.collectionView.insertSections(sectionsToInsert)
self.collectionView.insertItems(at: indexPathsToInsert)
} completion: { [weak self] _ in
guard let self = self else { return }
let headerIndexPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader)
headerIndexPaths.forEach { indexPath in
guard let header = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionHeader, at: indexPath) as? HeaderView else { fatalError() }
let section = self.sectionedStubs[indexPath.section]
header.configure(with: "\(section.category) \(section.characters.count)")
}
}
performBatchUpdates
는 문자 그대로 배치 업데이트, 각 셀 단위의 업데이트를 실행하는 인스턴스 메소드import UIKit
import SwiftUI
class MultipleSectionCharactersViewController: UIViewController {
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.sectionInset = .init(top: 0, left: 8, bottom: 0, right: 8)
layout.minimumLineSpacing = 0
layout.minimumInteritemSpacing = 0
layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.dataSource = self
collectionView.delegate = self
collectionView.register(CharacterCell.self, forCellWithReuseIdentifier: CharacterCell.identifier)
collectionView.register(HeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: HeaderView.identifier)
return collectionView
}()
private lazy var segmentedControl: UISegmentedControl = {
let control = UISegmentedControl(items: Universe.allCases.map({ $0.title }))
control.selectedSegmentIndex = 0
control.addTarget(self, action: #selector(didTapControl), for: .valueChanged)
return control
}()
private var sectionedStubs = Universe.ff7r.sectionedStubs {
didSet {
updateCollectionView(oldSectionItems: oldValue, newSectionItems: sectionedStubs)
}
}
override func viewDidLoad() {
super.viewDidLoad()
setUI()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
collectionView.frame = view.bounds
}
private func setUI() {
view.backgroundColor = .systemBackground
view.addSubview(collectionView)
navigationItem.titleView = segmentedControl
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "shuffle")?.withTintColor(.label, renderingMode: .alwaysOriginal), style: .plain, target: self, action: #selector(didTapShuffle))
}
private func updateCollectionView(oldSectionItems: [SectionCharacters], newSectionItems: [SectionCharacters]) {
var sectionsToInsert = IndexSet()
var sectionsToRemove = IndexSet()
var indexPathsToInsert = [IndexPath]()
var indexPathsToDelete = [IndexPath]()
let sectionDiff = newSectionItems.difference(from: oldSectionItems)
sectionDiff.forEach { change in
switch change {
case let .remove(offset, _, _):
sectionsToRemove.insert(offset)
case let .insert(offset, _, _):
sectionsToInsert.insert(offset)
}
}
(0..<newSectionItems.count).forEach { index in
let newSectionCharacter = newSectionItems[index]
if let oldSectionIndex = oldSectionItems.firstIndex(of: newSectionCharacter) {
let oldSectionCharacter = oldSectionItems[oldSectionIndex]
let diff = newSectionCharacter.characters.difference(from: oldSectionCharacter.characters)
diff.forEach { change in
switch change {
case let .remove(offset, _, _):
indexPathsToDelete.append(IndexPath(item: offset, section: oldSectionIndex))
case let .insert(offset, _, _):
indexPathsToInsert.append(IndexPath(item: offset, section: index))
}
}
}
}
collectionView.performBatchUpdates { [weak self] in
guard let self = self else { return }
self.collectionView.deleteSections(sectionsToRemove)
self.collectionView.deleteItems(at: indexPathsToDelete)
self.collectionView.insertSections(sectionsToInsert)
self.collectionView.insertItems(at: indexPathsToInsert)
} completion: { [weak self] _ in
guard let self = self else { return }
let headerIndexPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader)
headerIndexPaths.forEach { indexPath in
guard let header = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionHeader, at: indexPath) as? HeaderView else { fatalError() }
let section = self.sectionedStubs[indexPath.section]
header.configure(with: "\(section.category) \(section.characters.count)")
}
}
}
@objc private func didTapControl() {
sectionedStubs = segmentedControl.selectedUniverse.sectionedStubs
}
@objc private func didTapShuffle() {
sectionedStubs = sectionedStubs.shuffled().map({ charactor in
return SectionCharacters(category: charactor.category, characters: charactor.characters.shuffled())
})
}
}
SingleSectionCharactersViewController
와 달리 섹션이 여러 개인 SectionCharacter
를 컬렉션 뷰의 데이터 소스로 사용하고 있다는 게 특징didSet
을 통해 이전 값과 현재 값을 컬렉션 뷰를 업데이트하는 함수에 파라미터로 자동으로 넘겨주기extension MultipleSectionCharactersViewController: UICollectionViewDataSource {
func numberOfSections(in collectionView: UICollectionView) -> Int {
return sectionedStubs.count
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return sectionedStubs[section].characters.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CharacterCell.identifier, for: indexPath) as? CharacterCell else { fatalError() }
let character = sectionedStubs[indexPath.section].characters[indexPath.item]
cell.configure(with: character)
return cell
}
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: HeaderView.identifier, for: indexPath) as? HeaderView else { fatalError() }
let section = sectionedStubs[indexPath.section]
header.configure(with: "\(section.category) \(section.characters.count)".uppercased())
return header
}
}
extension MultipleSectionCharactersViewController: UICollectionViewDelegate {
}
extension MultipleSectionCharactersViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
let header = HeaderView()
let section = sectionedStubs[section]
header.configure(with: "\(section.category) \(section.characters.count)".uppercased())
return header.systemLayoutSizeFitting(.init(width: collectionView.bounds.width, height: UIView.layoutFittingExpandedSize.height), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
}
}
사실
Diffable DataSource
를 쓰면 위와 같은 배치 업데이트 함수를 구현하기 전 단계 만큼 공수를 들일 필요가 없을 것 같긴 하지만, 그 이전 단계 또한 공부해보자!