[UITableViewDiffableDataSource] Diffable Datasource, 데이터가 달라졌을 때 View에 효율적으로 반영하는 방법

2023. 11. 4. 11:04UIKit, H.I.G

728x90

0️⃣ Diffable Datasource? DataSource?


UITableView와 UICollectionView를 사용할 때, 공통적으로 사용하는 두 객체가 있다.
그것은 바로 Delegate와 DataSource.

잠깐 Delegate와 Datasource의 개념을 복습하고 지나가보자면,
MVC(Model-View-Controller) 프로그래밍 디자인 패턴에서 이 객체들을 바라봤을 때,
뷰를 그리기 위해 필요한 데이터를 제공하는 모델(M)의 역할은 DataSource가, 화면을 처리하는 뷰(V)의 역할은 테이블뷰 인스턴스가, 뷰의 모양과 동작을 관리하는 컨트롤러(C)의 역할은 Delegate가 담당한 셈이라고 이해하면 된다.

오늘 이 글에서는 데이터를 제공하는 DataSource 중에서도 (제공되는 데이터가) 달라질 수 있는 "Diffable"한 DataSource,
즉 UITableViewDiffableDataSource와 UICollectionViewDiffableDataSource에 대해 알아보게 될 것이다.
 

UITableViewDiffableDataSource | Apple Developer Documentation

The object you use to manage data and provide cells for a table view.

developer.apple.com

 

UICollectionViewDiffableDataSource | Apple Developer Documentation

The object you use to manage data and provide cells for a collection view.

developer.apple.com

 

"Diffable하다는 것"은 테이블뷰나 컬렉션뷰에 들어가는 데이터의 내용이 달라질 수 있다는 것을 의미한다.

어? 근데 원래 기본 DataSource를 사용했어도 데이터 내용이 달라지는 것을 처리해 줄 수 있지 않았던가?
맞다. 처리를 해줄 수 있었다.
reloadData()performBatchUpdates()를 호출해서 뷰에게 모델이 변경되었다는 것은 전달해주고, 해당 모델에 맞게 다시 뷰를 그려주는 방법을 이전까지는 사용했을 거다.

이 방법보다 더 빠르고, 간결하고, indexPath를 사용하지 않아도 되고, 애니메이션까지 자동으로 적용 (reloadData를 이용하면 애니메이션이 적용되지 않고, 사용자 경험을 떨어뜨린다고 함. 아래 gif를 확인해 보자) 시켜주는 새로운 방법이 iOS 13부터 등장하게 된 것이다.

왼쪽은 reloadData()를 이용한 처리, 오른쪽은 DiffableDataSource를 이용한 처리 모습이다.

 

그럼 한번 어떤 점에서 다른지 바로 코드로 적용시켜보자.
지난 글에서 다뤘던 SearchBar 입력에 따라 특정 Cell을 바꿔서 표출시키던 내용을 DiffableDataSource로 Refactoring 하는 식으로 글을 전개해 보겠다.
나는 UITableView를 기반으로 만들었는데, UICollectionView를 사용하더라도 DiffableDataSource의 내용은 거의 동일하니 이 글과 같은 과정으로 만들어줘도 되겠다.

SearchController의 개념은 지난 아래 글에서 잘 작성해 두었으니, 모르겠다면 들어가서 확인하길 바란다. 

 

[iOS] UISearchController를 이용해서 TableView의 특정 Cell을 표출하는 SearchBar

1️⃣ Intro 이번 글에서는 요즘 과제로 하고 있는 아이폰 기본 "날씨" 앱을 클론 코딩하면서 알게 된 SearchController와 SearchBar에 대한 내용을 정리해 보겠다. 내가 구현하고 싶었던 내용은 아래 내용

mini-min-dev.tistory.com

TableView이건 CollectionView이건 DiifableDataSource를 활용해서 View를 그리는 과정은 동일하다.
애플 개발자 공식문서에서 소개해주는 과정은 아래와 같고, 이 글에서도 아래 과정에 맞게 코드를 추가하며 설명해 보겠다.


1. Connect a diffable data source to your table(collection) view.
-> 그리고 싶은 TableView 혹은 CollectionView를 DiffableDataSource와 연결(connect)시킨다.

