【Tips】SidebarのAppearanceを動的に切り替える

traitCollection.horizontalSizeClassのcompactとregularで別々のappearanceをサイドバーに適応させたいようなケースがあります。FinderやMusicなど、Apple純正Appではよく見られます。

![IMG_E4281229A214-1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114709/82426e41-aed2-4e91-1f8c-72a91b2e2f63.jpeg)
(筆者のファイルアプリスクリーンショット)

このようなインターフェースは[iOS HIG](https://developer.apple.com/design/human-interface-guidelines/ios/bars/sidebars/)でもSidebarパターンとして言及されています。

> Apply the correct appearance to a sidebar. To create a sidebar, use the sidebar appearance of a collection view list layout. For developer guidance, see UICollectionLayoutListConfiguration.Appearance.[^1]

ガイドラインで実装方法について指定があるため、Sidebarは[UICollectionLayoutListConfiguration](https://developer.apple.com/documentation/uikit/uicollectionlayoutlistconfiguration)と[UICollectionViewCompositionalLayout](https://developer.apple.com/documentation/uikit/uicollectionviewcompositionallayout)を利用して[UICollectionView](https://developer.apple.com/documentation/uikit/uicollectionview)で実装します。

よく見られるUICollectionLayoutListConfigurationのサンプルは、以下のように[UICollectionViewCompositionalLayout.list(using:)](https://developer.apple.com/documentation/uikit/uicollectionviewcompositionallayout/3600951-list)を利用してレイアウトを作成しているものです。

```swift
let configration = UICollectionLayoutListConfiguration(appearance: .sidebar)
let layout = UICollectionViewCompositionalLayout.list(using: configration)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
```

これでは`layout`が静的に決まってしまうので、動的にAppearanceを切り替えるには別のAPIを利用する必要があります。ここで、[UICollectionViewCompositionalLayout.init(sectionProvider:)](https://developer.apple.com/documentation/uikit/uicollectionviewcompositionallayout/3199214-init)を利用します。

```swift
let layout = UICollectionViewCompositionalLayout { [unowned self] section, layoutEnvironment in

    let appearance: UICollectionLayoutListConfiguration.Appearance = {

        // sidebarとして利用するviewcontrollerは常にhorizontalSizeClassが.compactで返されるため、
        // 画面全体の情報が知りたい場合はsplitViewController.traitCollection、
        // view.window?.windowScene?.traitCollection、もしくはUITraitCollection.currentなどを利用する必要がある。

        if self.splitViewController?.traitCollection.horizontalSizeClass == .compact {
            return .insetGrouped
        } else {
            return .sidebar
        }
    }()

    let configration = UICollectionLayoutListConfiguration(appearance: appearance)
    return NSCollectionLayoutSection.list(using: configration, layoutEnvironment: layoutEnvironment)
}

collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
```

本来このAPIはセクションごとに別の[NSCollectionLayoutSection](https://developer.apple.com/documentation/uikit/nscollectionlayoutsection)オブジェクトを生成して返すことを目的として設計し実装されていますが、上記のサンプルのようにtraitCollectionなどの状態変化に適応させる事にも利用できます。

しかし、これだけでは変更があったときにappearanceの更新を実行することができず、トリガーするための実装が必要になります。

```swift
diffableDataSource.apply(diffableDataSource.snapshot(), animatingDifferences: false)
```

[UICollectionViewDiffableDataSource](https://developer.apple.com/documentation/uikit/uicollectionviewdiffabledatasource)を利用している場合は、上記のようにapply()で同じsnapshotを適応させることにより「表示しているデータは変更しないが見た目の変更だけトリガーする」ということを実現できます。(※個人的に、このような見た目の変化だけを目的としたapply()を**空撃ち**と呼んでいます)

この空撃ちをContainer View Controller側の[traitCollectionDidChange(_:)](https://developer.apple.com/documentation/uikit/uitraitenvironment/1623516-traitcollectiondidchange)から呼び出せるような実装にしておくと、適切に変更を適応できます。

注意事項ですが、この空撃ちの実行であったとしても`apply(snapshot)`を実行するとリストのセレクションがクリアされるため、見た目を変えたいだけで選択状態は維持したいというケースであれば以下のように一時保存変数をうまく活用して、`apply(snapshot)`後に再度適応させてあげてください。

```swift
let stashedIndexPathForSelectedRow = collectionView.indexPathsForSelectedItems?.first
diffableDataSource.apply(diffableDataSource.snapshot(), animatingDifferences: false)
collectionView.selectItem(at: stashedIndexPathForSelectedRow, animated: false, scrollPosition: [])
```

このような動的な実装をした場合はお使いの[UICollectionView.CellRegistration](https://developer.apple.com/documentation/uikit/uicollectionview/cellregistration)の中でもステート変化に対応してCellの見た目が変わるように実装をしておきましょう。

例えば以下の例では、contentConfiguration, backgroundConfiguration, accessoriesをtraitCollectionによって分けて設定しています。

```swift
let cellRegiration = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { [unowned self] cell, indexPath, item in

    /*
    これは実際にプロジェクトで使っているコードの切り抜きですが、sidebarとして表示できる時はsidebarとして最適な見た目で表示し
    compactの場合はsingle columnなhierarchical navigationのルート階層として振る舞うのに最適な、通常のUITableViewっぽいスタイルを
    提供しています。
    */

    // コンテンツ設定
    var contentConfiguration: UIListContentConfiguration = {
        if self.splitViewController?.traitCollection.horizontalSizeClass == .compact {
            return cell.defaultContentConfiguration()
        } else {
            return UIListContentConfiguration.accompaniedSidebarCell()
        }
    }()

    contentConfiguration.image = ...
    contentConfiguration.text = ...
    cell.contentConfiguration = contentConfiguration

    // 背景設定
    if self.containerSplitViewController.traitCollection.horizontalSizeClass == .compact {
        cell.accessories = [.disclosureIndicator()]
        cell.backgroundConfiguration = UIBackgroundConfiguration.listGroupedCell()
        
    } else {
        cell.accessories = []
        cell.backgroundConfiguration = UIBackgroundConfiguration.listAccompaniedSidebarCell()
    }

}

```

# まとめ
この記事では、UICollectionViewCompositionalLayoutのクロージャを引数にとるイニシャライザをうまく活用することで、traitCollectionの変更や任意のきっかけに応じて動的にLayoutやAppearanceを変更できることを紹介しました。

# 参考情報
今回の記事は、こちらのAppleのサンプルコードをもとに記載しています。解説は、ImplementingModernCollectionViews>Modern Collection Views> ListAppearancesViewController.swift>L66~L75のcreateLayout()というコードがやっていることを噛み砕いたものです。

https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views

[^1]: (Apple inc., Apply the correct appearance to a sidebar, Sidebars, https://developer.apple.com/design/human-interface-guidelines/ios/bars/sidebars/, viewed: 2021/08/11)