관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] UITableViewDiffableDataSource 사용 방법 (아이템 업데이트, header, moveRowAt) 본문

iOS 응용 (swift)

[iOS - swift] UITableViewDiffableDataSource 사용 방법 (아이템 업데이트, header, moveRowAt)

jake-kim 2023. 4. 22. 22:46

MVVM형태 + Diffable Data Source로 구현

Diffable DataSoure 사용 개념

* 기본 개념은 이전 포스팅 글 참고

  • 데이터 소스 관리
    • UI를 업데이트 하는 곳인 ViewController에서 dataSource를 가지고 있고, 이 데이터는 UI에 표시되는 데이터만 사용
    • ViewModel을 사용한다면 여기서 실제 items를 들고 있고, 값이 변경될때 ViewController에 모든 아이템들을 전달해주고 업데이트 해달라고 요쳥 시 자동으로 변경해줌

Diffable DataSource가 좋은 이유

  • 애니메이션 처리가 안전(크래시를 줄일 수 있음)
    • UITableDataSource를 사용하고 변경된 부분에 대해서 애니메이션을 적용하려면 performBatchUpdates()나 beginUpdates()와 endupdates() 사이에 데이터 변경 작업을 작성하여 처리하지만, 이때 타이밍 상 거의 동시에 performBatchUpdates()가 여러곳에서 불리면 index 관련 크래시가 발생
    • diffable을 사용하면 index로 접근하는게 아닌 hashable로 접근하기 때문에 index 관련 크래시 발생 x
  • items관련하여 변경사항에 대해서 일일이 비교하거나 indexPath로 접근하지 않고 단순히 모든 배열을 dataSource에 던져주면 알아서 변경된 부분을 업데이트함

사용 방법

  • 사용한 cocoa pod
    • MVVM의 대표적인 형태인 ReactorKit 사용
pod 'ReactorKit'
pod 'RxSwift'
pod 'RxCocoa'
pod 'SnapKit'
pod 'Then'
  • UITableViewDiffableDataSource를 사용하기 위해 별도의 클래스로 추가
    • 별도의 클래스로 추가하는 이유: 테이블 뷰에서 편집 모드에서 셀 이동시키는 기능을 구현하려면 이곳에서 오버라이딩하여 구현해야 하기 때문에 이 기능을 위해 따로 빼놓기
    • extension으로 update하는 로직 구현 (snapshot에서 모든 아이템을 지우고, 변경되거나 추가되거나 삭제된 아이템을 포함한 모든 아이템을 넣어주면 알아서 반영됨)
//  MyDataSource.swift

final class MyDataSource: UITableViewDiffableDataSource<Int, MyItem> {
    
    // 편집모드 > 셀 이동 처리
//    override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
//        <#code#>
//    }
//    override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
//        <#code#>
//    }
}

extension MyDataSource {
    func update(items: [MyItem]) {
        var snapshot = snapshot()
        snapshot.deleteAllItems()
        snapshot.appendSections([0])
        snapshot.appendItems(items)
        apply(snapshot)
    }
}
  • ViewController 쪽에서 MyDataSource 초기화
    • 여기서 tableView를 넣어주기 때문에 별도의 tableView.dataSource = dataSource와 같은 코드는 필요 x
init() {
    self.dataSource = MyDataSource(tableView: tableView, cellProvider: { tableView, indexPath, itemIdentifier in
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = itemIdentifier.value.description
        return cell
    })
    super.init(nibName: nil, bundle: nil)
    
    setupViews()
    setupLayout()
}
  • ViewController에서 reactor쪽으로 이벤트 전달
// MARK: Bind
func bind(reactor: MyReactor) {
    // Action
    Observable
        .just(MyReactor.Action.load)
        .bind(to: reactor.action)
        .disposed(by: disposeBag)
    
    appendButton.rx.tap
        .throttle(.milliseconds(300), scheduler: MainScheduler.instance)
        .map { Reactor.Action.append }
        .bind(to: reactor.action)
        .disposed(by: disposeBag)
    
    removeButton.rx.tap
        .throttle(.milliseconds(300), scheduler: MainScheduler.instance)
        .map { Reactor.Action.remove }
        .bind(to: reactor.action)
        .disposed(by: disposeBag)
    
    removeCenterButton.rx.tap
        .throttle(.milliseconds(300), scheduler: MainScheduler.instance)
        .map { Reactor.Action.removeCenter }
        .bind(to: reactor.action)
        .disposed(by: disposeBag)
}
  • reactor쪽에서는 단순히 아이템을 받아서 업데이트 후 최종 items 배열을 변경
func mutate(action: Action) -> Observable<Mutation> {
    switch action {
    case .load:
        return .just(.setItems([.init(value: 0), .init(value: 1), .init(value: 2), .init(value: 3), .init(value: 4)]))
    case .append:
        return .just(.appendItem)
    case .remove:
        return .just(.removeItem)
    case .removeCenter:
        return .just(.removeCenter)
    }
}

func reduce(state: State, mutation: Mutation) -> State {
    var state = state
    switch mutation {
    case let .setItems(array):
        state.items = array
    case .appendItem:
        state.items.append(.init(value: state.items.count))
    case .removeItem:
        guard !state.items.isEmpty else { break }
        state.items.removeLast()
    case .removeCenter:
        guard !state.items.isEmpty else { break }
        let centerIndex = (state.items.count - 1) / 2
        state.items.remove(at: centerIndex)
    }
    return state
}
  • ViewController쪽에서는 items를 관찰하고 있다가 items 전체를 다시 update하면 자동으로 반영
// State
reactor.state.map(\.items)
    .distinctUntilChanged()
    .observe(on: MainScheduler.instance)
    .bind(with: self) { ss, items in
        ss.dataSource.update(items: items)
    }
    .disposed(by: self.disposeBag)

(완료)

MVVM형태 + Diffable Data Source로 구현 완료

HeaderView 사용 방법

  • UITableView에서 사용 방법
    • 프로퍼티인 sectionHeaderHeight값을 지정해주고 DataSource에서 viewForHeaderInSection 메소드에서 HeaderView를 반환하여 적용
  • Diffable DataSource에서 사용 방법
    • UITableViewDataSource를 사용하지 않고, Diffable DataSource에서도 viewForHeaderInSection 메소드가 없기 때문에 UITableView 프로퍼티에 대입해줘야함
let headerView = UIView()
view.tableHeaderView = headerView
headerView.frame.size.height = 100

정리

  • Diffable DataSource 장점
    • performBatchUpdates()는 크래시가 많이 나지만 Diffable DataSource를 사용하면 해결가능
    • 변경된 사항에 대해서 item을 Diffable DataSource로 관리하면 매우 쉽게 처리가 가능 (변경사항에 대해서 일일이 비교 없이 모든 items를 Diffable DataSource에 던져주기만 해도 반영됨)
  • Diffable DataSource 단점
    • 편집모드에서 셀을 이동시키는 기능을 만들고 싶은 경우, DataSource 클래스를 따로 만들고 기존에 UITableViewDelegate에 있던 canMoveRowAt와moveRowAt을 이 DataSource에서 override해서 구현해야함

* 전체 코드: https://github.com/JK0369/ExDiffableDataSource

Comments