[UIKit] Modern Collection View: Diffable DataSource

Junyoung Park·2022년 12월 6일
0

UIKit

목록 보기
113/142
post-thumbnail
post-custom-banner

Modern Collection View [3] - Intro to Diffable Data Source | Search with Combine

Modern Collection View: Diffable DataSource

구현 목표

  • Diffable DataSource를 사용한 컬렉션 뷰 구현

구현 태스크

  • Diffable DataSource 구현
  • Snapshot 함수 구현
  • 서치 바 업데이트 델리게이트 컴바인 연결
  • 텍스트에 따른 데이터 필터링 및 정렬 자동 완성

핵심 코드

    private func setupCollectionView() {
        collectionView = .init(frame: view.bounds, collectionViewLayout: listLayout)
        collectionView.backgroundColor = .systemBackground
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(collectionView)
        
        cellRegistration = UICollectionView.CellRegistration(
            handler: { (cell: UICollectionViewListCell, _, character: Character) in
                var content = cell.defaultContentConfiguration()
                content.text = character.name
                content.secondaryText = character.job
                content.image = UIImage(named: character.imageName)
                content.imageProperties.maximumSize = .init(width: 60, height: 60)
                content.imageProperties.cornerRadius = 30
                cell.contentConfiguration = content
            })
        
        headerRegistration = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader, handler: { [weak self] (header: UICollectionViewListCell, _, indexPath) in
            guard let self = self else { return }
            self.configureHeader(header, at: indexPath)
        })
        
        dataSource = .init(collectionView: collectionView, cellProvider: { [weak self] (collectionView, indexPath, item) -> UICollectionViewCell? in
            guard let self = self else { return nil }
            let cell = collectionView.dequeueConfiguredReusableCell(using: self.cellRegistration, for: indexPath, item: item)
            return cell
        })
        dataSource.supplementaryViewProvider = { [weak self] (collectionView, kind, indexPath) -> UICollectionReusableView in
            guard let self = self else { return UICollectionReusableView() }
            let header = collectionView.dequeueConfiguredReusableSupplementary(using: self.headerRegistration, for: indexPath)
            return header
        }
    }
  • 데이터 소스를 이니셜라이즈할 때 미리 클로저 형식으로 선언해둔 헤더 및 셀 등록 변수를 그대로 넣어줄 수 있음
  • 헤더 뷰 또한 데이터 소스의 프로퍼티에 그대로 클로저로 넣기
    private func setupSnapshot(store: [SectionCharactersTuple]) {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Character>()
        store.forEach { sectionCharacter in
            let (section, characters) = sectionCharacter
            snapshot.appendSections([section])
            snapshot.appendItems(characters, toSection: section)
        }
        dataSource.apply(snapshot, animatingDifferences: true)
        reloadHeaders()
    }
  • 현재 스냅샷과 새로운 스냅샷 간의 데이터 해시를 비교, 서로 다른 값만을 Diffable하게 처리하기 위한 스냅샷 적용 함수
  • 현 상황에서는 스냅샷을 적용할 때마다 새로운 스냅샷을 적용 중
    private func reloadHeaders() {
        collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader).forEach { [weak self] indexPath in
            guard let header = collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionHeader, at: indexPath) as? UICollectionViewListCell else { return }
            self?.configureHeader(header, at: indexPath)
        }
    }
  • 현재 컬렉션 뷰의 데이터 소스가 가지고 있는 스냅샷은 데이터를 포함하고 있는 인스턴스. 해당 정보를 통해 헤더의 타이틀 정보 등을 다시 한 번 리로드
    @objc private func shuffleTapped() {
        backingStore = backingStore.shuffled().map({ ($0.section, $0.characters.shuffled()) })
        setupSnapshot(store: backingStore)
    }
  • 셔플 탭을 눌렀을 때 섹션과 섹션 내 캐릭터 데이터를 모두 셔플한 채 새롭게 스냅샷을 업로드
    @objc private func resetTapped() {
        backingStore = segmentedControl.selectedUniverse.sectionedStubs
        setupSnapshot(store: backingStore)
    }
  • 기존 순서대로 복구
    private func configureHeader(_ headerView: UICollectionViewListCell, at indexPath: IndexPath) {
        guard
            let model = dataSource.itemIdentifier(for: indexPath),
            let section = dataSource.snapshot().sectionIdentifier(containingItem: model) else { return }
        let count = dataSource.snapshot().itemIdentifiers(inSection: section).count
        var content = headerView.defaultContentConfiguration()
        content.text = section.headerTitleText(count: count)
        headerView.contentConfiguration = content
    }
  • 스냅샷이 가지고 있는 섹션, 모델 등을 통해 새로운 헤더 타이틀을 적용
func updateSearchResults(for searchController: UISearchController) {
        searchText.send(searchController.searchBar.text ?? "")
    }
  • 네비게이션 아이템의 서치 컨트롤러에 사용한 업데이트 함수
  • 텍스트를 검색할 때마다 자동으로 해당 퍼블리셔에 텍스트 값을 보내 자동으로 컬렉션 뷰 UI를 업데이트하는 구조
private func setupSearchTextObserver() {
        searchText
            .debounce(for: .seconds(0.1), scheduler: RunLoop.main)
            .map({ $0.lowercased() })
            .map(filterAndSortData)
            .sink(receiveValue: { [weak self] store in
                self?.setupSnapshot(store: store)
            })
            .store(in: &cancellables)
    }
  • 해당 옵저버를 통해 서치 텍스트 필드의 값을 통해 컬렉션 뷰를 새롭게 업데이트 가능
  • 디바운스, 맵 등 고차 함수를 통해 보다 컴바인 스타일을 활용
private func filterAndSortData(text: String) -> [SectionCharactersTuple] {
        guard !text.isEmpty else { return segmentedControl.selectedUniverse.sectionedStubs }
        let filteredAndSortedData = backingStore.map { section, characters -> SectionCharactersTuple in
            let characters = characters
                .sorted(by: {$0.name < $1.name })
                .filter({($0.name.lowercased().contains(text))})
            return (section, characters)
        }
            .filter({!$0.characters.isEmpty})
        return filteredAndSortedData
    }
  • 텍스트가 비었다면 전체 데이터를, 텍스트가 존재한다면 캐릭터 이름이 일치하는 데이터를 필터링 및 정렬한 결과값으로 새롭게 스냅샷 업데이트. 해당 스냅샷을 업데이트할 때 자동으로 헤더 또한 업데이트.

구현 화면

CellRegisttion 등과 함께 컬렉션 뷰를 쓰는 게 매우 깔끔하다! 스냅샷을 적용할 때 또한 별도의 데이터 소스에 직접 접근하기보다도, 주어진 컬렉션 뷰에 데이터를 주는 데이터 소스의 스냅샷으로부터 정보를 얻어오는 방식을 다시 한 번 체크!

profile
JUST DO IT
post-custom-banner

0개의 댓글