見出し画像

UIKit CatalogのOutline UIのコードを読み解く

UIKit Catalogという、かなり昔からある公式サンプルがある。

このサンプルは更新され続けていて、最新版ではAvailabilityはiOS 14+となっており、起動するとトップのビューは次のようないわゆるドリルダウンUI的な、タップでサブ要素を展開するUIになっている。

画像1


こういうUIは昔からOSS(や自前実装)では実現されていたが、iOS 14からは標準でサポートされた、というのはなんとなく伝え聞いていた。Appleは"Outline"と呼んでいるようだ。

コードを読んでみたところ、実態こそ昔からあるUICollectionViewなのだが、

- UICollectionViewDiffableDataSource (iOS 13〜)
- UICollectionViewCompositionalLayout (iOS 13〜)
- UICollectionView.CellRegistration(iOS 14〜)
- UICollectionViewListCell(iOS 14〜)
- UIListContentConfiguration(iOS 14〜)
- UICellAccessory(iOS 14〜)
- NSDiffableDataSourceSectionSnapshot (iOS 14〜)

という感じで新要素が入り混じっていて、レガシーiOSエンジニアな自分には少々複雑だった...

多くのことを学ぶという目的にはこれで良いのだろうけど、

・OutlineスタイルのUIを実装したい
・テーブルUIをUICollectionViewで実装したい

といった「やりたいことに対して何が最小限必要なのか」を知るにはわかりにくいサンプルだと感じた。

ちなみにそのコードを載せておく。

import UIKit

class OutlineViewController: UIViewController {

   enum Section {
       case main
   }

   class OutlineItem: Identifiable, Hashable {
       let title: String
       let subitems: [OutlineItem]
       let storyboardName: String?
       let imageName: String?

       init(title: String, imageName: String?, storyboardName: String? = nil, subitems: [OutlineItem] = []) {
           self.title = title
           self.subitems = subitems
           self.storyboardName = storyboardName
           self.imageName = imageName
       }
       
       func hash(into hasher: inout Hasher) {
           hasher.combine(id)
       }
       
       static func == (lhs: OutlineItem, rhs: OutlineItem) -> Bool {
           return lhs.id == rhs.id
       }

   }

   var dataSource: UICollectionViewDiffableDataSource<Section, OutlineItem>! = nil
   var outlineCollectionView: UICollectionView! = nil

   override func viewDidLoad() {
       super.viewDidLoad()

       configureCollectionView()
       configureDataSource()
       
       // Add a translucent background to the primary view controller for the Mac.
       splitViewController!.primaryBackgroundStyle = .sidebar
       view.backgroundColor = UIColor.clear
       
       // Listen for when a split view controller is expanded or collapsed.
       NotificationCenter.default.addObserver(
           self,
           selector: #selector(showDetailTargetDidChange(_:)),
           name: UIViewController.showDetailTargetDidChangeNotification,
           object: nil)
       
       if navigationController!.traitCollection.userInterfaceIdiom == .mac {
           navigationController!.navigationBar.isHidden = true
       }
   }
   
   // Posted when a split view controller is expanded or collapsed.
   @objc
   func showDetailTargetDidChange(_ notification: NSNotification) {
       // Reaload the data source, the disclosure indicators need to change (push vs. present on a cell).
       var snapshot = dataSource.snapshot()
       snapshot.reloadItems(menuItems)
       dataSource.apply(snapshot, animatingDifferences: false)
   }
   
   deinit {
       NotificationCenter.default.removeObserver(self, name: UIViewController.showDetailTargetDidChangeNotification, object: nil)
   }
   
   lazy var controlsOutlineItem: OutlineItem = {
       var controlsSubItems = [
           OutlineItem(title: NSLocalizedString("ButtonsTitle", comment: ""), imageName: nil,
                       storyboardName: "ButtonViewController"),
           
           OutlineItem(title: NSLocalizedString("PageControlTitle", comment: ""), imageName: nil, subitems: [
               OutlineItem(title: NSLocalizedString("DefaultPageControlTitle", comment: ""), imageName: nil,
                           storyboardName: "DefaultPageControlViewController"),
               OutlineItem(title: NSLocalizedString("CustomPageControlTitle", comment: ""), imageName: nil,
                           storyboardName: "CustomPageControlViewController")
           ]),
           
           OutlineItem(title: NSLocalizedString("SearchBarsTitle", comment: ""), imageName: nil, subitems: [
               OutlineItem(title: NSLocalizedString("DefaultSearchBarTitle", comment: ""), imageName: nil,
                           storyboardName: "DefaultSearchBarViewController"),
               OutlineItem(title: NSLocalizedString("CustomSearchBarTitle", comment: ""), imageName: nil,
                           storyboardName: "CustomSearchBarViewController")
           ]),
           
           OutlineItem(title: NSLocalizedString("SegmentedControlsTitle", comment: ""), imageName: nil,
                       storyboardName: "SegmentedControlViewController"),
           OutlineItem(title: NSLocalizedString("SlidersTitle", comment: ""), imageName: nil,
                       storyboardName: "SliderViewController"),
           OutlineItem(title: NSLocalizedString("SwitchesTitle", comment: ""), imageName: nil,
                       storyboardName: "SwitchViewController"),
           OutlineItem(title: NSLocalizedString("TextFieldsTitle", comment: ""), imageName: nil,
                       storyboardName: "TextFieldViewController")
       ]
       
       #if !targetEnvironment(macCatalyst)
       /** Because this sample has "Optimize Interface for Mac" turned on -
           UIStepper class is not supported when running Mac Catalyst apps in the Mac idiom.
       */
       let stepperItem =
           OutlineItem(title: NSLocalizedString("SteppersTitle", comment: ""), imageName: nil,
                       storyboardName: "StepperViewController")
       controlsSubItems.append(stepperItem)
       #endif
       
       return OutlineItem(title: "Controls", imageName: "slider.horizontal.3", subitems: controlsSubItems)
   }()
   
