2023. 11. 4. 11:04ㆍUIKit, SwiftUI, H.I.G
0️⃣ Diffable Datasource? DataSource?
UITableView와 UICollectionView를 사용할 때, 공통적으로 사용하는 두 객체가 있다.
그것은 바로 Delegate와 DataSource.
잠깐 Delegate와 Datasource의 개념을 복습하고 지나가보자면,
MVC(Model-View-Controller) 프로그래밍 디자인 패턴에서 이 객체들을 바라봤을 때,
뷰를 그리기 위해 필요한 데이터를 제공하는 모델(M)의 역할은 DataSource가, 화면을 처리하는 뷰(V)의 역할은 테이블뷰 인스턴스가, 뷰의 모양과 동작을 관리하는 컨트롤러(C)의 역할은 Delegate가 담당한 셈이라고 이해하면 된다.
오늘 이 글에서는 데이터를 제공하는 DataSource 중에서도 (제공되는 데이터가) 달라질 수 있는 "Diffable"한 DataSource,
즉 UITableViewDiffableDataSource와 UICollectionViewDiffableDataSource에 대해 알아보게 될 것이다.
"Diffable하다는 것"은 테이블뷰나 컬렉션뷰에 들어가는 데이터의 내용이 달라질 수 있다는 것을 의미한다.
어? 근데 원래 기본 DataSource를 사용했어도 데이터 내용이 달라지는 것을 처리해 줄 수 있지 않았던가?
맞다. 처리를 해줄 수 있었다.
reloadData()나 performBatchUpdates()를 호출해서 뷰에게 모델이 변경되었다는 것은 전달해주고, 해당 모델에 맞게 다시 뷰를 그려주는 방법을 이전까지는 사용했을 거다.
이 방법보다 더 빠르고, 간결하고, indexPath를 사용하지 않아도 되고, 애니메이션까지 자동으로 적용 (reloadData를 이용하면 애니메이션이 적용되지 않고, 사용자 경험을 떨어뜨린다고 함. 아래 gif를 확인해 보자) 시켜주는 새로운 방법이 iOS 13부터 등장하게 된 것이다.
그럼 한번 어떤 점에서 다른지 바로 코드로 적용시켜보자.
지난 글에서 다뤘던 SearchBar 입력에 따라 특정 Cell을 바꿔서 표출시키던 내용을 DiffableDataSource로 Refactoring 하는 식으로 글을 전개해 보겠다.
나는 UITableView를 기반으로 만들었는데, UICollectionView를 사용하더라도 DiffableDataSource의 내용은 거의 동일하니 이 글과 같은 과정으로 만들어줘도 되겠다.
SearchController의 개념은 지난 아래 글에서 잘 작성해 두었으니, 모르겠다면 들어가서 확인하길 바란다.
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하다.)
+ enum에 붙어있는 CaseIterable에 대한 자세한 설명이 보고싶다면, 2024.08.05 - [Swift, iOS Foundation] - [Swift] 자네 열거형(enum)을 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는 snapshot과 apply() 메서드를 활용한다.
처음 화면을 표출(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)
}
}