ごんれのラボ

iOS、Android、Adobe系ソフトの自動化スクリプトのことを書き連ねています。

UIKit版のOutlineのサンプルを実装してハマった

概要

WWDC20のセッション動画を観て「へー、なるほど、わかったわかった」と思ってたけど、業務でUICollectionView Compositional Layoutを使ったときに結構ハマって、これはOutlineも触っておいたほうがいいなって思ったので、簡単なサンプルを実装してみました。

ソースコード

GitHubにあげています。

github.com

実装したもの

この記事のサンプルは 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()hasChildrentrue のときは子要素を持っており開閉可能にするので、 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つの型を用意しました。
HeaderChildren をひとつの配列に詰め込むので、 itemsAnyHashable の配列になっています。
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 ごとに HeaderChildren が親子関係になるように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
    }
}

新版では itemsSectionHeader の配列にしました。
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版のサンプルも実装しました。
同じリポジトリにあげています。
記事にするのはもう少しあとになると思うので、興味のある方はコードをみてみてください。

参考