【Swift UIKit】UITableViewDiffableDataSourceの使い方!データの局所更新
この記事からわかること
- SwiftのUIKitでリストを実装する方法
- UITableViewを使った方法
- UITableViewDiffableDataSourceの使い方
index
[open]
\ アプリをリリースしました /
友達や家族の誕生日をメモ!通知も届く-みんなの誕生日-
posted withアプリーチ
環境
- Xcode:16.0
- iOS:18.0
- Swift:5.9
- macOS:Sonoma 14.6.1
UITableViewDiffableDataSourceでできること
UIKitベースでリストビューを作成するときはUITableView
を使用するかと思います。しかし実装は意外と複雑かつセルのデータを更新する際もreloadData
を呼び出してテーブル全体をリフレッシュしたりとパフォーマンス的に少し懸念点が残る部分もあるかと思います。reloadRows
やreloadSections
で一部のみの更新を行うことも可能ですが、UITableViewDiffableDataSource
を使用することで比較的シンプルに冗長なリフレッシュのないリストビューを実装することが可能になっています。
NSDiffableDataSourceSnapshot
公式リファレンス:NSDiffableDataSourceSnapshot
UITableViewDiffableDataSource
で肝となるのがNSDiffableDataSourceSnapshot
です。「Diffable」→「差分可能」なこのデータソースはデータの差分のみを自動で更新対象に限定してくれます。従来通りにセクションとリストアイテムを管理することができるようになっています。
Hashableに準拠させる
NSDiffableDataSourceSnapshot
でセクションやアイテムとして管理するクラスはHashable
に準拠している必要があります。
/// 商品
struct Product: Hashable, Identifiable {
var id: UUID = UUID()
let name: String
let category: ProductCategory
}
/// 商品カテゴリ
enum ProductCategory: String, CaseIterable, Hashable {
case electronics = "電子機器"
case clothing = "衣料品"
case groceries = "食料品"
case miscellaneous = "その他"
var backgroundColor: UIColor {
switch self {
case .electronics: return .systemBlue
case .clothing: return .systemGreen
case .groceries: return .systemOrange
case .miscellaneous: return .systemGray
}
}
}
実装の手順
- Hashableに準拠したデータ型の定義
- カスタムセルビューの定義
- UITableViewのベースを作成する
- 扱いやすいようにタイプエイリアスを作成
- データソースの設定
- データ更新処理の実装
- データの追加/削除を実装する
1.Hashableに準拠したデータ型の定義
これは先ほど定義したものを利用していきます。
2.カスタムセルビューの定義
リストに表示するためのカスタムセルビューを定義しておきます。ここは特に特別なことはしていません。
class ProductCell: UITableViewCell {
let label = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
label.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(label)
label.font = .systemFont(ofSize: 15)
label.textColor = .darkGray
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
label.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
3.UITableViewのベースを作成する
続いてUITableViewのベースを作成しておきます。カスタムヘッダービューの作成やカスタムセルビューの連携などを行なっています。
class TableViewController: UITableViewController {
// 初期描画用のデータ
private let initialProducts = [
Product(name: "Laptop", category: .electronics),
Product(name: "Smartphone", category: .electronics),
Product(name: "T-Shirt", category: .clothing),
Product(name: "Jeans", category: .clothing),
Product(name: "Apples", category: .groceries),
Product(name: "Milk", category: .groceries)
]
// 現在の商品のリストを保持する配列。
private var currentProducts: [Product] = []
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = "Product List"
navigationController?.navigationBar.prefersLargeTitles = true
// テーブルビューにセルを登録
tableView.register(ProductCell.self, forCellReuseIdentifier: "ProductCell")
// 初期データを現在の商品リストに設定し、スナップショットを適用。
currentProducts = initialProducts
}
// MARK: - UITableViewDelegate
/// セクションヘッダーのカスタムビューを作成。
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
guard let category = dataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
let headerView = UIView()
headerView.backgroundColor = category.backgroundColor
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = category.rawValue
label.font = .boldSystemFont(ofSize: 16)
label.textColor = .white
headerView.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 16),
label.centerYAnchor.constraint(equalTo: headerView.centerYAnchor)
])
return headerView
}
/// セクションヘッダーの高さ
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return 50
}
}
4.扱いやすいようにタイプエイリアスを作成
使用するUITableViewDiffableDataSource
とNSDiffableDataSourceSnapshot
型は扱いやすいようにタイプエイリアスを作成しておきます。
// 可読性向上のため型エイリアスを定義
private typealias DataSource = UITableViewDiffableDataSource<ProductCategory, Product>
private typealias Snapshot = NSDiffableDataSourceSnapshot<ProductCategory, Product>
5.データソースの設定
続いてtableView.dataSource
にUITableViewDiffableDataSource
を設定するための処理を記述します。configureDataSource
メソッドとして用意した部分でDataSource
型を作成しています。
class TableViewController: UITableViewController {
/// テーブルビューとデータの連携を管理する役割
private lazy var dataSource = configureDataSource()
// 〜〜〜〜〜〜〜〜〜〜〜〜〜〜
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = "Product List"
navigationController?.navigationBar.prefersLargeTitles = true
tableView.register(ProductCell.self, forCellReuseIdentifier: "ProductCell")
// テーブルビューにデータソースをセット
tableView.dataSource = dataSource
currentProducts = initialProducts
}
private func configureDataSource() -> DataSource {
return UITableViewDiffableDataSource(tableView: tableView, cellProvider: { tableView, indexPath, product in
let cell = tableView.dequeueReusableCell(withIdentifier: "ProductCell", for: indexPath) as? ProductCell
cell?.label.text = product.name
return cell
})
}
}
6.データ更新処理の実装
最後に実際にデータを更新してUIに反映させる部分を実装します。NSDiffableDataSourceSnapshot<ProductCategory, Product>
型のインスタンスを生成しそこにデータをappendSections
/appendItems
を使用して格納します。追加が完了したらapply
メソッドで実際に反映させます。初回は全てのデータが更新されますが、2回目以降はデータの変更部分だけを自動で更新するように制限してくれます。
class TableViewController: UITableViewController {
// 〜〜〜〜〜〜〜〜〜〜〜〜〜〜
override func viewDidLoad() {
super.viewDidLoad()
// 〜〜〜〜〜〜〜〜〜〜〜〜〜〜
// 初期データを現在の商品リストに設定し、スナップショットを適用。
currentProducts = initialProducts
applySnapshot()
}
// 〜〜〜〜〜〜〜〜〜〜〜〜〜〜
/// スナップショットの更新
/// データをリフレッシュしてUIを更新
private func applySnapshot(animatingDifferences: Bool = true) {
var snapshot = Snapshot()
// 現在の商品リストからカテゴリを抽出して並び替え
let categories = Set(currentProducts.map { $0.category })
snapshot.appendSections(categories.sorted { $0.rawValue < $1.rawValue })
// 各カテゴリごとに商品を追加
for category in categories {
let productsInCategory = currentProducts.filter { $0.category == category }
snapshot.appendItems(productsInCategory, toSection: category)
}
// Diffable Data Sourceにスナップショットを適用
dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
}
7.データの追加/削除を実装する
最後にデータを追加/削除する処理を追加しておきます。今回はナビゲーションに追加するのでNavigationViewController
を追加しておいてください。追加/削除を行った後は先ほど実装したapplySnapshot
メソッドを呼び出すだです。
class TableViewController: UITableViewController {
// 〜〜〜〜〜〜〜〜〜〜〜〜〜〜
override func viewDidLoad() {
super.viewDidLoad()
// 〜〜〜〜〜〜〜〜〜〜〜〜〜〜
// ナビゲーションバーにボタンを追加
navigationItem.rightBarButtonItems = [
UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addItem)),
UIBarButtonItem(barButtonSystemItem: .trash, target: self, action: #selector(removeItem))
]
// 〜〜〜〜〜〜〜〜〜〜〜〜〜〜
}
/// 商品の追加
@objc private func addItem() {
// ランダムに新しい商品を追加
let categories: [ProductCategory] = [.electronics, .clothing, .groceries]
let randomCategory = categories.randomElement()!
let newProduct = Product(name: "New Product \(Int.random(in: 1...100))", category: randomCategory)
currentProducts.append(newProduct)
applySnapshot()
}
/// 商品の削除
@objc private func removeItem() {
// ランダムに商品を削除
guard !currentProducts.isEmpty else { return }
let randomIndex = Int.random(in: 0..<currentProducts.count)
currentProducts.remove(at: randomIndex)
applySnapshot()
}
}
全体のコード
class TableViewController: UITableViewController {
/// テーブルビューとデータの連携を管理する役割
private lazy var dataSource = configureDataSource()
// 可読性向上のため型エイリアスを定義。
private typealias DataSource = UITableViewDiffableDataSource<ProductCategory, Product>
private typealias Snapshot = NSDiffableDataSourceSnapshot<ProductCategory, Product>
private let initialProducts = [
Product(name: "Laptop", category: .electronics),
Product(name: "Smartphone", category: .electronics),
Product(name: "T-Shirt", category: .clothing),
Product(name: "Jeans", category: .clothing),
Product(name: "Apples", category: .groceries),
Product(name: "Milk", category: .groceries)
]
// 現在の商品のリストを保持する配列。
private var currentProducts: [Product] = []
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = "Product List"
navigationController?.navigationBar.prefersLargeTitles = true
// テーブルビューにセルを登録
tableView.register(ProductCell.self, forCellReuseIdentifier: "ProductCell")
// テーブルビューにデータソースをセット。
tableView.dataSource = dataSource
// ナビゲーションバーにボタンを追加
navigationItem.rightBarButtonItems = [
UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addItem)),
UIBarButtonItem(barButtonSystemItem: .trash, target: self, action: #selector(removeItem))
]
// 初期データを現在の商品リストに設定し、スナップショットを適用。
currentProducts = initialProducts
applySnapshot()
}
///
private func configureDataSource() -> DataSource {
// セルを取得し、商品名を設定
return UITableViewDiffableDataSource(tableView: tableView, cellProvider: { tableView, indexPath, product in
let cell = tableView.dequeueReusableCell(withIdentifier: "ProductCell", for: indexPath) as? ProductCell
cell?.label.text = product.name
return cell
})
}
/// スナップショットの更新
/// データをリフレッシュしてUIを更新
private func applySnapshot(animatingDifferences: Bool = true) {
var snapshot = Snapshot()
// 現在の商品リストからカテゴリを抽出して並び替え
let categories = Set(currentProducts.map { $0.category })
snapshot.appendSections(categories.sorted { $0.rawValue < $1.rawValue })
// 各カテゴリごとに商品を追加
for category in categories {
let productsInCategory = currentProducts.filter { $0.category == category }
snapshot.appendItems(productsInCategory, toSection: category)
}
// Diffable Data Sourceにスナップショットを適用
dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
/// 商品の追加
@objc private func addItem() {
// ランダムに新しい商品を追加
let categories: [ProductCategory] = [.electronics, .clothing, .groceries]
let randomCategory = categories.randomElement()!
let newProduct = Product(name: "New Product \(Int.random(in: 1...100))", category: randomCategory)
currentProducts.append(newProduct)
applySnapshot()
}
/// 商品の削除
@objc private func removeItem() {
// ランダムに商品を削除
guard !currentProducts.isEmpty else { return }
let randomIndex = Int.random(in: 0..<currentProducts.count)
currentProducts.remove(at: randomIndex)
applySnapshot()
}
// MARK: - UITableViewDelegate
/// セクションヘッダーのカスタムビューを作成。
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
guard let category = dataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
let headerView = UIView()
headerView.backgroundColor = category.backgroundColor
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = category.rawValue
label.font = .boldSystemFont(ofSize: 16)
label.textColor = .white
headerView.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 16),
label.centerYAnchor.constraint(equalTo: headerView.centerYAnchor)
])
return headerView
}
/// セクションヘッダーの高さ
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return 50
}
}
まだまだ勉強中ですので間違っている点や至らぬ点がありましたら教えていただけると助かります。
ご覧いただきありがとうございました。