WWDC Quick Look 💓 By SwiftGGTeam
Unleash the UIKit trait system

Unleash the UIKit trait system

元の動画を見る

ハイライト

iOS 17 の UIKit Trait System は、カスタム trait 定義、trait オーバーライド API、クロージャ式の trait 変化監視、SwiftUI environment key との双方向ブリッジをサポートし、UIKit と SwiftUI コンポーネント間でデータがシームレスに流れます。

主要内容

Trait の基礎おさらい

Trait は、システムが App 内のすべてのビューコントローラーとビューに伝播する独立したデータ片です。UIKit には多くのシステム trait があります。user interface style、horizontal size class、preferred content size category などです。

iOS 17 ではビューコントローラーとビューの trait 階層が統一されました。以前はビューコントローラーの trait が親コントローラーから直接継承し、ビューは所属コントローラーから継承していたため、ビュー階層での trait 伝播がコントローラーのビューで「途切れ」ていました。今は線形伝播に変更され、ビューコントローラーはビューの superview から trait を継承し、trait はビュー階層を自然に流れます。

重要な帰結として、viewWillAppear で trait collection にアクセスすると古い値が返ることがあります。この時点ではビューがまだ階層に参加していないためです。viewIsAppearing がより良い代替で、iOS 13 まで後方互換です。

カスタム Trait

iOS 17 では UITraitDefinition プロトコルで独自の trait を定義できます。デリゲート、クロージャ、シングルトンが不要な新しいデータ伝播パターンが開かれ、データは深くネストしたコンポーネントまで自動伝播します。

trait 定義時は、関連データ型に値型を使うのが最適です。Bool、Int、Double、Int raw value の enum が最も効率的です。カスタム struct には効率的な Equatable 実装が必要です。システムは trait 値を頻繁に比較して変化を検出します。

trait が色の解決に影響する場合は affectsColorAppearance = true を設定し、trait 変化時にビューを自動再描画します。オーバーヘッドが大きいため、変化頻度の低い trait にのみ使います。

Trait オーバーライド

iOS 17 では各 trait environment(window scene、window、view controller、view、presentation controller)に traitOverrides プロパティが追加されました。オーバーライドはそのオブジェクトとすべての子孫の trait 値を変更します。

traitOverrides はオーバーライドの存在確認(contains)と削除(remove)をサポートします。trait 値の読み取りは常に traitCollection から行い、未設定時に traitOverrides を直接読むと例外がスローされます。

パフォーマンス面では、各オーバーライドにわずかなコストがあります。必要な場所にのみ設定してください。変化時はすべての子孫の trait collection を更新するため、変更回数を最小化します。trait が少数の深いビューにのみ影響する場合は、ルートではなく最も近い共通祖先にオーバーライドを適用します。

Trait 変化の監視

traitCollectionDidChange は iOS 17 で非推奨です。システムは関心のある trait を知らないため、どの trait が変わってもコールバックが呼ばれ、スケールしません。

新しい registerForTraitChanges API はクロージャと target-action の両方をサポートします。関心のある trait のみ登録し、それらが変化したときだけコールバックします。サブクラス化不要で、どこからでも監視できます。

意味的なシステム trait セットも提供されます。systemTraitsAffectingColorAppearancesystemTraitsAffectingImageLookup を登録メソッドに直接渡せます。

登録は自動クリーンアップされます。高度なシナリオでは返されたトークンで手動登録解除できます。

UIKit と SwiftUI のブリッジ

カスタム UIKit trait とカスタム SwiftUI environment key をブリッジできます。environment key を UITraitBridgedEnvironmentKey に準拠させ、read と write メソッドを実装します。UIKit の trait オーバーライドが SwiftUI の environment に自動同期され、SwiftUI の environment modifier も UIKit の trait に同期します。

混合 UI アーキテクチャでのデータ伝播の課題を解決します。SwiftUI が UIKit にネストされていても、UIKit が SwiftUI にネストされていても、データは双方向に流れます。

詳細

