ハイライト
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 セットも提供されます。systemTraitsAffectingColorAppearance、systemTraitsAffectingImageLookup を登録メソッドに直接渡せます。
登録は自動クリーンアップされます。高度なシナリオでは返されたトークンで手動登録解除できます。
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
}
}
キーポイント:
EnvironmentKeyをUITraitBridgedEnvironmentKeyに準拠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 の
.environmentmodifier は UIKit の trait オーバーライドと同等 - UIKit コードは
traitCollection.myAppThemeでブリッジ値を読み取り - 双方向ブリッジでフレームワーク間をシームレスにデータが流れる
重要ポイント
1. 深いデータ伝播をカスタム trait に置き換え
- 何を作るか: デリゲート、クロージャ、NotificationCenter で渡すコンテキスト情報をカスタム trait に変更
- 価値がある理由: trait は階層に沿って自動伝播。深くネストしたコンポーネントも直接読み取れ、コードが簡潔に
- 始め方:
UITraitDefinitionを定義し、共通祖先でtraitOverridesを設定、必要なビューでtraitCollectionを読み取る
2. App にマルチテーマ対応を追加
- 何を作るか: カスタム theme trait で App 内テーマ切り替え(標準、パステル、bold、モノクロなど)を実装
- 価値がある理由: trait 変化時に dynamic color が自動再描画。各ビューの手動リフレッシュが不要
- 始め方:
MyAppThemeTraitを定義しaffectsColorAppearance = true、UIColor { 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を追加
関連セッション
- What’s new in UIKit — UIKit の新機能概覧。viewIsAppearing と trait システムの紹介を含む
- Animate symbols in your app — SF Symbols のアニメーション効果
- Build better document-based apps — ドキュメント App の構築。複雑な UI における trait の応用
コメント
GitHub Issues · utterances