[UIKit] UICollectionView: Batch Update

Junyoung Park·2022년 12월 4일
0

UIKit

목록 보기
110/142
post-thumbnail

UICollectionView From Scratch [3] - Data Diffing | Batch Updates | Multiple Sections

UICollectionView: Batch Update

구현 목표

  • 업데이트 이전 데이터 / 이후 데이터 간의 차이를 통해 컬렉션 뷰 배치 업데이트 구현

구현 태스크

  • 변경 내역 데이터를 통한 인덱스패스 구분
  • 섹션 비교를 통한 인덱스패스 구분
  • 삭제 및 삽입 인덱스패스를 통한 컬렉션 뷰 배치 업데이트 구현

핵심 코드

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를 업데이트
  • 각 아이템이 속한 섹션 인덱스 또한 변경되었을 수 있으므로 oldSectionIndexindex를 통해 새로운 인덱스 패스를 만들고 각 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())
        })
    }
}
  • 섹션이 1개였던 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를 쓰면 위와 같은 배치 업데이트 함수를 구현하기 전 단계 만큼 공수를 들일 필요가 없을 것 같긴 하지만, 그 이전 단계 또한 공부해보자!

profile
JUST DO IT

0개의 댓글