WWDC Quick Look 💓 By SwiftGGTeam
Unleash the UIKit trait system

Unleash the UIKit trait system

观看原视频

Highlight

iOS 17 的 UIKit Trait System 支持自定义 trait 定义、trait 覆写 API、闭包式 trait 变化监听,以及与 SwiftUI environment key 的双向桥接,让数据在 UIKit 和 SwiftUI 组件间无缝流动。

核心内容

Trait 基础回顾

Trait 是系统自动向应用中每个视图控制器和视图传播的独立数据片段。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 允许定义自己的 trait,通过 UITraitDefinition 协议实现。这打开了一种全新的数据传递模式——不需要代理、不需要闭包、不需要单例,数据就能自动传播到嵌套很深的组件。

定义 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 变化都要调用,不 scalable。

新的 registerForTraitChanges API 支持闭包和 target-action 两种回调方式。注册时指定关心的 trait,只有这些 trait 变化时才回调。不需要子类化,从任何地方都能监听。

还提供了语义化的系统 trait 集合:systemTraitsAffectingColorAppearancesystemTraitsAffectingImageLookup,可以直接传给注册方法。

注册会自动清理,高级场景可以用返回的 token 手动注销。

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 时始终写这两个扩展

定义影响颜色外观的主题 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 格式,支持编码等额外功能
  • enum 用 Int raw value 效率最高

使用自定义主题 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 closure 中读取自定义 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,避免无关回调
  • 闭包第一个参数是被监听的对象,不需要 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 个参数
  • 第一个参数是变化的对象,第二个是变化前的 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 的 .environment modifier 等价于 UIKit 的 trait 覆写
  • UIKit 代码用 traitCollection.myAppTheme 读取桥接值
  • 双向桥接,数据在两个框架间无缝流动

核心启发

  1. 用自定义 trait 替代深层数据传递

    • 做什么:把通过代理、闭包、NotificationCenter 传递的上下文信息改成自定义 trait
    • 为什么值得做:trait 自动沿层级传播,嵌套多层的组件也能直接读取,代码更简洁
    • 怎么开始:定义一个 UITraitDefinition,在公共祖先上设置 traitOverrides,在需要的视图里读取 traitCollection
  2. 给应用加上多主题支持

    • 做什么:用自定义 theme trait 实现应用内的主题切换(标准、柔和、 bold、单色等)
    • 为什么值得做:trait 变化时动态颜色自动重绘,不需要手动刷新每个视图
    • 怎么开始:定义 MyAppThemeTrait,设置 affectsColorAppearance = true,用 UIColor { traitCollection in ... } 创建动态颜色
  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

关联 Session

评论

GitHub Issues · utterances