Trait Collection の作成と変更

01:51

// Build a new trait collection instance from scratch
let myTraits = UITraitCollection { mutableTraits in
    mutableTraits.userInterfaceIdiom = .phone
    mutableTraits.horizontalSizeClass = .regular
}

// Get a new instance by modifying traits of an existing one
let otherTraits = myTraits.modifyingTraits { mutableTraits in
    mutableTraits.horizontalSizeClass = .compact
    mutableTraits.userInterfaceStyle = .dark
}

キーポイント:

  • UITraitCollection にクロージャ初期化子が追加
  • modifyingTraits で既存 trait collection の変更コピーを作成
  • UIMutableTraits は新しい可変コンテナプロトコル

単純なカスタム Trait の定義

09:06

struct ContainedInSettingsTrait: UITraitDefinition {
    static let defaultValue = false
}

let traitCollection = UITraitCollection { mutableTraits in
    mutableTraits[ContainedInSettingsTrait.self] = true
}

let value = traitCollection[ContainedInSettingsTrait.self]
// true

キーポイント:

  • UITraitDefinition プロトコルに準拠
  • defaultValue の提供が必須
  • 値型は defaultValue から推論(ここでは Bool)

カスタム Trait 用プロパティ構文の追加

10:23

struct ContainedInSettingsTrait: UITraitDefinition {
    static let defaultValue = false
}

extension UITraitCollection {
    var isContainedInSettings: Bool { self[ContainedInSettingsTrait.self] }
}

extension UIMutableTraits {
    var isContainedInSettings: Bool {
        get { self[ContainedInSettingsTrait.self] }
        set { self[ContainedInSettingsTrait.self] = newValue }
    }
}

let traitCollection = UITraitCollection { mutableTraits in
    mutableTraits.isContainedInSettings = true
}

let value = traitCollection.isContainedInSettings
// true

キーポイント:

  • UITraitCollection に読み取り専用プロパティを追加
  • UIMutableTraits に読み書きプロパティを追加
  • カスタム trait 定義時は常に両方の extension を書く

色の外観に影響するテーマ Trait の定義

11:00

enum MyAppTheme: Int {
    case standard, pastel, bold, monochrome
}

struct MyAppThemeTrait: UITraitDefinition {
    static let defaultValue = MyAppTheme.standard
    static let affectsColorAppearance = true
    static let name = "Theme"
    static let identifier = "com.myapp.theme"
}

extension UITraitCollection {
    var myAppTheme: MyAppTheme { self[MyAppThemeTrait.self] }
}

extension UIMutableTraits {
    var myAppTheme: MyAppTheme {
        get { self[MyAppThemeTrait.self] }
        set { self[MyAppThemeTrait.self] = newValue }
    }
}

キーポイント:

  • affectsColorAppearance = true で trait 変化時に自動再描画
  • name はデバッグ出力用
  • identifier は reverse-DNS 形式。エンコードなどの追加機能をサポート
  • Int raw value の enum が最も効率的

カスタムテーマ Trait の使用

12:33

let customBackgroundColor = UIColor { traitCollection in
    switch traitCollection.myAppTheme {
    case .standard:    return UIColor(named: "StandardBackground")!
    case .pastel:      return UIColor(named: "PastelBackground")!
    case .bold:        return UIColor(named: "BoldBackground")!
    case .monochrome:  return UIColor(named: "MonochromeBackground")!
    }
}

let view = UIView()
view.backgroundColor = customBackgroundColor

キーポイント:

  • dynamic color の provider クロージャでカスタム trait を読み取り
  • affectsColorAppearance = true ならテーマ変化時にビューが自動更新
  • 手動の変化監視やリフレッシュは不要

Trait オーバーライドの管理

18:05

func toggleThemeOverride(_ overrideTheme: MyAppTheme) {
    if view.traitOverrides.contains(MyAppThemeTrait.self) {
        // There's an existing theme override; remove it
        view.traitOverrides.remove(MyAppThemeTrait.self)
    } else {
        // There's no existing theme override; apply one
        view.traitOverrides.myAppTheme = overrideTheme
    }
}

