WWDC Quick Look 💓 By SwiftGGTeam
Use SwiftUI with AppKit and UIKit

Use SwiftUI with AppKit and UIKit

观看原视频

Highlight

AppKit 和 UIKit 的 NSView/UIView 现在可以直接响应 @Observable 模型的属性变化并自动重绘,开发者无需手动调用 needsDisplay = true;同时新增 NSGestureRecognizerRepresentableNSHostingMenuNSHostingSceneRepresentation 三个 API,让老项目在不改动整体架构的前提下,把 SwiftUI 组件、手势和 Scene 逐步嵌入现有 AppKit/UIKit 应用。

核心内容

手动刷新 NSView 的痛点

想象你维护一个 macOS 灯光控制 App,界面里有三个 NSSlider 分别控制 Hue、Saturation 和 Brightness。当你拖动 Hue 滑块时,Saturation 和 Brightness 滑块的轨道颜色也要跟着变——因为轨道颜色取决于当前选中的 Hue 值。

在 AppKit 里,这意味你要写一堆样板代码:每次模型属性变化,手动找到所有受影响的视图,逐个设置 needsDisplay = true。三个滑块之间互相影响,代码很快变成一团乱麻。

03:10

@Observable 自动追踪来到 AppKit

Apple 把 Observation 框架的自动追踪能力扩展到了 AppKit 和 UIKit。只要你的模型类加上 @Observable 宏,然后在 draw(_:)updateConstraints()layout()updateLayer() 里读取了模型的属性,框架就会自动注册依赖关系。属性一变,视图自动重绘。

不需要改任何绘制逻辑。你之前怎么画,现在还怎么画——只是再也不用写 needsDisplay = true 了。

03:38

什么时候该把组件迁到 SwiftUI

演讲者想给灯光 App 换一个全新的圆形色盘:外圈是 Hue 环,内圈左右各一个半圆分别控制 Saturation 和 Brightness,中间是颜色预览。整个绘制逻辑和交互方式都要重写。

这种”从零开始重写一个组件”的场景,就是引入 SwiftUI 的最佳时机。不需要为了用 SwiftUI 而重写整个 App,只在需要全新实现的地方切入。

05:52

把老手势识别器塞进 SwiftUI

演讲者有一个自定义的 Force Click 手势识别器,已经在 App 的其他地方用了。现在他想把这个功能加到新的 SwiftUI 色盘上——用力按压 trackpad 就把 Saturation 和 Brightness 重置到 100%。

NSGestureRecognizerRepresentable 包一层,不用重写任何手势逻辑,直接通过 .gesture() 挂载到 SwiftUI 视图上。这个手势和 SwiftUI 原生的 DragGesture 可以共存,互不干扰。

08:14

用 SwiftUI 写菜单,挂到 AppKit 主菜单上

Force Click 不是每台 Mac 都支持(比如 Magic Mouse 或 MacBook Neo 的 trackpad)。演讲者决定加一个菜单项作为备选方案。

他用 SwiftUI 写了一个 ColorMenu,包含按钮、快捷键和调色板选择器。然后用 NSHostingMenu 把它包装成 NSMenu,直接挂到 AppKit 的主菜单栏上。菜单里的键盘快捷键、动画过渡全部原生工作。

09:42

在老 AppDelegate 里动态注入 SwiftUI Scene

演讲者想加一个 Menu Bar Extra,让用户不用打开主窗口就能快速调节灯光亮度。他用 SwiftUI 的 MenuBarExtra Scene 和 Settings Scene 写了这个功能,然后通过 NSHostingSceneRepresentationapplicationWillFinishLaunching 里动态注入到现有的 NSApplicationDelegate 中。

不需要把 App 改成 @main + App 协议的结构。老架构不动,新功能照样用 SwiftUI Scene。

11:36

详细内容

@Observable 在 AppKit 中的自动刷新

03:38

先把数据模型改成 @Observable

import Observation

@Observable @MainActor
final class ColorModel {
    var hue: Double = 0.6
    var saturation: Double = 1.0
    var brightness: Double = 1.0
}

然后在自定义 NSSliderCelldrawKnob 里直接读取 model.hue。AppKit 会自动追踪这个访问,并在 hue 变化时调用重绘。

这套机制支持的 AppKit 方法包括:

  • NSView.draw(_:)
  • NSView.updateConstraints()
  • NSView.layout()
  • NSView.updateLayer()
  • NSViewController 的对应方法

UIKit 支持的范围更广,还包括 UIButtonUICollectionViewCell 等。

