Controller 에서 받은 데이터 vs UI(CollectionView) 에서 가지고 있는 데이터가 이원화 되어 관리되어서, 매번
reloadData()
를 해서 싱크를 맞춰야 했습니다. 이것을 다른 방식으로 접근해서 문제를 해결하려고 나왔습니다.
기존의 UICollectionViewDataSource 는 명령형으로 구현해야 했습니다.
예를들면, 몇 개의 아이템을 보여줄 지, 어떤 셀을 보여줄지 ... 이런 것들을 델리게이트 프로토콜 메서드를 구현해서 정해줬습니다.
이번에 새로나온 diffable datasource
는 새로 업데이트할 값과 기존에 있던 값을 비교합니다. 그리고 비교해서 다른 부분만 변경합니다.
이 동작 원리 때문에, 각 데이터들이 Hashable
해야합니다. 여기서 Hashable
해야하는 이유는, "중복되지 않는 값임이 보장되어야 함" 이라는 속성을 따라야하는 데이터만 diffable datasource
에 사용될 수 있음을 뜻합니다.
말이 어려운데 정리해보면 다음과 같습니다.
UICollectionViewDataSource
의 경우, 값이 변경할 때마다 직접 변경해주어야 했다.Diffable DataSource
의 경우, 기존값과 새로운 값을 비교해서 변경되는 부분만 알아서 변경해준다. Hashable
을 채택하고 있어야 한다.중간중간에 실수로 빼먹은 부분이 있을 수 있습니다. 그때 전체 코드 참고해서 봐주시면 감사하겠습니다 ㅜㅜ
기본적으로 라이브러리는 다음 두 개를 사용했습니다.
import UIKit
class Cat: Hashable {
var id = UUID()
var name: String
var thumbnilStr: String?
init(name: String, thumbnilStr: String? = nil) {
self.name = name
self.thumbnilStr = thumbnilStr
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func ==(lhs: Cat, rhs: Cat) -> Bool {
return lhs.id == rhs.id
}
}
extension Cat {
static let allCats = [
Cat(
name: "첫 번째 고양이",
thumbnilStr: "https://cdn2.thecatapi.com/images/0XYvRd7oD.jpg"
),
Cat(
name: "두 번째 고양이",
thumbnilStr: "https://cdn2.thecatapi.com/images/MTc5NjI5Mw.jpg"
),
Cat(
name: "세 번째 고양이",
thumbnilStr: "https://cdn2.thecatapi.com/images/MTUzMjkzMg.jpg"
),
Cat(
name: "네 번째 고양이",
thumbnilStr: "https://cdn2.thecatapi.com/images/MTcwMDE1Mw.jpg"
),
Cat(
name: "다섯 번째 고양이",
thumbnilStr: "https://cdn2.thecatapi.com/images/MTY4OTE5Mw.jpg"
),
Cat(
name: "여섯 번째 고양이",
thumbnilStr: "https://cdn2.thecatapi.com/images/ad3.jpg"
),
Cat(
name: "일곱 번째 고양이",
thumbnilStr: "https://cdn2.thecatapi.com/images/aa1.jpg"
),
Cat(
name: "여덟 번째 고양이",
thumbnilStr: "https://cdn2.thecatapi.com/images/4m2.jpg"
),
Cat(
name: "열한번 번째 고양이",
thumbnilStr: "https://cdn2.thecatapi.com/images/JBkP_EJm9.jpg"
),
Cat(
name: "열 번째 고양이",
thumbnilStr: "https://cdn2.thecatapi.com/images/1nv.jpg"
),
Cat(
name: "아홉 번째 고양이",
thumbnilStr: "https://cdn2.thecatapi.com/images/4v.jpg"
)
]
}
import UIKit
import SnapKit
final class ViewController: UICollectionViewController {
// CollectionView 에서 사용할 데이터 인스턴스를 정의한다
private var catList = Cat.allCats
private var searchController = UISearchController(searchResultsController: nil)
override func viewDidLoad() {
super.viewDidLoad()
configureCollectionView()
}
func configureCollectionView() {
collectionView.register(CollectionViewCell.self, forCellWithReuseIdentifier: CollectionViewCell.reusableIdentifier)
}
}
// MARK: - UISearchResultsUpdating Delegate
extension ViewController: UISearchResultsUpdating {
// 검색 시, 이곳이 호출한다
func updateSearchResults(for searchController: UISearchController) {}
// SearchController 기본 세팅
func configureSearchController() {
searchController.searchResultsUpdater = self
searchController.obscuresBackgroundDuringPresentation = false
searchController.searchBar.placeholder = "고양이"
navigationItem.searchController = searchController
definesPresentationContext = true
}
}
// MARK: - Layout Handling
extension ViewController {
// iOS 13 부터 사용 가능한 Compositional Layout 을 이용한다
private func configureLayout() {
collectionView.collectionViewLayout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
let isPhone = layoutEnvironment.traitCollection.userInterfaceIdiom == UIUserInterfaceIdiom.phone
let size = NSCollectionLayoutSize(
widthDimension: NSCollectionLayoutDimension.fractionalWidth(1),
heightDimension: NSCollectionLayoutDimension.absolute(isPhone ? 280 : 250)
)
let itemCount = isPhone ? 1 : 3
let item = NSCollectionLayoutItem(layoutSize: size)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: size, subitem: item, count: itemCount)
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
section.interGroupSpacing = 10
return section
})
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { context in
self.collectionView.collectionViewLayout.invalidateLayout()
}, completion: nil)
}
}
//
// CollectionViewCell.swift
// UICollectionViewDataSource Example
//
// Created by 김우성 on 2023/03/19.
//
import UIKit
import SnapKit
import SDWebImage
final class CollectionViewCell: UICollectionViewCell {
static let reusableIdentifier = "CollectionViewCell"
private let cellLabel: UILabel = {
let label = UILabel()
return label
}()
private let cellImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
return imageView
}()
override func awakeFromNib() {
super.awakeFromNib()
}
override func prepareForReuse() {
super.prepareForReuse()
}
func configure(_ cellData: Cat) {
self.addSubview(cellLabel)
cellLabel.snp.makeConstraints { make in
make.centerX.equalTo(self.snp.centerX)
make.bottom.equalTo(self.snp.bottom).inset(10)
}
self.addSubview(cellImageView)
cellImageView.snp.makeConstraints { make in
make.top.equalTo(self.snp.top).offset(10)
make.left.equalTo(self.snp.left).offset(10)
make.right.equalTo(self.snp.right).inset(10)
make.bottom.equalTo(cellLabel.snp.top).offset(0)
}
if let imageURLStr = cellData.thumbnilStr {
cellImageView.sd_setImage(with: URL(string: imageURLStr)!)
}
cellLabel.text = cellData.name
}
}
ViewController의 전역 범위에 정의합니다.
enum Section {
case main
}
타입별칭(typealias) 를 사용하지 않아도 되지만, 유독 이름이 긴 타입이므로 가능하면 사용해보려고 합니다. ;)
// ### 3. DataSource 를 정의한다
typealias DataSource = UICollectionViewDiffableDataSource<Section, Cat>
여기 코드에서 보면 dataSource 를 프로토콜로 채택하는 방식이 아니라, 인스턴스로 생성해서 결정합니다. 덕분에 비슷한 데이터 소스가 나오면 해당 데이터 소스를 주입하는 방식으로 사용하면 됩니다.
cellProvider 부분에서 어떤 UICollectionViewCell 을 쓸지, 어떤 색션에 적용할지 그리고 어떤 데이터를 적용할지 까지 결정됩니다. 이 결정되는 부분인 이전 타입 결정할 때, 제네릭으로Cat
타입을 전달해줘서 파라미터로 전달받게 됩니다.
func makeDataSource() -> DataSource {
// 데이터 소스는 어떤 컬렉션뷰에 적용될지와, 셀은 어떤식으로 생성할지를 포함한다
let dataSource = DataSource(
collectionView: collectionView,
cellProvider: { (collectionView, indexPath, cat) -> UICollectionViewCell? in
// 셀을 생성한다
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: CollectionViewCell.reusableIdentifier,
for: indexPath
) as! CollectionViewCell
return cell
}
)
return dataSource
}
private lazy var dataSource = makeDataSource()
diifable DataSource 의 경우, snapshot 이라는 특정 시점의 특정 값 비교를 통해서 값을 갱신합니다. 그래서 reloaddata 대신 어떤 것이 snapshot 인지 datasource 에게 알려주어야 합니다.
typealias SnapShot = NSDiffableDataSourceSnapshot<Section, Cat>
func applySnapshot(animatingDifferences: Bool = true) {
// 스냅샷을 생성한다
var snapshot = Snapshot()
// 스냅샷에 색션을 추가한다.
snapshot.appendSections([.main])
// 스냅샷에 섹션에 데이터를 추가한다.
snapshot.appendItems(catList, toSection: .main)
// 데이터 소스에 저장한다.
dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
override func viewDidLoad() {
super.viewDidLoad()
configureCollectionView()
configureSearchController()
configureLayout()
applySnapshot(animatingDifferences: false)
}
func filteredCats(for queryOrNil: String?) -> [Cat] {
// 전체 데이터
// 이 부분이 만약 서버라면, 검색 결과자체를 받아서 리턴
let cats = Cat.allCats
// 검색어가 nil 이거나 없으면, 전체를 리턴한다.
guard let query = queryOrNil,
!query.isEmpty else {
return cats
}
// 전체 데이터 중에서 "name" 만 비교해서 리턴한다.
return cats.filter { cat in
return cat.name.lowercased().contains(query.lowercased())
}
}
updateSearchResults
메소드는 검색동작이 할때마다 호출되는 이벤트메소드입니다. 그래서 이곳에 apply() 메소드를 호출합니다.
// 검색 시, 이곳이 호출됩니다.
func updateSearchResults(for searchController: UISearchController) {
catList = filteredCats(for: searchController.searchBar.text)
applySnapshot()
}
Hashable
프로토콜을 따랴야 한다.reloadData
에서 apply
로 변경되었다.