【Swift UIKit】UITableViewDiffableDataSourceの使い方!データの局所更新

【Swift UIKit】UITableViewDiffableDataSourceの使い方!データの局所更新

この記事からわかること

  • SwiftUIKitリスト実装する方法
  • UITableViewを使った方法
  • UITableViewDiffableDataSource使い方

index

[open]

\ アプリをリリースしました /

みんなの誕生日

友達や家族の誕生日をメモ!通知も届く-みんなの誕生日-

posted withアプリーチ

環境

UITableViewDiffableDataSourceでできること

UIKitベースでリストビューを作成するときはUITableViewを使用するかと思います。しかし実装は意外と複雑かつセルのデータを更新する際もreloadDataを呼び出してテーブル全体をリフレッシュしたりとパフォーマンス的に少し懸念点が残る部分もあるかと思います。reloadRowsreloadSectionsで一部のみの更新を行うことも可能ですが、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
        }
    }
}

実装の手順

  1. Hashableに準拠したデータ型の定義
  2. カスタムセルビューの定義
  3. UITableViewのベースを作成する
  4. 扱いやすいようにタイプエイリアスを作成
  5. データソースの設定
  6. データ更新処理の実装
  7. データの追加/削除を実装する

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.扱いやすいようにタイプエイリアスを作成

使用するUITableViewDiffableDataSourceNSDiffableDataSourceSnapshot型は扱いやすいようにタイプエイリアスを作成しておきます。

// 可読性向上のため型エイリアスを定義
private typealias DataSource = UITableViewDiffableDataSource<ProductCategory, Product>
private typealias Snapshot = NSDiffableDataSourceSnapshot<ProductCategory, Product>

5.データソースの設定

続いてtableView.dataSourceUITableViewDiffableDataSourceを設定するための処理を記述します。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
    }
}

まだまだ勉強中ですので間違っている点や至らぬ点がありましたら教えていただけると助かります。

ご覧いただきありがとうございました。

searchbox

スポンサー

ProFile

ame

趣味:読書,プログラミング学習,サイト制作,ブログ

IT嫌いを克服するためにITパスを取得しようと勉強してからサイト制作が趣味に変わりました笑
今はCMSを使わずこのサイトを完全自作でサイト運営中〜

New Article

index