2. Implement a cell provider to configure your table(collection) view’s cells.
-> TableView 혹은 CollectionView에 표출할 Cell의 내용과 레이아웃을 구성해 둔다.

3. Generate the current state of the data. -> 데이터의 현재 상태를 생성한다.

4. Display the data in the UI. -> UI에 데이터를 표출한다.

 

1️⃣ UITableViewDiffableDataSource를 생성해주자


DiffableDataSource는 일반 DataSource처럼 Protocol이 아니라, Generic Class 타입이다.
Generic이란 타입에 의존하지 않는 범용 코드를 작성할 때 사용하는 Swift의 기능이며, 표준 라이브러리의 대부분이 Generic을 사용해서 선언되어 있다.

변수로 DiffableDataSource를 선언해 주면 되고, <Section Identifier Type, Item Identifier Type>의 형태로 적절한 타입을 생성해 주면 된다.
여기서 중요한 점! 두 타입은 모두 Hashable을 준수하는 타입이 들어가야 한다.

var dataSource: UITableViewDiffableDataSource<Section, WeatherLocation>!

Section Identifier Type 값에 들어갈 Section enum형을 만들어줬다.

그냥 Int값 (날씨앱은 하나의 섹션으로 구성했으니 0만)을 넣어주면 될걸, 왜 enum형으로 생성해 주는 거냐고 묻는다면,
개발자도 사람이기에 발생할 수 있는 접근 에러를 방지(= 값을 안전하게 가져오기 위함) 하기 위함도 있고, 연관값이 없는 enum은 항상 Hashable하다는 특성을 갖기 때문에 모든 타입이 Hashable 해야 한다는 DiffableDataSource의 제한을 만족시켜 줄 수도 있게 때문이다. (모든 연관값이 Hashable하면 enum도 Hashable하다.)

*CaseIterable과 관련된 글 작성 후 밑에 추가 설명 첨부 예정

enum Section: CaseIterable {
    case main
}

Item Identifier Type값은 지난 글에서 만들어준 WeatherLocation struct를 넣어줄 거다.
달라진 점이 있다면, 이 역시도 Hashable 속성을 만족시켜주기 위해 Hashable을 struct에 채택해 줬다는 것!

struct WeatherLocation: Hashable {
    
    let location: String
    let weather: String
    let temp: Int
    let maxTemp: Int
    let minTemp: Int
    let weatherSummary: String
    let timeWeatherList: [TimeWeather]
    let indexNumber: Int
}

struct TimeWeather: Hashable {
    let time: String
    let weather: WeatherState
    let temp: Int
}

 

2️⃣ 생성한 DiffableDataSource와 Cell을 TableView(CollectionView)와 연결시켜주자!


우선, Cell을 먼저 TableView(CollectionView)에 등록해 주는 과정이 필요하다.

이후, 위에서 초기화된 TableViewDiffableDataSource에 대해 (TableView, Cell Provider)를 매개변수로 전달해 주자.
여기서의 Cell Provider는 클로저 타입의 typealias인데, (TableView, indexPath, itemIdentifier) 형태로 값을 받아오게 된다.

이 부분이 기존 DataSource에서 테이블 뷰의 dataSource를 self로 선언하고, DataSource Protocol의 메서드 중 Cell For Row At에 해당하는 부분을 구현한다고 생각하면 되겠다.

    self.mainTableView.register(MainLocationTableViewCell.self,
                                    forCellReuseIdentifier: MainLocationTableViewCell.identifier)
    
    self.dataSource = UITableViewDiffableDataSource<Section, WeatherLocation>(tableView: mainTableView) { (tableView, indexPath, location) -> UITableViewCell? in
        guard let cell = tableView.dequeueReusableCell(withIdentifier: MainLocationTableViewCell.identifier, for: indexPath) as? MainLocationTableViewCell else { return UITableViewCell() }
            
        cell.bindData(data: location, row: indexPath.row)
        cell.selectionStyle = .none
            
        return cell     
    }
        
    self.mainTableView.dataSource = dataSource

 

3️⃣ 데이터의 현재 상태 표출 + 변경되는 데이터에 대해 UI를 표출시키는 로직 구현


