概要
WWDC20のセッション動画を観て「へー、なるほど、わかったわかった」と思ってたけど、業務でUICollectionView Compositional Layoutを使ったときに結構ハマって、これはOutlineも触っておいたほうがいいなって思ったので、簡単なサンプルを実装してみました。
ソースコード
GitHubにあげています。
実装したもの
この記事のサンプルは OutlineListSample
を参照してください。
サンプルとして、セクションなしの SimpleOutlineList
と、セクションありの OutlineListWithSection
の2種類を実装しました。
どちらも表示するModelは同じものを使用して、Listに反映する型を変えています。
Modelを階層表示したものが以下です。
詳細は後述しますが、セクションあり版は 動物
、食べ物
をセクションヘッダーとして表示しています。
動物 ├─ 犬 │ ├─ 柴犬 │ ├─ ヨークシャテリア │ └─ ミニチュアシュナウザー └─ 猫 ├─ アメリカンショートヘア ├─ ノルウェージャンフォレストキャット └─ 三毛猫 食べ物 ├─ 肉 │ ├─ 豚肉 │ ├─ 牛肉 │ └─ 馬肉 └─ 野菜 ├─ セロリ ├─ ブロッコリー └─ トマト
SimpleOutlineList
Outlineを展開した状態のスクリーンショットです。
このあとに紹介する OutlineListWithSection
を先に実装していたので、コードを流用しつつ、DataSourceとSnapshotのつなぎこみの部分を見直しただけですみました。
実装の説明
実務ではViewModelに値をもたせることが多いので、このサンプルでもViewModelを用意しました。
struct SimpleOutlineListViewModel { let items: [Item] init() { var items: [Item] = [] Kind.allCases.forEach { kind in switch kind { case .animal: let headers = Animal.allCases.map { animal -> Item in return Item(title: animal.description, children: animal.names.map { Item(title: $0, children: []) }) } items.append(Item(title: kind.description, children: headers)) case .food: let headers = Food.allCases.map { food -> Item in return Item(title: food.description, children: food.names.map { Item(title: $0, children: []) }) } items.append(Item(title: kind.description, children: headers)) } } self.items = items } } extension SimpleOutlineListViewModel { enum Section: Hashable { case main } struct Item: Hashable { private let identifier = UUID() let title: String let children: [Item] var hasChildren: Bool { return !children.isEmpty } } }
Hashable
に準拠した Item
という型を用意して、init()
でenumで表現したModelを前述した構造になるように多次元配列を生成しています。
個人的にはこの構造は好きではなくて、階層ごとに別の型を用意したいんですよね。
このサンプルではシンプルなプロパティしかもってないけど、階層ごとに必要なプロパティが違う場合は、その階層では不要なプロパティに対して値を渡す必要があって、きれいじゃないな、と。
optionalにしてinit時に初期値として nil
を渡せばいいんだろうけど、やっぱなんだかなぁって思ってしまいます。
AnyHashable
にするぐらいならこれでもいいかな…。
続いて、ViewControllerは以下です。
import UIKit final class SimpleOutlineListViewController: UIViewController { typealias Section = SimpleOutlineListViewModel.Section typealias Item = SimpleOutlineListViewModel.Item @IBOutlet var collectionView: UICollectionView! var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private let viewModel = SimpleOutlineListViewModel() override func viewDidLoad() { super.viewDidLoad() title = "SimpleOutlineList" collectionView.collectionViewLayout = createLayout() configureDataSource() applyInitialSnapshots() } } extension SimpleOutlineListViewController { private func createLayout() -> UICollectionViewLayout { let config = UICollectionLayoutListConfiguration(appearance: .insetGrouped) return UICollectionViewCompositionalLayout.list(using: config) } private func createHeaderCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> { return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in var content = cell.defaultContentConfiguration() content.text = title cell.contentConfiguration = content cell.accessories = [.outlineDisclosure(options: .init(style: .header))] } } private func createCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> { return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in var content = cell.defaultContentConfiguration() content.text = title cell.contentConfiguration = content cell.accessories = [.reorder()] } } private func configureDataSource() { let headerCellRegistration = createHeaderCellRegistration() let cellRegistration = createCellRegistration() dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in if item.hasChildren { return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: item.title) } return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item.title) } } private func applyInitialSnapshots() { var snapshot = NSDiffableDataSourceSectionSnapshot<Item>() func addItems(_ menuItems: [Item], to parent: Item?) { snapshot.append(menuItems, to: parent) for menuItem in menuItems where menuItem.hasChildren { addItems(menuItem.children, to: menuItem) } } addItems(viewModel.items, to: nil) dataSource.apply(snapshot, to: .main, animatingDifferences: false) } }
configureDataSource()
で hasChildren
が true
のときは子要素を持っており開閉可能にするので、 cell.accessories = [.outlineDisclosure(options: .init(style: .header))]
としています。
また、DataSource生成時に AnyHashable
を使わずに済み、すっきりしますね。
applyInitialSnapshots()
はdataSourceにSnapshotを適用するメソッドです。
InnerFunctionとして定義した addItems(_ menuItems: [Item], to parent: Item?)
はSnapchotにappendする再帰処理です。
Appleのサンプルコードから拝借しました。
OutlineListWithSection
Outlineを展開した状態のスクリーンショットです。
SimpleOutlineList
ではすべてのItemを親子構造にしましたが、OutlineListWithSection
ではルートのItemをセクションヘッダーにしました。
このサンプルは一度実装したあとに構造を見直しており、旧版のコードも紹介しつつ、なぜ作り直したかを説明します。
旧版の実装の説明
SimpleOutlineList
と同様にViewModelを用意しました。
struct ViewModel { private let items: [AnyHashable] init() { var items: [AnyHashable] = [] Section.allCases.forEach { section in switch section { case .animal: Animal.allCases.forEach { animal in items.append(Header(title: animal.description, kind: section, category: animal.rawValue)) animal.names.forEach { items.append(Children(title: $0, kind: section, category: animal.rawValue)) } } case .food: Food.allCases.forEach { food in items.append(Header(title: food.description, kind: section, category: food.rawValue)) food.names.forEach { items.append(Children(title: $0, kind: section, category: food.rawValue)) } } } } self.items = items } func getHeader(kind: Section, category: String) -> AnyHashable? { return items.first { item in guard let item = item as? Header else { return false } if item.kind == kind, item.category == category { return true } return false } } func getChildren(kind: Section, category: String) -> [AnyHashable]? { return items.filter { item in guard let item = item as? Children else { return false } if item.kind == kind, item.category == category { return true } return false } } } extension ViewModel { enum Section: Int, Hashable, CaseIterable { case animal, food var description: String { switch self { case .animal: return "動物" case .food: return "食べ物" } } } struct Header: Hashable { private let identifier = UUID() let title: String let kind: Section let category: String } struct Children: Hashable { private let identifier = UUID() let title: String let kind: Section let category: String } enum Animal: String, CaseIterable { case dog, cat var description: String { switch self { case .dog: return "犬" case .cat: return "猫" } } var names: [String] { switch self { case .dog: return [" 柴犬", "ヨークシャテリア", "ミニチュアシュナウザー"] case .cat: return ["アメリカンショートヘア", "ノルウェージャンフォレストキャット", "三毛猫"] } } } enum Food: String, CaseIterable { case meat, vegetable var description: String { switch self { case .meat: return "肉" case .vegetable: return "野菜" } } var names: [String] { switch self { case .meat: return ["豚肉", "牛肉", "馬肉"] case .vegetable: return ["セロリ", "ブロッコリー", "トマト"] } } } }
DataSourceの型として、セクションの Section
、開閉可能なセルの Header
、通常のセルのChildren
という3つの型を用意しました。
Header
と Children
をひとつの配列に詰め込むので、 items
は AnyHashable
の配列になっています。
getHeader(kind: Section, category: String) -> AnyHashable?
、getChildren(kind: Section, category: String) -> [AnyHashable]?
は items
から特定の型のdataを取り出すメソッドです。
我ながらなかなか苦しい設計になってしまって、書きながら「罪深い…これは罪深い…」とつぶやいてました。
サンプルなので動けばいいとはいえ、もうちょっとうまく書けるようになりたい…。
続いて、ViewControllerです。
import UIKit final class ViewController: UIViewController { typealias Section = ViewModel.Section @IBOutlet var collectionView: UICollectionView! var dataSource: UICollectionViewDiffableDataSource<Section, AnyHashable>! private let viewModel = ViewModel() override func viewDidLoad() { super.viewDidLoad() title = "Outline List Sample" collectionView.collectionViewLayout = createLayout() configureDataSource() applyInitialSnapshots() } } extension ViewController { private func createLayout() -> UICollectionViewLayout { let sectionProvider = { (_: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in var config = UICollectionLayoutListConfiguration(appearance: .sidebar) config.headerMode = .supplementary let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: layoutEnvironment) section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 0, trailing: 10) return section } return UICollectionViewCompositionalLayout(sectionProvider: sectionProvider) } private func createHeaderCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> { return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in var content = cell.defaultContentConfiguration() content.text = title cell.contentConfiguration = content cell.accessories = [.outlineDisclosure(options: .init(style: .header))] } } private func createCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> { return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in var content = cell.defaultContentConfiguration() content.text = title cell.contentConfiguration = content cell.accessories = [.reorder()] } } private func createSectionHeaderRegistration() -> UICollectionView.SupplementaryRegistration<UICollectionViewListCell> { return UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: "Header") { (headerView, _, indexPath: IndexPath) in guard let section = Section(rawValue: indexPath.section) else { return } var configuration = headerView.defaultContentConfiguration() configuration.text = section.description configuration.textProperties.font = .boldSystemFont(ofSize: 16) configuration.textProperties.color = .systemBlue configuration.directionalLayoutMargins = .init(top: 20.0, leading: 0.0, bottom: 10.0, trailing: 0.0) headerView.contentConfiguration = configuration } } private func configureDataSource() { let headerCellRegistration = createHeaderCellRegistration() let cellRegistration = createCellRegistration() dataSource = UICollectionViewDiffableDataSource<Section, AnyHashable>(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in switch item { case let model as ViewModel.Header: return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: model.title) case let model as ViewModel.Children: return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: model.title) default: fatalError() } } dataSource.supplementaryViewProvider = { (_, _, indexPath) in return self.collectionView.dequeueConfiguredReusableSupplementary(using: self.createSectionHeaderRegistration(), for: indexPath) } } private func applyInitialSnapshots() { let sections = Section.allCases var snapshot = NSDiffableDataSourceSnapshot<Section, AnyHashable>() snapshot.appendSections(sections) dataSource.apply(snapshot, animatingDifferences: false) Section.allCases.forEach { section in var outlineSnapshot = NSDiffableDataSourceSectionSnapshot<AnyHashable>() switch section { case .animal: ViewModel.Animal.allCases.forEach { animal in guard let header = viewModel.getHeader(kind: section, category: animal.rawValue), let children = viewModel.getChildren(kind: section, category: animal.rawValue) else { return } outlineSnapshot.append([header]) outlineSnapshot.append(children, to: header) } case .food: ViewModel.Food.allCases.forEach { food in guard let header = viewModel.getHeader(kind: section, category: food.rawValue), let children = viewModel.getChildren(kind: section, category: food.rawValue) else { return } outlineSnapshot.append([header]) outlineSnapshot.append(children, to: header) } } dataSource.apply(outlineSnapshot, to: section, animatingDifferences: false) } } }
Compositional Layoutの話になりますが、セクションヘッダーは dataSource.supplementaryViewProvider
のクロージャで設定していて、Viewは createSectionHeaderRegistration()
で定義しています。
Cellと同じような感じで実装できるので、いいですね。
configureDataSource()
でDataSourceを生成するメソッドです。
型が AnyHashable
になっているので、switch
で型を判別する処理をいれています。
switch item { case let model as ViewModel.Header: return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: model.title) case let model as ViewModel.Children: return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: model.title) default: fatalError() }
SimpleOutlineList
の節でも書きましたが、この処理が好きじゃないんですよね。
Hashable
に準拠した型ならなんでも渡せてしまうので、defaultが必要になるのが…。
applyInitialSnapshots()
はDataSourceにSnapshotを適用するメソッドです。
Section
ごとに Header
と Children
が親子関係になるようにDataを詰めています。
Section.allCases.forEach { section in var outlineSnapshot = NSDiffableDataSourceSectionSnapshot<AnyHashable>() switch section { case .animal: ViewModel.Animal.allCases.forEach { animal in guard let header = viewModel.getHeader(kind: section, category: animal.rawValue), let children = viewModel.getChildren(kind: section, category: animal.rawValue) else { return } outlineSnapshot.append([header]) outlineSnapshot.append(children, to: header) } case .food: ViewModel.Food.allCases.forEach { food in guard let header = viewModel.getHeader(kind: section, category: food.rawValue), let children = viewModel.getChildren(kind: section, category: food.rawValue) else { return } outlineSnapshot.append([header]) outlineSnapshot.append(children, to: header) } } }
以上が旧版です。
新版の実装の説明
AnyHashable
を使わずに済む方法を模索したものが新版です。
GitHubのmainブランチにあげているものになります。
ViewModelは以下です。
import Foundation struct OutlineListWithSectionViewModel { let items: [SectionHeader] init() { var items: [SectionHeader] = [] Kind.allCases.forEach { kind in switch kind { case .animal: let headers = Animal.allCases.map { animal -> Header in return Header(title: animal.description, children: animal.names.map { Children(title: $0) }) } items.append(SectionHeader(title: kind.description, headers: headers)) case .food: let headers = Food.allCases.map { food -> Header in return Header(title: food.description, children: food.names.map { Children(title: $0) }) } items.append(SectionHeader(title: kind.description, headers: headers)) } } self.items = items } } extension OutlineListWithSectionViewModel { enum ListItem: Hashable { case sectionHeader(SectionHeader) case header(Header) case children(Children) } struct SectionHeader: Hashable { private let identifier = UUID() let title: String let headers: [Header] } struct Header: Hashable { private let identifier = UUID() let title: String let children: [Children] } struct Children: Hashable { private let identifier = UUID() let title: String } }
新版では items
を SectionHeader
の配列にしました。
DataSourceの型として、セクションの SectionHeader
、開閉可能なセルの Header
、通常のセルのChildren
という3つの型を用意し、SectionHeader
が子要素となる Header
の配列をもち、Header
が子要素となる Children
の配列をもっています。
DataSourceからは ListItem
というenumを介して、各caseがassociated valueで保持したItemを取得するようにしています。
enum ListItem: Hashable { case sectionHeader(SectionHeader) case header(Header) case children(Children) }
続いて、ViewContollerは以下です。
import UIKit final class OutlineListWithSectionViewController: UIViewController { typealias ListItem = OutlineListWithSectionViewModel.ListItem typealias SectionHeader = OutlineListWithSectionViewModel.SectionHeader typealias Header = OutlineListWithSectionViewModel.Header typealias Children = OutlineListWithSectionViewModel.Children @IBOutlet var collectionView: UICollectionView! var dataSource: UICollectionViewDiffableDataSource<SectionHeader, ListItem>! private let viewModel = OutlineListWithSectionViewModel() override func viewDidLoad() { super.viewDidLoad() title = "OutlineListWithSection" collectionView.collectionViewLayout = createLayout() configureDataSource() applyInitialSnapshots() } } extension OutlineListWithSectionViewController { private func createLayout() -> UICollectionViewLayout { let sectionProvider = { (_: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in var config = UICollectionLayoutListConfiguration(appearance: .sidebar) config.headerMode = .firstItemInSection let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: layoutEnvironment) section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10) return section } return UICollectionViewCompositionalLayout(sectionProvider: sectionProvider) } private func createHeaderCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> { return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in var content = cell.defaultContentConfiguration() content.text = title cell.contentConfiguration = content cell.accessories = [.outlineDisclosure(options: .init(style: .header))] } } private func createCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> { return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in var content = cell.defaultContentConfiguration() content.text = title let marginLeft = content.directionalLayoutMargins.leading + content.textProperties.font.pointSize content.directionalLayoutMargins = .init(top: 0, leading: marginLeft, bottom: 0, trailing: 0) cell.contentConfiguration = content cell.accessories = [.reorder()] } } private func createSectionHeaderRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> { return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in var content = cell.defaultContentConfiguration() content.text = title content.textProperties.font = .boldSystemFont(ofSize: 16) content.textProperties.color = .systemBlue cell.contentConfiguration = content } } private func configureDataSource() { let sectionHeaderRegistration = createSectionHeaderRegistration() let headerCellRegistration = createHeaderCellRegistration() let cellRegistration = createCellRegistration() dataSource = UICollectionViewDiffableDataSource<SectionHeader, ListItem>(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in switch item { case let .sectionHeader(model): return collectionView.dequeueConfiguredReusableCell(using: sectionHeaderRegistration, for: indexPath, item: model.title) case let .header(model): return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: model.title) case let .children(model): return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: model.title) } } } private func applyInitialSnapshots() { var dataSourceSnapshot = NSDiffableDataSourceSnapshot<SectionHeader, ListItem>() dataSourceSnapshot.appendSections(viewModel.items) dataSource.apply(dataSourceSnapshot) for sectionHeader in viewModel.items { var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>() let sectionHeaderItem = ListItem.sectionHeader(sectionHeader) sectionSnapshot.append([sectionHeaderItem]) sectionHeader.headers.forEach { header in let headerItem = ListItem.header(header) let childrenItems = header.children.map { ListItem.children($0) } sectionSnapshot.append([headerItem]) sectionSnapshot.append(childrenItems, to: headerItem) } dataSource.apply(sectionSnapshot, to: sectionHeader, animatingDifferences: false) } } }
以下のように、headerMode
に .firstItemInSection
を設定すると、RootのItemをセクションとして処理してくれます。
便利ですね。
var config = UICollectionLayoutListConfiguration(appearance: .sidebar) config.headerMode = .firstItemInSection
前述したように configureDataSource()
内の switch
でDataを取り出しています。
switch
でDataを振り分けるのは旧版と同様ですがdefaultが必要なくなっています。
switch item { case let .sectionHeader(model): return collectionView.dequeueConfiguredReusableCell(using: sectionHeaderRegistration, for: indexPath, item: model.title) case let .header(model): return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: model.title) case let .children(model): return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: model.title) }
これはこれで微妙な気もしますが、旧版よりは扱いやすくなったかと思います。
まとめ
案の定ハマりました。
セッション動画や記事を観るだけではなくて、簡単でもいいから自分でサンプルを実装してみないとわからないことは多いんだぁと、改めて実感しました。
おまけ
SwiftUI版のサンプルも実装しました。
同じリポジトリにあげています。
記事にするのはもう少しあとになると思うので、興味のある方はコードをみてみてください。