キーポイント:

  • contains でオーバーライドの有無を確認
  • remove でオーバーライドを削除し継承値に復帰
  • 直接代入でオーバーライドを設定

クロージャで Trait 変化を登録

21:28

// Register for horizontal size class changes on self
registerForTraitChanges(
    [UITraitHorizontalSizeClass.self]
) { (self: Self, previousTraitCollection: UITraitCollection) in
    self.updateViews(sizeClass: self.traitCollection.horizontalSizeClass)
}

// Register for changes to multiple traits on another view
let anotherView: MyView
anotherView.registerForTraitChanges(
    [UITraitHorizontalSizeClass.self, ContainedInSettingsTrait.self]
) { (view: MyView, previousTraitCollection: UITraitCollection) in
    // Handle the trait change for this view...
}

キーポイント:

  • 実際に依存する trait のみ登録し、無関係なコールバックを避ける
  • クロージャの第1引数は監視対象オブジェクト。weak self は不要
  • self を監視する場合は (self: Self, ...) と書く

Target-Action で Trait 変化を登録

22:48

// Register for horizontal size class changes on self
registerForTraitChanges(
    [UITraitHorizontalSizeClass.self],
    action: #selector(UIView.setNeedsLayout)
)

// Register for changes to multiple traits on another view
let anotherView: MyView
anotherView.registerForTraitChanges(
    [UITraitHorizontalSizeClass.self, ContainedInSettingsTrait.self],
    target: self,
    action: #selector(handleTraitChange(view:previousTraitCollection:))
)

@objc func handleTraitChange(view: MyView, previousTraitCollection: UITraitCollection) {
    // Handle the trait change for this view...
}

キーポイント:

  • action メソッドは0、1、2個のパラメータを取れる
  • 第1引数は変化したオブジェクト、第2引数は変化前の trait collection
  • target 省略時は登録呼び出し元がデフォルト

UIKit Trait と SwiftUI Environment Key のブリッジ

26:19

enum MyAppTheme: Int {
    case standard, pastel, bold, monochrome
}

// Custom UIKit trait
struct MyAppThemeTrait: UITraitDefinition {
    static let defaultValue = MyAppTheme.standard
    static let affectsColorAppearance = true
}

extension UITraitCollection {
    var myAppTheme: MyAppTheme { self[MyAppThemeTrait.self] }
}

extension UIMutableTraits {
    var myAppTheme: MyAppTheme {
        get { self[MyAppThemeTrait.self] }
        set { self[MyAppThemeTrait.self] = newValue }
    }
}

// Custom SwiftUI environment key
struct MyAppThemeKey: EnvironmentKey {
    static let defaultValue = MyAppTheme.standard
}

extension EnvironmentValues {
    var myAppTheme: MyAppTheme {
        get { self[MyAppThemeKey.self] }
        set { self[MyAppThemeKey.self] = newValue }
    }
}

// Bridge SwiftUI environment key with UIKit trait
extension MyAppThemeKey: UITraitBridgedEnvironmentKey {
    static func read(from traitCollection: UITraitCollection) -> MyAppTheme {
        traitCollection.myAppTheme
    }

    static func write(to mutableTraits: inout UIMutableTraits, value: MyAppTheme) {
        mutableTraits.myAppTheme = value
    }
}

キーポイント:

  • EnvironmentKeyUITraitBridgedEnvironmentKey に準拠
  • read で UIKit trait から読み取り SwiftUI に返す
  • write で SwiftUI environment 値を UIKit trait に書き込む
  • 両側が同じデータにアクセス

UIKit で Trait を設定し SwiftUI で読み取り

27:01

// UIKit trait override applied to the window scene
let windowScene: UIWindowScene
windowScene.traitOverrides.myAppTheme = .monochrome

// Cell in a UICollectionView configured to display a SwiftUI view
let cell: UICollectionViewCell
cell.contentConfiguration = UIHostingConfiguration {
    CellView()
}