값이 변함에 따라 TableView에 반영을 해주기 위해 Diffable Datasource는 snapshotapply() 메서드를 활용한다.

처음 화면을 표출(View Life Cycle)할 때 사용하고, SearchBar에 입력되는 값이 변경될 때마다(updateSearchResults) 호출하는 performQuery 메서드에 snapshot과 apply()를 추가해주겠다.

snapshot은 DiffableDataSource와 유사하게, <Section Identifier Type, Item Identifier Type>을 갖고 초기화된다.
appendSections()와 appendItems() 메서드를 이용해서 각각 섹션과 Cell의 Item 요소들을 추가해주면 된다.

snapshot에 데이터가 추가되었으면, 이를 dataSource.apply 메서드로 추가해주기만 하면 적용이 된다.

이 부분이 기존 reloadData()와 Delegate를 통해 값의 변화를 View에다가 전달해 주던 부분이라고 생각하면 되겠다.

func performQuery(with filter: String?) {
        self.filteredLocationData = dummyLocationData.filter { return $0.location.lowercased().contains(filter ?? "".lowercased()) }
        
        var snapshot = NSDiffableDataSourceSnapshot<Section, WeatherLocation>()
        snapshot.appendSections([.main])
        if self.isFilterting {
            snapshot.appendItems(filteredLocationData)
        } else {
            snapshot.appendItems(dummyLocationData)
        }
        self.dataSource.apply(snapshot, animatingDifferences: true)
    }
// SearchController의 Delegate
func updateSearchResults(for searchController: UISearchController) {
        guard let text = searchController.searchBar.text else { return }
        performQuery(with: text)
    }

 

4️⃣ 전체 코드 살펴보기!


마찬가지로 전체적인 흐름을 확인하기 위해 전체 코드를 첨부하겠다.
WeatherLocation 자료형이나 Cell 구성 부분은 따로 첨부하지 않겠다. SearchBar에 대한 설명은 지난 글에서 확인할 수 있다.

//
//  MainViewController.swift
//  iOS-Assignment-Weather-Clone
//
//  Created by 민 on 10/14/23.
//

import UIKit
import SnapKit
import Then

protocol LocationSelectedProtocol: NSObject {
    func dataSend(data: [TimeWeather])
}

enum Section: CaseIterable {
    case main
}

final class MainViewController: UIViewController {
    
    // MARK: - Properties
    var delegate: LocationSelectedProtocol?
    var filteredLocationData = [WeatherLocation]()
    var dataSource: UITableViewDiffableDataSource<Section, WeatherLocation>!
    
    // MARK: - UI Components
    private let searchController = UISearchController()
    private let mainContentView = UIView()
    private let mainTableView = UITableView(frame: .zero, style: .insetGrouped).then {
        $0.backgroundColor = .black
    }
    
    // MARK: - View Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.setupUI()
        self.setTableViewConfig()
        self.performQuery(with: nil)
    }
}

// MARK: - Extensions
extension MainViewController {
    // UI 세팅
    private func setupUI() {
        
        setupLayout()
        setNavigationBar()
        setSearchBar()
    }
    
    // 레이아웃 세팅
    private func setupLayout() {
        self.view.addSubViews(mainTableView)
        
        mainTableView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
    }
    