   lazy var pickersOutlineItem: OutlineItem = {
       var pickerSubItems = [
           OutlineItem(title: NSLocalizedString("DatePickerTitle", comment: ""), imageName: nil,
                       storyboardName: "DatePickerController"),
           OutlineItem(title: NSLocalizedString("ColorPickerTitle", comment: ""), imageName: nil,
                       storyboardName: "ColorPickerViewController"),
           OutlineItem(title: NSLocalizedString("FontPickerTitle", comment: ""), imageName: nil,
                       storyboardName: "FontPickerViewController"),
           OutlineItem(title: NSLocalizedString("ImagePickerTitle", comment: ""), imageName: nil,
                       storyboardName: "ImagePickerViewController")
       ]
       
       #if !targetEnvironment(macCatalyst)
       /** Because this sample has "Optimize Interface for Mac" turned on -
           UIPickerView class is not supported when running Mac Catalyst apps in the Mac idiom.
       */
       let pickerViewItem =
           OutlineItem(title: NSLocalizedString("PickerViewTitle", comment: ""), imageName: nil,
                       storyboardName: "PickerViewController")
       pickerSubItems.append(pickerViewItem)
       #endif
       
       return OutlineItem(title: "Pickers", imageName: "list.bullet", subitems: pickerSubItems)
   }()
   
   lazy var viewsOutlineItem: OutlineItem = {
       OutlineItem(title: "Views", imageName: "rectangle.stack.person.crop", subitems: [
           OutlineItem(title: NSLocalizedString("ActivityIndicatorsTitle", comment: ""), imageName: nil,
                       storyboardName: "ActivityIndicatorViewController"),
           OutlineItem(title: NSLocalizedString("AlertControllersTitle", comment: ""), imageName: nil,
                       storyboardName: "AlertControllerViewController"),
           OutlineItem(title: NSLocalizedString("ImageViewTitle", comment: ""), imageName: nil,
                       storyboardName: "ImageViewController"),
           OutlineItem(title: NSLocalizedString("ProgressViewsTitle", comment: ""), imageName: nil,
                       storyboardName: "ProgressViewController"),
           OutlineItem(title: NSLocalizedString("StackViewsTitle", comment: ""), imageName: nil,
                       storyboardName: "StackViewController"),
           
           OutlineItem(title: NSLocalizedString("ToolbarsTitle", comment: ""), imageName: nil, subitems: [
               OutlineItem(title: NSLocalizedString("DefaultToolBarTitle", comment: ""), imageName: nil,
                           storyboardName: "DefaultToolbarViewController"),
               OutlineItem(title: NSLocalizedString("TintedToolbarTitle", comment: ""), imageName: nil,
                           storyboardName: "TintedToolbarViewController"),
               OutlineItem(title: NSLocalizedString("CustomToolbarBarTitle", comment: ""), imageName: nil,
                           storyboardName: "CustomToolbarViewController")
           ]),
           
           OutlineItem(title: NSLocalizedString("WebViewTitle", comment: ""), imageName: nil,
                       storyboardName: "WebViewController")
       ])
   }()
   
   private lazy var menuItems: [OutlineItem] = {
       return [
           controlsOutlineItem,
           viewsOutlineItem,
           pickersOutlineItem
       ]
   }()

}

// MARK: - UICollectionViewDiffableDataSource

extension OutlineViewController {

   private func configureCollectionView() {
       let collectionView =
           UICollectionView(frame: view.bounds, collectionViewLayout: generateLayout())
       view.addSubview(collectionView)
       collectionView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
       self.outlineCollectionView = collectionView
       collectionView.delegate = self
   }