向后兼容到 macOS 15 和 iOS 18,需要在 Info.plist 里加对应的 key:

  • macOS 15: NSObservationTrackingEnabled
  • iOS 18: UIObservationTrackingEnabled

2026 年的新系统默认开启,不需要配置。

关键点:

  • @Observable 宏让类里所有 var 属性自动参与观察系统
  • 只要在支持的方法里读了属性,就自动建立依赖
  • 向后兼容需要手动在 Info.plist 里开开关

用 Canvas 在 SwiftUI 中实现圆形色盘

06:28

import SwiftUI
import Observation

@Animatable
struct HSBColorPicker: View {
    var hue: Double
    var saturation: Double
    var brightness: Double
    @AnimatableIgnored var model: ColorModel

    init(model: ColorModel) {
        self.model = model
        self.hue = model.hue
        self.saturation = model.saturation
        self.brightness = model.brightness
    }

    var body: some View {
        Canvas { context, size in
            let metrics = PickerMetrics(size: size)
            drawPicker(in: &context, metrics: metrics,
                       hue: hue, saturation: saturation, brightness: brightness)
        }
        .contentShape(Circle())
        .modifier(ColorPickerDragGesture(model: model))
        .aspectRatio(1, contentMode: .fit)
    }
}

Canvas 提供即时模式绘制 API,和 AppKit 的 drawRect 类似。每次重绘都会传入一个 GraphicsContext,你可以直接调用 strokefill 等命令。已有的 CoreGraphics 代码可以通过 withCGContext 复用。

关键点:

  • @Animatable 让结构体支持 SwiftUI 动画系统
  • @AnimatableIgnored 标记不参与动画插值的属性(如 model 引用)
  • Canvas 的闭包签名是 (GraphicsContext, CGSize) -> Void
  • .contentShape(Circle()) 把点击区域限制在圆形内

把 SwiftUI 视图嵌入 AppKit 视图层级

07:21

let hostingView = NSHostingView(
    rootView: HSBColorPicker(model: model)
)

NSHostingViewNSView 的子类,可以直接放到任何 AppKit 视图层级中。因为 ColorModel 已经是 @Observable,数据变化会自动同步到 SwiftUI 视图中,不需要额外的桥接代码。

关键点:

  • NSHostingView 继承自 NSView,可以像普通 AppKit 视图一样使用
  • 数据模型提前迁移到 @Observable 后,混编变得零成本

复用 NSGestureRecognizer 到 SwiftUI

08:14

import SwiftUI
import AppKit

struct ForceClickReset: NSGestureRecognizerRepresentable {
    var model: ColorModel

    func makeNSGestureRecognizer(context: Context) -> ForceClickGestureRecognizer {
        ForceClickGestureRecognizer()
    }

    func handleNSGestureRecognizerAction(
        _ recognizer: ForceClickGestureRecognizer,
        context: Context
    ) {
        withAnimation {
            model.saturation = 1
            model.brightness = 1
        }
    }
}

final class ForceClickGestureRecognizer: NSGestureRecognizer {
    private var didActivate = false

    override func pressureChange(with event: NSEvent) {
        if event.stage >= 2 && !didActivate {
            didActivate = true
            state = .ended
        }
    }

    override func mouseDown(with event: NSEvent) {
        didActivate = false
        state = .possible
    }

    override func mouseUp(with event: NSEvent) {
        didActivate = false
        state = .possible
    }
}

在 SwiftUI 视图中像原生手势一样使用:

HSBColorPicker(model: model)
    .gesture(ForceClickReset(model: model))

关键点:

  • makeNSGestureRecognizer 返回你现有的手势识别器子类
  • handleNSGestureRecognizerAction 在手势触发时回调
  • withAnimation 包裹模型修改,SwiftUI 会自动处理动画过渡
  • 和 SwiftUI 原生 DragGesture 可以共存

用 SwiftUI 写 AppKit 主菜单

09:42

struct ColorMenu: View {
    var model: ColorModel

    private static let hues: [(name: String, hue: Double)] = [
        ("Red", 0), ("Yellow", 0.17), ("Green", 0.33),
        ("Cyan", 0.5), ("Blue", 0.67), ("Purple", 0.83),
    ]

    var body: some View {
        Button("Full Intensity") {
            withAnimation {
                model.saturation = 1
                model.brightness = 1
            }
        }
        .keyboardShortcut(.upArrow, modifiers: [.command, .shift])

        Button("Blackout") {
            withAnimation {
                model.brightness = 0
            }
        }
        .keyboardShortcut(.downArrow, modifiers: [.command, .shift])

        Divider()

        Picker("Color", selection: Bindable(model).hue) {
            ForEach(Self.hues, id: \.hue) { entry in
                Label(entry.name, systemImage: "circle.fill")
                    .tint(Color(hue: entry.hue, saturation: 1, brightness: 1))
                    .tag(entry.hue)
            }
        }
        .pickerStyle(.palette)
    }
}