    private func setNavigationBar() {
        navigationController?.navigationBar.prefersLargeTitles = true
        navigationController?.navigationBar.barTintColor = .black
        navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor.white]
        navigationItem.title = "Weather"
        navigationController?.navigationBar.largeTitleTextAttributes = [.foregroundColor: UIColor.white]
        navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(named: "moreIcon"),
                                                            style: .plain,
                                                            target: nil,
                                                            action: nil)
        navigationItem.rightBarButtonItem?.tintColor = .white
        navigationItem.hidesSearchBarWhenScrolling = false
    }
    
    private func setSearchBar() {
        navigationItem.searchController = searchController
        navigationItem.searchController?.searchBar.placeholder = "Search for a city or airport"
        navigationItem.searchController?.searchResultsUpdater = self
        
        let textFieldInsideSearchBar = navigationItem.searchController?.searchBar.value(forKey: "searchField") as? UITextField
        textFieldInsideSearchBar?.textColor = .white
    }
    
    private func setTableViewConfig() {
        self.mainTableView.register(MainLocationTableViewCell.self,
                                    forCellReuseIdentifier: MainLocationTableViewCell.identifier)
        self.mainTableView.delegate = self
        
        // self.mainTableView.dataSource = self
        self.dataSource = UITableViewDiffableDataSource<Section, WeatherLocation>(tableView: mainTableView) { (tableView, indexPath, location) -> UITableViewCell? in
            guard let cell = tableView.dequeueReusableCell(withIdentifier: MainLocationTableViewCell.identifier, for: indexPath) as? MainLocationTableViewCell else { return UITableViewCell() }
            
            cell.bindData(data: location, row: indexPath.row)
            cell.selectionStyle = .none
            
            return cell
        }
        
        self.mainTableView.dataSource = dataSource
    }
    
    func performQuery(with filter: String?) {
        self.filteredLocationData = dummyLocationData.filter { return $0.location.lowercased().contains(filter ?? "".lowercased()) }
        
        // self.mainTableView.reloadData()
        var snapshot = NSDiffableDataSourceSnapshot<Section, WeatherLocation>()
        snapshot.appendSections([.main])
        if self.isFilterting {
            snapshot.appendItems(filteredLocationData)
        } else {
            snapshot.appendItems(dummyLocationData)
        }
        self.dataSource.apply(snapshot, animatingDifferences: true)
    }
}

extension MainViewController: UISearchResultsUpdating {
    var isFilterting: Bool {
        let searchController = self.navigationItem.searchController
        let isActive = searchController?.isActive ?? false
        let isSearchBarHasText = searchController?.searchBar.text?.isEmpty == false
        
        return isActive && isSearchBarHasText
    }
    
    func updateSearchResults(for searchController: UISearchController) {
        guard let text = searchController.searchBar.text else { return }
        performQuery(with: text)
    }
}

// MARK: - TableView Delegate
extension MainViewController: UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 133
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let detailPageVC = DetailPageViewController()
        
        for index in 0 ..< dummyLocationData.count {
            let detailVC = DetailViewController()
            detailVC.indexNumber = index
            detailPageVC.viewControllersArray.append(detailVC)
        }
        
        let firstVC = detailPageVC.viewControllersArray[indexPath.row]
        detailPageVC.pageVC.setViewControllers([firstVC], direction: .forward, animated: true)
        
        self.navigationController?.pushViewController(detailPageVC, animated: true)
    }
}

 

5️⃣ Reference


 

[iOS - swift] 1. Diffable Data Source - UITableViewDiffableDataSource (테이블 뷰)

1. Diffable Data Source - UITableViewDiffableDataSource (테이블 뷰) 2. Diffable Data Source - UICollectionViewDiffableDataSource (컬렉션 뷰) 원리 기존 Controller와 UI의 관계 UI가 Controller에게 cell의 모양(cellForRowAt), cell의 개

ios-development.tistory.com

 

[CollectionView] Diffable DataSource 이해하기 (1/3) - Advances in UI Data Sources (WWDC19)

CollectionView / TableView와 관련해서 "Diffable DataSource" 개념이 등장했다. 러닝커브가 조금 있는 내용이라 포스팅을 남기려고 한다. ✏️ 새로운 기술을 습득하기 가장 좋은 방법은 Apple이 만든 WWDC 영

applecider2020.tistory.com

 

Diffable Datasource

안녕하세요 :) Zedd입니다. 이거 꼭 공부해보고싶었는데! # Introducing Diffable Data Source WWDC19. Apple이 Diffable Datasource를 소개합니다. 물론 iOS 13부터 사용이 가능한 ^^.. Datasource하니까 가장 먼저 떠오르

zeddios.tistory.com

 

iOS) Diffable Data Source 알아보기

👷Diffable Data Source 란? 먼저, Diffable Data Source 가 무엇인지에 대해서 간단하게 알고 넘어가보자! TableView(또는 CollectionView)를 그리기 위한 데이터를 관리하고 UI를 업데이트 하는 역할을 한다. Data So

gyuios.tistory.com

 

728x90