はじめに
地味に時々いいねをもらえているこちらの記事の続編として、iOS14でCoreDataを使ったサンプルアプリをつくるとしたら...について簡単に紹介したいと思います。
知れること
- UICollectionViewを利用したリスト形式の実装方法
- DiffableDataSourceでCoreDataをなるべく簡単に使う方法
完成版コード
今回の記事の元にするサンプルアプリはこちらになります。
Part.1 UICollectionViewを利用したリスト形式の実装方法
こちらの方法はすでに何人かの方が投稿されていると思いますが、Xcode12/iOS14から導入される UICollectionListViewCell
というものを利用します。
これと DiffableDataSource
をつかったリスト形式の実装サンプルを以下で逆引き的に紹介します。ほとんどは公式のCollectionViewのサンプルアプリを参考に実装しているのでより詳しい方法や他のスタイルに関してはそちらを確認してください。
セクションと要素のView側での形式を定義する
まずDiffableDataSourceや新しいCollectionViewを存分に活用するには、セクションに対応するenumとHashableな構造体を用意しておくとさまざまな部分がすっきりと書けるようにできます。
具体的にはこのようなものをViewController内で定義してあげます。
enum Section: Int, Hashable, CaseIterable {
case main
case outline
}
struct Item: Hashable {
let name: String?
let deadline: String?
let isDone: Bool
// 入れ子形式のセル用
let hasChildren: Bool
init(_ name: String, hasChildren: Bool = false) {
self.name = name
self.hasChildren = hasChildren
self.isDone = false
self.deadline = nil
}
// このTodoがCoreDataのデータ形式
// つまりItemがViewModel、TodoがModel(Entity)のような責務の分割も自然と実現されている
init(_ todo: Todo) {
self.name = todo.name
self.isDone = todo.isDone
if let deadline = todo.deadline {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy/MM/dd"
self.deadline = dateFormatter.string(from: deadline)
} else {
self.deadline = nil
}
self.hasChildren = false
}
private let identifier = UUID()
}
今回は2つのセクションで別々の形式として使いまわしたかったため2つのinit関数が実装されていますが、単純なものであればもっとシンプルで大丈夫です。
レイアウトを定義する
新しいCollectionViewでは UICollectionViewLayout
という型にセクションごとのレイアウトを登録することによって、セクション別にバラバラのレイアウトを容易に設定することができるようになっています。一例としては以下のようなかたちです。
collectionView.collectionViewLayout = createLayout()
func createLayout() -> UICollectionViewLayout {
let sectionProvider = { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
// ここで先程のSectionを有効活用できる。下のif文は多くなってきたらSwitch文でもよいかも
guard let sectionKind = Section(rawValue: sectionIndex) else { return nil }
if sectionKind == .main {
// リスト形式のレイアウトにする
return NSCollectionLayoutSection.list(using: .init(appearance: .insetGrouped), layoutEnvironment: layoutEnvironment)
} else if sectionKind == .outline {
// ここでappearanceにsidebarを指定するとサイドバーで使われるような入れ子のリスト形式も使うことができる
let section = NSCollectionLayoutSection.list(using: .init(appearance: .sidebar), layoutEnvironment: layoutEnvironment)
section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 0, trailing: 10)
return section
} else {
fatalError("Unknown section")
}
}
return UICollectionViewCompositionalLayout(sectionProvider: sectionProvider)
}
今回はサンプルの都合上2つともリスト形式を利用していますが、もちろんグリッド形式をおりまぜたりすることも可能となります。
セルとデータの紐付け
セルとデータの紐付けには従来であればUICollectionViewDataSourceのメソッド内で事前にregisterしていたものをdequeueするという流れが一般的でしたが、DiffableDataSourceを用いた実装においてはCellRegistrationという仕組みを利用してセルのクラスとデータの構造体を紐付けます。
またこの際組み込みのセルを使えばセルの中のUI要素に直接反映するかたちではなく、ContentConfigurationというものを介してUI要素にはViewController側では触れない状態でデータをUIに反映することができます。
func configuredListCell() -> UICollectionView.CellRegistration<UICollectionViewListCell, Item> {
// このようにジェネリクスでセルのクラスとデータの構造体を渡してあげます
return UICollectionView.CellRegistration<UICollectionViewListCell, Item> { [weak self] (cell, indexPath, item) in
guard let self = self else { return }
// 今回はUICollectionViewListCellに対するcontentConfigurationなのでUIListContentConfigurationを利用しています
// おそらくこれは自作することができるはず
var content = UIListContentConfiguration.valueCell()
content.text = item.name
content.secondaryText = item.deadline
cell.contentConfiguration = content
// セルのアクセサリを定義します
cell.accessories = self.accessoriesForListCellItem(item)
// leadingの方向をスワイプで出したときのアイテムを定義します(後述)
cell.leadingSwipeActionsConfiguration = self.leadingSwipeActionConfigurationForListCellItem(item)
// trailingの方向をスワイプで出したときのアイテムを定義します(後述)
cell.trailingSwipeActionsConfiguration = self.trailingSwipeActionConfigurationForListCellItem(item)
}
}
地味にありがたいのはより柔軟にSwipeActionを定義できるようになったところだと思います(※今までもあったかもしれませんが、Deleteがeditの設定の中でコードを書くと勝手にできるようになったり仕様が不透明になってしまう場合があった印象からこう書いてます)
今回のサンプルアプリではleadingにeditボタン、trailingにdeleteボタンを設置したかったので以下のようなコードになりました。今までライブラリを使っていた部分も多かったと思うのでかなり簡単になったのではないでしょうか。
func leadingSwipeActionConfigurationForListCellItem(_ item: Item) -> UISwipeActionsConfiguration? {
// 基本はこのUIContextualActionが一個のデータを表す
let editAction = UIContextualAction(style: .normal, title: nil) {
[weak self] (_, _, completion) in
guard let self = self else {
completion(false)
return
}
if let currentIndexPath = self.dataSource.indexPath(for: item) {
let todo = self.fetchedResultsController.object(at: currentIndexPath)
self.performSegue(withIdentifier: self.todoSegueIdentifier, sender: todo)
}
completion(true)
}
// ボタンの見た目や色なども細かく設定可能
editAction.image = UIImage(systemName: "square.and.pencil")
editAction.backgroundColor = .systemGreen
return UISwipeActionsConfiguration(actions: [editAction])
}
func trailingSwipeActionConfigurationForListCellItem(_ item: Item) -> UISwipeActionsConfiguration? {
let deleteAction = UIContextualAction(style: .destructive, title: nil) {
[weak self] (_, _, completion) in
guard let self = self else {
completion(false)
return
}
if let currentIndexPath = self.dataSource.indexPath(for: item) {
let alert = UIAlertController(title: "Delete", message: "Do you delete this todo?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { _ in
let todo = self.fetchedResultsController.object(at: currentIndexPath)
self.dataManager.delete(todo)
completion(true)
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
completion(false)
})
self.present(alert, animated: true, completion: nil)
} else {
completion(true)
}
}
deleteAction.image = UIImage(systemName: "trash")
deleteAction.backgroundColor = .systemRed
return UISwipeActionsConfiguration(actions: [deleteAction])
}
なおスワイプのボタンに対応するアクションは標準アプリでの挙動と同じく一個であればボタンを押さなくてもスワイプを長めにすると実行されるようになっています。
データソースの設定
今までのものを用いてDiffableDataSourceを定義します。これは以下のように行います。
var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
/* 中略 */
func configureDataSource() {
// セクションと要素の型をジェネリクスで渡し、対象となるCollectionViewは引数で渡す
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
guard let section = Section(rawValue: indexPath.section) else {
fatalError("Unknown section")
}
switch section {
case .main:
// ここでdequeueをする。先程のCellRegistrationが直接使えるのでidentifierが不要に。
return collectionView.dequeueConfiguredReusableCell(using: self.configuredListCell(), for: indexPath, item: item)
case .outline:
// 入れ子の中身なのか、外なのかはhasChildrenなどで振り分け
if item.hasChildren {
return collectionView.dequeueConfiguredReusableCell(using: self.configuredOutlineHeaderCell(), for: indexPath, item: item.name)
} else {
return collectionView.dequeueConfiguredReusableCell(using: self.configuredOutlineCell(), for: indexPath, item: item)
}
}
}
}
あとはデータを実際に反映する部分ですが、こちらはCoreDataの利用方法も関わってくるのでPart2に回したいと思います。
Part2. CoreDataと組み合わせる
続いてCoreDataとの組み合わせです。大部分は前回の記事と同じなのでそちらも参照してください。
まずManagerクラスは変わらず以下のように定義しました。
class DataManager {
static let shared: DataManager = DataManager()
private var persistentContainer: NSPersistentContainer!
init() {
persistentContainer = NSPersistentContainer(name: "TodoAppCollectionView")
persistentContainer.loadPersistentStores { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
}
}
func create<T: NSManagedObject>() -> T {
let context = persistentContainer.viewContext
let object = NSEntityDescription.insertNewObject(forEntityName: String(describing: T.self), into: context) as! T
return object
}
func delete<T: NSManagedObject>(_ object: T) {
let context = persistentContainer.viewContext
context.delete(object)
}
func saveContext() {
let context = persistentContainer.viewContext
do {
try context.save()
} catch {
print("Failed save context: \(error)")
}
}
func getFetchedResultController<T: NSManagedObject>(with descriptor: [String] = []) -> NSFetchedResultsController<T> {
let context = persistentContainer.viewContext
let fetchRequest = NSFetchRequest<T>(entityName: String(describing: T.self))
fetchRequest.sortDescriptors = descriptor.map { NSSortDescriptor(key: $0, ascending: true) }
return NSFetchedResultsController<T>(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)
}
}
前回の記事ではNSFetchedResultsControllerDelegateを存分に活用して実装していましたが、今回はDiffableDataSourceの仕組みを利用して更新部分を書きます。
これはそもそもDiffableDataSource自体がデータの差分を見てなるべく変更が少ないようにデータと見た目を処理してくれる仕組みとなっているためわざわざアクションごとに分ける必要がないからです。
具体的には以下のようにセクションごとにSnapshotをつくっていきます。
func updateSnapshot() {
let sections = Section.allCases
// データはfetchedObjectsでとってくる
let fetchObjects = fetchedResultsController.fetchedObjects ?? []
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(sections)
dataSource.apply(snapshot, animatingDifferences: false)
var allSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
var outlineSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
// TodoをItemに変換する
// IMO: 実はここでidentifierを同じデータの場合同じにできるようにしておくとパフォーマンスあがるかも
let items = fetchObjects.map { Item($0) }
allSnapshot.append(items)
let undoneRoot = Item("Undone", hasChildren: true)
let doneRoot = Item("Done", hasChildren: true)
outlineSnapshot.append([undoneRoot, doneRoot])
// ここでもう一度itemsをつくっているのは同じidentifierのItemは同じセルの情報とみなされてしまうから
let outlineItems = fetchObjects.map { Item($0) }
let undoneItems = outlineItems.filter { !$0.isDone }
let doneItems = outlineItems.filter { $0.isDone }
outlineSnapshot.append(undoneItems, to: undoneRoot)
outlineSnapshot.append(doneItems, to: doneRoot)
dataSource.apply(allSnapshot, to: .main, animatingDifferences: false)
dataSource.apply(outlineSnapshot, to: .outline, animatingDifferences: false)
}
これを更新したいタイミングで呼び出すことで最新状態になります。つまりこれがUICollectionViewDataSourceを利用した際のreloadDataに当たる操作と対応することになります。
基本的にはこれだけです。あとはDataManagerを活用して操作を行った後、updateSnapshotを呼び出せばいいということになります。こちらは controllerDidChangeContent(_ controller:)
の中でupdateSnapshotを呼び出しておけばいろんなところに書く必要もありません。
まとめ
新しいCollectionViewとCoreDataを利用する方法の一例をご紹介しました。
DiffableDataSourceはiOS13以降、ListCellはiOS14以降と特に企業のアプリでは導入はまだまだ先の話になってしまうと思いますが、プロトコルを利用する部分が減ったのでかなり責務の部分でわかりやすい実装方法になってきたのではないかなと思います。(クロージャーとジェネリクスはかなり多用するようになりましたが)
なにより二種類あったCollectionViewとTableViewが統合されていくのは学ぶ量が減っていいのではないかなと思っています。
ぜひみなさんもなにかこれらの新しい技術を用いてサンプルアプリを作ってみてください。
なお、今回記事のもとにしたサンプルアプリではUIMenuやAccentColor、新しいDatePickerなどWWDC20で発表された技術てんこ盛りで製作しているのでよければbeta版で動かしながら参考にしてみてください。
touyou/TodoAppCollectionView