// SwiftUI view displayed in the cell, which reads the bridged value from the environment
struct CellView: View {
    @Environment(\.myAppTheme) var theme: MyAppTheme

    var body: some View {
        Text("Settings")
            .foregroundStyle(theme == .monochrome ? .gray : .blue)
    }
}

キーポイント:

  • window scene の trait オーバーライドが SwiftUI environment に自動伝播
  • SwiftUI は @Environment でブリッジ値を読み取り
  • SwiftUI が依存を自動追跡し、trait 変化時にビューが更新

SwiftUI で Environment を設定し UIKit で読み取り

28:16

// SwiftUI environment value applied to a UIViewControllerRepresentable
struct SettingsView: View {
    var body: some View {
        SettingsControllerRepresentable()
            .environment(\.myAppTheme, .standard)
    }
}

final class SettingsControllerRepresentable: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> SettingsViewController {
        SettingsViewController()
    }
    
    func updateUIViewController(_ uiViewController: SettingsViewController, context: Context) {
        // Update the view controller...
    }
}

// UIKit view controller contained in the SettingsControllerRepresentable
class SettingsViewController: UIViewController {
    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        title = settingsTitle(for: traitCollection.myAppTheme)
    }
    
    func settingsTitle(for theme: MyAppTheme) -> String {
        switch theme {
        case .standard:   return "Standard"
        case .pastel:     return "Pastel"
        case .bold:       return "Bold"
        case .monochrome: return "Monochrome"
        }
    }
}

キーポイント:

  • SwiftUI の .environment modifier は UIKit の trait オーバーライドと同等
  • UIKit コードは traitCollection.myAppTheme でブリッジ値を読み取り
  • 双方向ブリッジでフレームワーク間をシームレスにデータが流れる

重要ポイント

1. 深いデータ伝播をカスタム trait に置き換え

  • 何を作るか: デリゲート、クロージャ、NotificationCenter で渡すコンテキスト情報をカスタム trait に変更
  • 価値がある理由: trait は階層に沿って自動伝播。深くネストしたコンポーネントも直接読み取れ、コードが簡潔に
  • 始め方: UITraitDefinition を定義し、共通祖先で traitOverrides を設定、必要なビューで traitCollection を読み取る

2. App にマルチテーマ対応を追加

  • 何を作るか: カスタム theme trait で App 内テーマ切り替え(標準、パステル、bold、モノクロなど)を実装
  • 価値がある理由: trait 変化時に dynamic color が自動再描画。各ビューの手動リフレッシュが不要
  • 始め方: MyAppThemeTrait を定義し affectsColorAppearance = trueUIColor { traitCollection in ... } で dynamic color を作成

3. traitCollectionDidChange を registerForTraitChanges に移行

  • 何を作るか: traitCollectionDidChange のオーバーライドを registerForTraitChanges に置き換え
  • 価値がある理由: 関心のある trait のみ監視し、不要なコールバックを減らしてパフォーマンス向上
  • 始め方: traitCollectionDidChange 実装を見つけ、registerForTraitChanges([関心のあるtrait]) { ... } で置き換え

4. 混合 UIKit/SwiftUI プロジェクトでデータをブリッジ

  • 何を作るか: 両フレームワーク間で共有するデータをブリッジ trait/environment key にする
  • 価値がある理由: 追加の同期ロジック不要。片方で設定すればもう片方が自動感知
  • 始め方: UIKit trait と SwiftUI EnvironmentKey を定義し、key を UITraitBridgedEnvironmentKey に準拠

5. traitOverrides で局所的な UI 調整

  • 何を作るか: 特定のビューコントローラーやビューで trait をオーバーライドし、局所的な size class、テーマ、スタイル調整を実現
  • 価値がある理由: 手動状態伝播より宣言的。子孫ビューが自動継承
  • 始め方: 調整が必要なビューに view.traitOverrides.xxx = value を追加

関連セッション

コメント

GitHub Issues · utterances