개발/Swift
Compositional Layout+ Diffable Datasource 활용 가이드
덤벨로퍼
2024. 8. 27. 18:33
Section 영역 은 3가지 타입이며
enum 내부에서 각각 레이아웃을 들고 있도록 하여 viewcontroller의 복잡도를 낮추었다.
public enum FAQSection: Hashable {
case tag
case faq
case bottomGuide
public var layoutSize: NSCollectionLayoutSection {
switch self {
case .tag:
let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(50),
heightDimension: .absolute(36))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .absolute(36))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
group.interItemSpacing = .fixed(8)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 8
section.contentInsets = .init(top: 12, leading: 16, bottom: 12, trailing: 16)
let background = NSCollectionLayoutDecorationItem.background(elementKind: FAQBackgroundDecorationView.id)
section.decorationItems = [background]
return section
case .faq:
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1),
heightDimension: .estimated(48)))
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .estimated(48))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
group.interItemSpacing = .fixed(1)
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = .init(top: 8, leading: 0, bottom: 8, trailing: 0)
return section
case .bottomGuide:
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1),
heightDimension: .absolute(154)))
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .absolute(154))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitem: item, count: 1)
let section = NSCollectionLayoutSection(group: group)
return section
}
}
}
- NSCollectionLayoutDecorationItem.background 를 통해 특정 섹션에만 다른 백그라운드 색상을 가지고있도록 함
- tag는 너비가 동적인 셀들이 flow하게 나열되도록 (디바이스 너비에서 아래 줄로 위치) 함
- faq는 리스트 타입이고 동적인 높이를 가짐
- bottomguide는 1개 셀만 존재
public enum FAQCellData: Hashable {
case tag(tag: any FAQTagDisplayable, isSelected: Bool)
case faq(item: any FAQItemDisplayable, isCollapsed: Bool)
case bottomGuide
var id: String {
switch self {
case .tag: return FAQTagCell.id
case .faq: return FAQItemCell.id
case .bottomGuide: return FAQBottomGuideCell.id
}
}
public func hash(into hasher: inout Hasher) {
switch self {
case let .faq(item, _): hasher.combine(item)
case let .tag(tag, _): hasher.combine(tag)
default: return
}
}
public static func == (lhs: FAQCellData, rhs: FAQCellData) -> Bool {
switch (lhs, rhs) {
case let (.tag(lhsTag, lhsSelected), .tag(rhsTag, rhsSelected)):
return lhsTag.id == rhsTag.id && lhsSelected == rhsSelected
case let (.faq(lhsItem, lhsSelected), .faq(rhsItem, rhsSelected)):
return lhsItem.id == rhsItem.id && lhsSelected == rhsSelected
default: return false
}
}
}
protocol FAQCellProtocol: AnyObject {
func apply(cellData: FAQCellData)
}
item 으로 Cell Data 를 사용 하여 Cell 을 그릴때 어떤 Cell사용할지 활용되도록 함
associatedValue Enum 타입으로 필요한 데이터 타입을 전달해줌
FAQTagDisplayable 같은 protocol 타입을 전달하기 떄문에 Hashable 메소드들을 정의해줌
== 같은 경우 정확히 비교를 해줘야 hashable의 장점 (속도) 를 가져갈수 있음
VM
public let snapshot: Observable<NSDiffableDataSourceSnapshot<FAQSection, FAQCellData>>
snapshot = Observable.combineLatest(selectedTagId, tagList, selectedFaqList, expandedFAQSet)
.map({ selectedTagId, tagList, faqList, expandedFAQSet in
var snapshot = NSDiffableDataSourceSnapshot<FAQSection, FAQCellData>()
snapshot.appendSections([.tag])
let tagCells = tagList.map { tag in
FAQCellData.tag(tag: tag, isSelected: tag.id == selectedTagId)
}
snapshot.appendItems(tagCells, toSection: .tag)
snapshot.appendSections([.faq])
let faqCells = faqList.map { faqItem in
FAQCellData.faq(item: faqItem, isCollapsed: !expandedFAQSet.contains { $0 == faqItem.id })
}
snapshot.appendItems(faqCells, toSection: .faq)
snapshot.appendSections([.bottomGuide])
snapshot.appendItems([.bottomGuide], toSection: .bottomGuide)
return snapshot
})
snapshot 을 VC에서 옵져빙하여 사용할수 있도록 함
스냅샷 데이터 구성은 비교적 쉽게 구현 가능
VC
viewModel.snapshot
.observe(on: MainScheduler.instance)
.bind { [weak self] snapshot in
let currentOffset = self?.collectionView.contentOffset
self?.diffableDataSource?.apply(snapshot, animatingDifferences: false)
if let currentOffset = currentOffset {
self?.collectionView.setContentOffset(currentOffset, animated: false)
}
}.disposed(by: disposeBag)
snapshot 바인딩 하여서 데이터소스에 적용, 적용하면 컨텐츠 길이변화 때문에 스크롤위치가 이상하여 스크롤 위치 보존하는 로직 필요함
diffableDataSource = UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: {
collectionView, indexPath, item in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: item.id, for: indexPath)
(cell as? FAQCellProtocol)?.apply(cellData: item)
if let cell = cell as? FAQItemCell {
cell.tapHandler = { [weak self] id in
self?.viewModel.openOrCollapseFAQ(id: id)
}
}
if let cell = cell as? FAQBottomGuideCell {
cell.tapHandler = {[weak self] in
AnalyticsHelper.shared.log(.touchFAQInquiryButton)
self?.coordinator.presentQuestionVC()
}
}
return cell
})
CellProvider 적용 부분, apply 공통 적용 가능 하도록 모든 cell이 FAQCellProtocol 준수하도록 구현됨
item.id 는 아까 enum 에서 구현되었으므로 item 마다 필요한 cell 타입을 가져다 쓸수 있음
Section 과 Item 정의가 잘 되어있으면 비교적 짧고 간단하게
VC와 VM 구현을 할수 있었다.