개발/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 구현을 할수 있었다.