Upgrade to Pro — share decks privately, control downloads, hide ads and more …

UIKit ベースの Custom UIContentConfiguration API を 用いた複雑なカスタムセルの作り方

Go Takagi
September 01, 2023

UIKit ベースの Custom UIContentConfiguration API を 用いた複雑なカスタムセルの作り方

iOSDC2023 Day 0 Track B 19:20〜
UIKit ベースの Custom UIContentConfiguration API を 用いた複雑なカスタムセルの作り方

iOS 14 からUICollectionViewのセルのカスタマイズ方法にConfigurationというAPIが追加されました。

これまでカスタムセルはサブクラスを作成して実装していました。Configurationにより、セルレイアウトと外観を分離し再利用しやすいComposableなAPIの管理ができます。

また縦方向のリスト形式の例では削除や並び替えといった機能と滑らかな表示体験が提供でき、安全にOSの機能を考慮した複雑なカスタムセルが実装できます。

本セッションでは日本経済新聞社の紙面ビューアーアプリで実際に導入したUIKitベースのカスタム例を紹介します。
UIKitベースのパターンは実装例が比較的少ない一方、依然として重要な選択肢です。SwiftUIベースでは困難な制約やリスト表示のパフォーマンス観点などを踏まえ、仕組みからUIKitとSwiftUIの理解を深めます。

Go Takagi

September 01, 2023
Tweet

More Decks by Go Takagi

Other Decks in Technology