挂到 AppKit 主菜单:

@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
    let colorModel = ColorModel()

    func setupMainMenu() {
        let mainMenu = NSMenu()

        let colorMenu = NSHostingMenu(rootView: ColorMenu(model: colorModel))
        colorMenu.title = "Color"

        let colorMenuItem = NSMenuItem()
        colorMenuItem.submenu = colorMenu
        mainMenu.addItem(colorMenuItem)
    }
}

关键点:

  • NSHostingMenuNSMenu 的子类,接收 SwiftUI View 作为根视图
  • SwiftUI 的 keyboardShortcut 修饰符会映射到 AppKit 的菜单快捷键
  • Bindable(model).hue 创建对 @Observable 属性的双向绑定
  • .pickerStyle(.palette) 在菜单中显示为调色板样式

在老 AppDelegate 中动态注入 SwiftUI Scene

11:36

@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
    let model = AppModel()
    var openSettingsAction: (() -> Void)?

    func applicationWillFinishLaunching(_ notification: Notification) {
        let scenes = NSHostingSceneRepresentation {
            LightMenuBarExtra(appModel: model)
            LightSettings(appModel: model)
        }
        NSApplication.shared.addSceneRepresentation(scenes)
        openSettingsAction = {
            scenes.environment.openSettings()
        }
    }

    @IBAction func openSettings(_ sender: Any?) {
        openSettingsAction?()
    }
}

struct LightMenuBarExtra: Scene {
    var appModel: AppModel

    var body: some Scene {
        MenuBarExtra("Light Mix", systemImage: "lightbulb.fill",
                     isInserted: Bindable(appModel).showMenuBarExtra) {
            MenuBarContent(appModel: appModel)
        }
        .menuBarExtraStyle(.window)
    }
}

struct LightSettings: Scene {
    var appModel: AppModel

    var body: some Scene {
        Settings {
            SettingsView(appModel: appModel)
        }
    }
}

关键点:

  • NSHostingSceneRepresentation 把 SwiftUI Scene 包装成 AppKit 可识别的形式
  • applicationWillFinishLaunching 中调用 addSceneRepresentation 完成注入
  • scenes.environment.openSettings() 暴露打开设置窗口的动作,可以从老代码的 @IBAction 中调用
  • MenuBarExtraisInserted 参数控制菜单栏图标的显示/隐藏

核心启发

1. 给老 macOS 工具 App 加一个 SwiftUI 设置面板

很多老 App 的设置界面是用 XIB 或纯代码写的,维护成本高。用 SwiftUI 重写一个 Settings Scene,通过 NSHostingSceneRepresentation 注入到现有 AppDelegate 中。设置项用 Form + TabView 组织,代码量比原来的 XIB 少一半。

入口 API:NSHostingSceneRepresentation { Settings { ... } }

2. 把复杂的自定义绘制迁移到 SwiftUI Canvas

如果你有一个用 CoreGraphics 写的自定义图表或色盘,可以考虑迁移到 SwiftUI 的 Canvas。绘制逻辑基本不用改——CanvasGraphicsContextCGContext 概念一致,还可以通过 withCGContext 直接拿到原生上下文复用旧代码。

入口 API:Canvas { context, size in ... }

3. 用 SwiftUI 写菜单栏工具

很多开发者想给 App 加一个菜单栏快捷入口,但不想重构整个 App 架构。现在可以直接在现有 NSApplicationDelegate 里加一个 MenuBarExtra Scene,几行代码搞定。

入口 API:MenuBarExtra("Title", systemImage: "icon") { ... }

4. 复用现有的复杂手势识别器

如果你有一个花了大量时间调试的自定义 NSGestureRecognizer(比如支持压感、多指手势等),不需要用 SwiftUI 的 Gesture 协议重写。用 NSGestureRecognizerRepresentable 包一层就能直接用。

入口 API:实现 NSGestureRecognizerRepresentable 协议

5. 先把模型层迁移到 @Observable

这是成本最低、收益最大的第一步。把核心数据模型改成 @Observable,现有的 NSView 立刻获得自动刷新能力。等以后需要写新 UI 时,模型已经可以直接被 SwiftUI 消费,不需要再做一次迁移。

入口 API:@Observable class MyModel { ... }

关联 Session

评论

GitHub Issues · utterances