   private func configureDataSource() {

       let containerCellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, OutlineItem> { (cell, indexPath, menuItem) in

           var contentConfiguration = cell.defaultContentConfiguration()
           contentConfiguration.text = menuItem.title
          
           if menuItem.imageName != nil {
               contentConfiguration.image = UIImage(systemName: menuItem.imageName!)
           }
           
           contentConfiguration.textProperties.font = .preferredFont(forTextStyle: .headline)
           cell.contentConfiguration = contentConfiguration
           
           let disclosureOptions = UICellAccessory.OutlineDisclosureOptions(style: .header)
           cell.accessories = [.outlineDisclosure(options:disclosureOptions)]
           
           let background = UIBackgroundConfiguration.clear()
           cell.backgroundConfiguration = background
       }
       
       let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, OutlineItem> { cell, indexPath, menuItem in
           var content = UIListContentConfiguration.cell()
           content.text = menuItem.title
           cell.contentConfiguration = content
           
           let background = UIBackgroundConfiguration.clear()
           cell.backgroundConfiguration = background
           
           cell.accessories = self.splitViewWantsToShowDetail() ? [] : [.disclosureIndicator()]
       }
       
       dataSource = UICollectionViewDiffableDataSource<Section, OutlineItem>(collectionView: outlineCollectionView) {
           (collectionView: UICollectionView, indexPath: IndexPath, item: OutlineItem) -> UICollectionViewCell? in
           // Return the cell.
           if item.subitems.isEmpty {
               return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
           } else {
               return collectionView.dequeueConfiguredReusableCell(using: containerCellRegistration, for: indexPath, item: item)
           }
       }

       // Load our initial data.
       let snapshot = initialSnapshot()
       self.dataSource.apply(snapshot, to: .main, animatingDifferences: false)
   }

   private func generateLayout() -> UICollectionViewLayout {
       let listConfiguration = UICollectionLayoutListConfiguration(appearance: .sidebar)
       let layout = UICollectionViewCompositionalLayout.list(using: listConfiguration)
       return layout
   }

   private func initialSnapshot() -> NSDiffableDataSourceSectionSnapshot<OutlineItem> {
       var snapshot = NSDiffableDataSourceSectionSnapshot<OutlineItem>()

       func addItems(_ menuItems: [OutlineItem], to parent: OutlineItem?) {
           snapshot.append(menuItems, to: parent)
           for menuItem in menuItems where !menuItem.subitems.isEmpty {
               addItems(menuItem.subitems, to: menuItem)
           }
       }
       
       addItems(menuItems, to: nil)
       return snapshot
   }

}

// MARK: - UICollectionViewDelegate

extension OutlineViewController: UICollectionViewDelegate {
   
   private func splitViewWantsToShowDetail() -> Bool {
       return splitViewController?.traitCollection.horizontalSizeClass == .regular
   }
   
   private func pushOrPresentViewController(viewController: UIViewController) {
       if splitViewWantsToShowDetail() {
           let navVC = UINavigationController(rootViewController: viewController)
           splitViewController?.showDetailViewController(navVC, sender: navVC) // Replace the detail view controller.
           
           if navigationController!.traitCollection.userInterfaceIdiom == .mac {
               navVC.navigationBar.isHidden = true
           }
       } else {
           navigationController?.pushViewController(viewController, animated: true) // Just push instead of replace.
       }
   }
   
   private func pushOrPresentStoryboard(storyboardName: String) {
       let exampleStoryboard = UIStoryboard(name: storyboardName, bundle: nil)
       if let exampleViewController = exampleStoryboard.instantiateInitialViewController() {
           pushOrPresentViewController(viewController: exampleViewController)
       }
   }
   
   func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
       guard let menuItem = self.dataSource.itemIdentifier(for: indexPath) else { return }
       
       collectionView.deselectItem(at: indexPath, animated: true)
   
       if let storyboardName = menuItem.storyboardName {
           pushOrPresentStoryboard(storyboardName: storyboardName)
       }
   }
   
}

iPad向け、macOS向けの実装も入っていて、それらも複雑性を増している。

というわけで、いらない要素を取り除いていき、「何がOutlineスタイルのUI実装における必要最小限なのか」を読み解いてみた。

記事末尾に整理したサンプルコードを添付しています。不要な要素は取り除き、メインのOutlineViewControllerは約100行以下まで削減できました。

## 結論: Outline UIとしての実態はどこなのか?

いきなり結論だが、Outline UIをOutline UIたらしめている部分は、もともと300行近くあったコードのうち、以下の2行だった。

ここから先は

4,454字 / 1ファイル
文章やサンプルコードは多少荒削りかもしれませんが、ブログや書籍にはまだ書いていないことを日々大量に載せています。たったの400円で、すぐに購読解除してもその月は過去記事もさかのぼって読めるので、少しでも気になる内容がある方にはオトクかと思います。

技術的なメモやサンプルコード、思いついたアイデア、考えたこと、お金の話等々、頭をよぎった諸々を気軽に垂れ流しています。

最後まで読んでいただきありがとうございます!もし参考になる部分があれば、スキを押していただけると励みになります。 Twitterもフォローしていただけたら嬉しいです。 https://twitter.com/shu223/