Transcript

  1. Keynote ‣ UIKit に⼊ってきた Con fi guration という概念を学ぶ • UI

    更新を安全に⾏う仕組み ‣ UICollectionViewでUIContentCon fi gurationを使いこなそう! • Custom 実装例が少ない • 仕組みを理解してどうやって作ればいいかを知る ‣ ちなみに UIHostingCon fi guration は? • 特徴を整理して使い分けを考える 2
  2. Me ( Go Takagi ) ‣ID • shimastripe / shimastriper

    • bento.me/shimastripe ‣Work at • 株式会社 ⽇本経済新聞社 ‣ iOS エンジニア ‣ iOSDC NOC チーム ‣Like • Swift / ⾃動化 / 柴⽝ 3 電⼦版広報⽝デンシバ 去年は PencilKit の話!
  3. UI における Con fi guration ‣ データ構成をベースに、view のコンテンツとスタイルを更新する • 構成(Data)に基づいて

    UI を決めておく宣⾔的なAPI • レンダリングに影響するコードを直接呼ばない (管理しない) • 基本 Struct ベース ‣ 新しい Con fi guration を適⽤すると状態がリセットされて更新 • view を使いまわしても過去の view の状態に引っ張られない • リセット忘れといったバグを⽣まずに安全に再利⽤できる 5 https://developer.apple.com/documentation/uikit/appearance_customization/con fi gurations
  4. 例: UIButton.Con fi guration (iOS 15 +) ‣ UIButton を⾃由にスタイリング可能に

    • コンテンツや外観を⾃由にいじれる • 上書きしたら新しい Con fi guration で UI 描画される ‣ 他にもバリエーションは増えている • UIPasteControl.Con fi guration (iOS 16 +) • UIContentUnavailableCon fi guration (iOS 17 +) • TVMediaItemContentCon fi guration (tvOS 1 5 +) 6 https://developer.apple.com/wwdc 23 / 10 05 5
  5. Con fi guration が反映、前の状態は引き継がない 7 var config = UIButton.Configuration.bordered() config.title

    = "iOSDC 2023" config.baseForegroundColor = .systemRed button.configuration = config
  6. Con fi guration が反映、前の状態は引き継がない 8 var config = UIButton.Configuration.bordered() config.title

    = "iOSDC 2023" config.baseForegroundColor = .systemRed button.configuration = config var newConfig = UIButton.Configuration.filled() newConfig.title = "iOSDC 2023" button.configuration = newConfig 新たな Con fi g を Set
  7. iOS 1 4 : UICollectionView List ‣ UITableView ライクで Section

    表⽰できるように 1 0 https://developer.apple.com/videos/play/wwdc 20 20 / 10 097 / 横スクロール insetGrouped List Sidebar List (折りたたみ)
  8. Cell lifecycle : 作った View を再利⽤する 1 1 https://developer.apple.com/wwdc 21

    / 10 25 2 Preparation Display Con fi gure Cell Display Cell Dequeue Cell Pool から Cell を取り出す 取り出した Cell を加⼯する 表⽰、画⾯外に出たら Pool に戻す → Enqueue Cell
  9. Cell へのデータ反映も Con fi guration だと嬉しい 1 7 別の Con

    fi guration を Set ! 😄 安全に更新 Reuse Pool
  10. ListContentCon fi guration ‣ List Cell コンテンツ (ContentView) が作れる •

    CollectionViewCell に contentCon fi guration をセット • Text / SecondaryText / Image などのデータを設定 1 8 https://developer.apple.com/videos/play/wwdc 20 20 / 10 097 / var contentConfiguration = UIListContentConfiguration.cell() contentConfiguration.image = UIImage(systemName: "hammer") contentConfiguration.text = "Ready. Set. Code." cell.contentConfiguration = contentConfiguration
  11. ContentCon fi guration ‣ 今までの Cell • サブクラスを作って、ContentView に addSubView()

    して中⾝を実装 ‣ ContentCon fi guration • CollectionViewCell (⼊れ物) + ContentCon fi guration (中⾝) に分離 • 別種類のセルで ContentCon fi guration を共有可能、Composable な構成に • UICollectionViewCell や UITableViewCell で共有したり 1 9
  12. Reminder の CollectionViewListCell の活⽤ 2 1 同じ ContentCon fi guration

    を適⽤ Root Cell は disclosure ∨ を付加 ほとんど同じ HeaderView‧BodyCell を⽤意しなくて済む 😄
  13. UIContentView: カスタマイズする View ‣ Con fi guration をセットできる view (UIView)

    を作る • ここに Label や Button などコンテンツとなる View を実装する 2 3 @MainActor public protocol UIContentView : NSObjectProtocol { @MainActor var configuration: UIContentConfiguration { get set } }
  14. UIContentCon fi guration: データ構成 ‣ Con fi guration (データ構成) •

    ContentView を⽣成することができる 2 4 public protocol UIContentConfiguration { @MainActor func makeContentView() -> UIView & UIContentView }
  15. 2 通りの動きをサポートする ‣ Reuse されたことがある Cell に Con fi guration

    をセットした場合 • ContentView に Con fi guration をセットして Update する ‣ Reuse されたことがない Cell に Con fi guration をセットした場合 • Con fi guration から ContentView を⽣成する 2 5
  16. Point: Reuse Cell は ContentView まで Cache ‣ Reuse されたことがある

    Cell に Con fi guration をセットした場合 • ContentView に Con fi guration をセットして Update する ‣ Reuse されたことがない Cell に Con fi guration をセットした場合 • Con fi guration から ContentView を⽣成する 2 9
  17. final class CustomContentView: UIView, UIContentView { init(configuration: CustomConfiguration) { super.init(frame:

    .zero) setupInternalViews() apply(configuration: configuration) } private func setupInternalViews() { // View ͷॳظԽॲཧΛߦ͏ɺ addSubView() ΍ AutoLayout } private var appliedConfiguration: CustomConfiguration! var configuration: UIContentConfiguration { get { appliedConfiguration } set { apply(configuration: newValue as! CustomConfiguration) } } private func apply(configuration: CustomConfiguration) { guard appliedConfiguration != configuration else { return } appliedConfiguration = configuration // ඞཁʹԠͯ͡஋ͷ Reset Λߦ͏ titleLabel.text = configuration.title }
  18. final class CustomContentView: UIView, UIContentView { init(configuration: CustomConfiguration) { super.init(frame:

    .zero) setupInternalViews() apply(configuration: configuration) } private func setupInternalViews() { // View ͷॳظԽॲཧΛߦ͏ɺ addSubView() ΍ AutoLayout } private var appliedConfiguration: CustomConfiguration! var configuration: UIContentConfiguration { get { appliedConfiguration } set { apply(configuration: newValue as! CustomConfiguration) } } private func apply(configuration: CustomConfiguration) { guard appliedConfiguration != configuration else { return } appliedConfiguration = configuration // ඞཁʹԠͯ͡஋ͷ Reset Λߦ͏ titleLabel.text = configuration.title } ContentView ⽣成時に呼ばれる 
 内部で使う SubView は init()で追加する 1 . Make
  19. final class CustomContentView: UIView, UIContentView { init(configuration: CustomConfiguration) { super.init(frame:

    .zero) setupInternalViews() apply(configuration: configuration) } private func setupInternalViews() { // View ͷॳظԽॲཧΛߦ͏ɺ addSubView() ΍ AutoLayout } private var appliedConfiguration: CustomConfiguration! var configuration: UIContentConfiguration { get { appliedConfiguration } set { apply(configuration: newValue as! CustomConfiguration) } } private func apply(configuration: CustomConfiguration) { guard appliedConfiguration != configuration else { return } appliedConfiguration = configuration // ඞཁʹԠͯ͡஋ͷ Reset Λߦ͏ titleLabel.text = configuration.title } Con fi guration がセットされる度に呼ばれる 
 View へ値のリセットと反映‧Hiddenの切り替え‧タップイベントの登録 2 . Update
  20. Cell × ContentView の種類だけ Pool にいる 3 3 Reuse Pool

    CollectionViewCell 表⽰領域 CollectionViewCell
  21. ContentView が異なる Con fi guration をセット!? 3 4 CollectionViewCell Reuse

    された Cell Custom Con fi guration Warning: You are setting a new content configuration to a cell that has an existing content configuration, but the existing content view does not support the new configuration. This means the existing content view must be replaced with a new content view created from the new configuration, instead of updating the existing content view directly, which is expensive. Use separate registrations or reuse identifiers for different types of cells to avoid this. WARNINGS
  22. Cell Registration で Reuse Identi fi er も不要 ‣ データ構造

    と Reuse Cell 単位が簡潔に結びつく 3 5 let cellRegistration = CellRegistration<Cell, Item> { (cell, indexPath, item) in cell.contentConfiguration = CustomConfiguration(item: item) } let otherRegistration = CellRegistration<Cell, OtherItem> { (cell, indexPath, item) in cell.contentConfiguration = OtherConfiguration(item: item) } Reuse 単位
  23. Cell Registration で Reuse Identi fi er も不要 ‣ Closure

    内で切り替えると Reuse 効率が悪くなる可能性がある 3 6 let cellRegistration = CellRegistration<Cell, Item> { (cell, indexPath, item) in if item.category == .dog { cell.contentConfiguration = DogConfiguration(item: item) } else { cell.contentConfiguration = CatConfiguration(item: item) } } ❌ Reuse 単位 対応する ContentView が異なるとダメ
  24. UIHostingCon fi guration ‣ Cell の contentCon fi guration を

    SwiftUI で作れる • BackgroundCon fi guration もサポート • iOS 16 + ‣ 画期的!これからはこれで All OK? 3 8 cell.contentConfiguration = UIHostingConfiguration { HeartRateBPMView() } https://developer.apple.com/wwdc 22 / 10 07 2 ヘルスケアアプリで利⽤ SwiftUI View
  25. UIViewRepresentable が使えない ‣ あくまでSwiftUI Only の View を作らないとダメ • UIKit

    のコンポーネントを SwiftUI 化したものは利⽤できない • 以下は SwiftUI にないため HostingCon fi guration 上で使⽤不可 • WKWebView • MPVolumeView (⾳量コントロール) • etc ... • 動画プレイヤーを AVVideoPlayer で代替する⾏為はできない • SwiftUI バグ回避のための UIKit 利⽤みたいな Workaround もできない 3 9 (20230906 追記 間違っているため次ページ参照)
  26. UIViewControllerRepresentable が使えない ‣ embedding view controllers inside of Cell は⼀般的に⾮推奨で、Hostingでは使えない

    • 検証した結果、UIViewRepresentable は問題なく動作しました ‣ あくまでSwiftUI Only の View を作らないとダメ • UIKit のコンポーネントを SwiftUI 化したものは利⽤できない • 以下は SwiftUI にないため HostingCon fi guration 上で使⽤不可 • WKWebView • MPVolumeView (⾳量コントロール) • etc ... • 動画プレイヤーを AVVideoPlayer で代替する⾏為はできない • SwiftUI バグ回避のための UIKit 利⽤みたいな Workaround もできない 4 0 (20230906 追記)
  27. UIHostingController (iOS 13 +) ‣ SwiftUI View を持つ ViewController •

    今まではこれしかなかった ‣ SwiftUI → UIKit ブリッジ ができる • Cell をこれで作ろうとした⼈も多いハズ 4 1 https://developer.apple.com/wwdc 22 / 10 07 2 UIViewController view SwiftUI Content
  28. パフォーマンスが悪い、特に List のスクロール ‣ セルの要素が多いと描画がカクつきながら配置 • あまり凝ってない Cell でも体感でカクつく、、 ‣

    アニメーションをつけると Reuse 前の情報が出てしまう • Cell の Self-sizing とも少しズレてて違和感 4 3 contentConfiguration = UIHostingConfiguration { let patA = Bool.random() Text(patA ? "iOSDC" : "2023") .padding(patA ? 20 : 0) .background(patA ? Color.yellow : .mint) .animation(.easeInOut) }
  29. HostingCon fi guration の Reuse 周りが気になる ‣ List はスクロールして Cell

    を⾼速に使い回す • パフォーマンスが重要 • ContentView は Reuse される? • SwiftUIContent の View の lifetime 管理は影響ありそうか? • いわゆる View Identity ※ 実装がわからないため動作ベースの検証に留まります 4 4
  30. HostingContentView (Private Class) ‣ HostingCon fi guration の ContentView •

    内部的には UIHostingContentView<Content: View, Background: View> • View の body の型 (Structual Identity) 単位で Reuse されていた 4 5 contentConfiguration = UIHostingConfiguration { if Bool.random() { Text("iOSDC") } else { Image(systemName: "star") } } .background(.background) UIHostingContentView<_ConditionalContent<Text, Image>, _UIHostingConfigurationBackgroundView<BackgroundStyle>>
  31. ContentView の Cache はされていそう 4 6 contentConfiguration = UIHostingConfiguration {

    Text("iOSDC") } // UIHostingContentView<Text, EmptyView> contentConfiguration = UIHostingConfiguration { Image(systemName: "star") } // UIHostingContentView<Image, EmptyView> // warning: the existing content view does not // support the new configuration!! body (Structural) の型が違うと 
 HostingCon fi guration でも Warning (ちなみに AnyView でくくると怒られない)
  32. View Identity が影響しないか ‣ View の同値性を意識して lifetime を管理する • ◎

    だと更新する、△ だと完全に作り直されてしまう • Xcode 1 5 で細かく覗けそう......? 4 7 contentConfiguration = UIHostingConfiguration { let patA = Bool.random() Text(patA ? "iOSDC" : "2023") if patA { Text("iOSDC") } else { Text("2023") } } ◎ △
  33. Con fi guration 上書きでは作り直しになってそう? 4 8 Text(patA ? "iOSDC" :

    "2023") if patA { Text("iOSDC") } else { Text("2023") } ◎ △ Con fi gurationを再セット SwiftUI 上で更新
  34. 個⼈的 HostingCon fi guration 使い分け ‣ Performance を意識する画⾯ (Cell) なら

    UIContentCon fi guration • List のような Reuse 頻度が⾼い場合 • ニュースの⾒出しの⼀覧はサクサク表⽰したい......!! • 要素の表⽰が動的に変わる場合 • 現状の調査だと View が再⽣成されてしまいそう ‣ 利⽤頻度が低い Cell なら HostingCon fi guration • Reuse 頻度が低ければ問題にならない • メリハリをつけるためのサイズが⼤きい Cell • Reuse 単位を変更して、View内の条件分岐を避けてみる? • ただし、SwiftUI で完結が必須、UIViewControllerRepresntableは使えない、iOS 16 + も 4 9 (20230906 追記)
  35. UIContentView の暗黙的な AutoLayout ‣ contentCon fi guration に設定すると 44px の⾼さ制約が付加

    • 例えば中の ImageView に⾼さをつけておくと衝突する ‣ ⾼さ制約の Priority をずらして衝突を回避する 5 1 <NSAutoresizingMaskLayoutConstraint:0x000000000000 h=--& v=--& CustomContentView:0x00000000.height == 44 (active)> let const = imageView.heightAnchor.constraint(equalToConstant: 200) const.priority = .defaultHigh NSLayoutConstraint.activate([const])
  36. CompositionalLayout.list は Scroll Performance ◎ ‣ CollectionViewCompositionalLayout • ⾃前実装と⽐較してみる •

    スクロールの Hitch が少なく、滑らか • 縦スクロールコンテンツは基本 List がオススメ 5 2 List estimated 再現 NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44) ) === ൺֱ === UICollectionViewCompositionalLayout.list(using: .plain)
  37. (Collectionview) invalidateLayout() を避ける ‣ 画⾯回転時に呼んでいる事例が多い • しかし、道中の Cell のサイズ計算全てがリセットされてしまう •

    スクロールの途中から戻ると表⽰時に伸びる奇っ怪な動きになったり • 以下を先に検討してみるといい ‣ selfSizingInvalidation (iOS 16 +) • CollectionView.selfSizingInvalidation = .enabledIncludingConstraints ‣ recon fi gureSnapshot() (iOS 15 +) • 表⽰している Cell をターゲットに Di ff ableDataSource に apply() 5 3
  38. Cell 側の状態変化で反映: UICon fi gurationState ‣ 複数の⼿段があるので受け取りたい箇所で実装する • Cell のサブクラス:

    iOS 1 4 + • updateCon fi guration() • Cell に Closure を登録: iOS 1 5 + • cellUpdateHandler • ContentCon fi guration: iOS 1 4 + • updated(for: ) ‣ isSelected / isFocused などの Cell の状態を受信 • それに応じた contentCon fi guration を作ってセットする (選択中は⾊を変えるなど) 5 4
  39. まとめ ‣ Con fi guration の概念を理解 • UIKit でも Data-driven

    に安全に UI を構築する仕組みが⼊ってきている ‣ ContentCon fi guration の仕組みとカスタム実装を紹介 • Cell をサブクラス化する必要がない時代へ • 複数の Cell で ContentCon fi guration を再利⽤できる Composable な API ‣ List レイアウト における UIHostingCon fi guration との使いわけ • HostingCon fi guration は SwiftUI だけで完結しないといけない UIViewRepresentableの範囲まで • Cell においては ContentView の Cache は⾏われていそう • 特に List 系の再利⽤時パフォーマンスに注意、取捨選択 ‣ 移⾏していけば⾃然と安全に利⽤を実現する仕組み!使っていきましょう! 5 5 (20230906 追記)