WWDC Quick Look 💓 By SwiftGGTeam
Use SwiftUI with AppKit

Use SwiftUI with AppKit

观看原视频

Highlight

Shortcuts 团队的 Ian 以 Shortcuts App 为实际案例,系统讲解了在 macOS App 中混合使用 SwiftUI 和 AppKit 的各种场景。

核心内容

Shortcuts 到了 macOS 以后,团队面对一个常见问题:Mac App 里已经有 AppKit 的窗口、split view、collection view、菜单和响应链,但许多跨平台界面已经用 SwiftUI 写好。全部重写成本高,直接嵌入又会遇到状态、布局、焦点和性能问题。

Ian 的做法是从 Shortcuts 的真实界面拆开讲。左侧边栏是 SwiftUI List,外层窗口仍然是 NSSplitViewController。快捷指令网格使用 NSCollectionView,单个 cell 内托管 SwiftUI 的 shortcut 卡片。编辑器是 SwiftUI View,但它要响应 AppKit 主菜单里的 cut、copy、paste 和自定义命令。最后,AppleScript 编辑器仍然是 AppKit NSView,需要放进 SwiftUI 布局里。

这场 session 的核心不是要求你立刻迁移整个 Mac App。它给出一组接缝:NSHostingControllerNSHostingViewObservableObjectNSViewRepresentable、coordinator、Responder Chain 和 Focus。每个接缝都对应一个迁移场景,可以逐块采用 SwiftUI。

详细内容

在 AppKit split view 里托管 SwiftUI 边栏

01:29)Shortcuts 主窗口的外层是 AppKit split view,左侧边栏用 SwiftUI List 实现。边栏内部维护选中项,选项由 SidebarItem 枚举表示。

struct SidebarView: View {
    @State private var selectedItem: SidebarItem
    
    var body: some View {
        List(selection: $selectedItem) {
            ...
            Section("Shortcuts") { ... }
            Section("Folders") { ... }
        }
    }
}

enum SidebarItem: Hashable {
    case gallery
    case allShortcuts
    ...
    case folder(Folder)
}

关键点:

  • SidebarView 是普通 SwiftUI View,可以单独描述边栏界面。
  • @State private var selectedItem 保存当前选中的位置。
  • List(selection:) 把列表选择绑定到状态。
  • Section("Shortcuts")Section("Folders") 对应 Shortcuts 的导航分组。
  • SidebarItem: Hashable 让每个导航目标可以作为列表选择值。

01:53)把这个 SwiftUI 边栏放进 AppKit,入口是 NSHostingController。它像普通 view controller 一样使用,所以可以直接包装成 NSSplitViewItem

let splitViewController = NSSplitViewController()

let sidebar = NSHostingController(rootView: SidebarView(...))
let splitViewItem = NSSplitViewItem(viewController: sidebar)
splitViewController.addSplitViewItem(splitViewItem)

关键点:

  • NSSplitViewController() 仍然由 AppKit 管理窗口结构。
  • NSHostingController(rootView:) 把 SwiftUI SidebarView 变成 AppKit 可用的 view controller。
  • NSSplitViewItem(viewController:) 接收托管控制器,不需要额外桥接层。
  • addSplitViewItem 把 SwiftUI 边栏加入 split view。

用共享模型把选择状态传回 AppKit

03:06)边栏选中项最初只存在于 SwiftUI 内部。右侧详情页由 AppKit split view 管理,需要知道选中项。Ian 的方案是把状态移到 SwiftUI 外部的 ObservableObject

class SelectionModel: ObservableObject {

    @Published var selectedItem: SidebarItem = .allShortcuts

}

// AppKit Window Controller
cancellable = selectionModel.$selectedItem.sink { newItem in
    // update the NSSplitViewController detail
}

关键点:

  • SelectionModel 是引用类型,可以被 AppKit 和 SwiftUI 同时持有。
  • ObservableObject 让 SwiftUI 在模型变化时刷新视图。
  • @Published var selectedItem 公开当前选择,并产生 Combine publisher。
  • AppKit window controller 订阅 $selectedItem
  • sink 中的 newItem 用来更新 NSSplitViewController 的详情区域。

这个模式适合已有 AppKit 架构。状态不锁在 SwiftUI view 树里,AppKit 控制器可以继续承担窗口协调工作。

在 collection view cell 中复用 NSHostingView

04:37)collection view 和 table view 的 cell 会被复用。滚动时反复添加、删除子视图会拖慢界面。官方建议是:每个 cell 创建一次 NSHostingView,后续只替换它的 rootView

class ShortcutItemView: NSCollectionViewItem {
    private var hostingView: NSHostingView<ShortcutView>?

    func displayShortcut(_ shortcut: Shortcut) {
        let shortcutView = ShortcutView(shortcut: shortcut)

        if let hostingView = hostingView {
            hostingView.rootView = shortcutView
        } else {
            let newHostingView = NSHostingView(rootView: shortcutView)
            view.addSubview(newHostingView)
            setupConstraints(for: newHostingView)
            self.hostingView = newHostingView
        }
    }
}

关键点:

  • ShortcutItemView 仍然是 AppKit 的 NSCollectionViewItem
  • hostingView 缓存当前 cell 内的 SwiftUI 托管视图。
  • displayShortcut(_:) 由 data source 在配置 cell 时调用。
  • ShortcutView(shortcut:) 为当前模型生成新的 SwiftUI 描述。
  • 已有 hostingView 时只更新 rootView,避免重建整棵托管视图层级。
  • 第一次显示内容时才创建 NSHostingView、加入 cell,并设置 Auto Layout 约束。

06:08)当 cell 滚出屏幕后再显示另一个 shortcut,SwiftUI 会复用原有结构,只更新发生变化的图片、文字和背景。这个细节很重要:复用 NSHostingView 让 AppKit 的 cell reuse 和 SwiftUI 的 view 更新模型配合起来。

让 SwiftUI 托管视图参与 AppKit 布局和窗口尺寸

06:35NSHostingControllerNSHostingView 会根据 SwiftUI view 的 ideal width、ideal height 生成 intrinsic size。SwiftUI 还会为 minimum size 和 maximum size 生成约束,交给 AppKit Auto Layout 使用。

这带来两个实际规则。

第一,嵌入 NSHostingView 后,你仍然要在 AppKit 侧给它和父视图添加约束。SwiftUI 内部的 frame 等布局修饰会改变托管视图产生的约束,例如固定宽度。

第二,如果 HostingView 是窗口的 top-level contentView,SwiftUI 会根据内容更新窗口的最小和最大尺寸。窗口能否横向、纵向缩放,会跟随 SwiftUI 内容改变。

07:55)modal 展示也可以直接使用 hosting controller。popover、sheet 和 modal window 都走 AppKit API,内容尺寸由 SwiftUI 提供。

viewController.present(NSHostingController(rootView: ...), 
    asPopoverRelativeTo: rect, of: view, 
    preferredEdge: .maxY, behavior: .transient)

关键点:

  • NSHostingController(rootView:) 直接作为 popover 的内容控制器。
  • asPopoverRelativeTo: rect 指定 popover 相对哪个区域弹出。
  • of: view 指定参照视图。
  • preferredEdge: .maxY 指定优先弹出方向。
  • behavior: .transient 使用 AppKit 的 transient popover 行为。

08:15)sheet 更短,只需要把 hosting controller 传给 presentAsSheet

viewController.presentAsSheet(NSHostingController(rootView: ...))

关键点:

  • presentAsSheet 是 AppKit 的 sheet 展示方式。
  • SwiftUI view 通过 NSHostingController 进入 sheet。
  • sheet 的内容尺寸由托管的 SwiftUI 内容决定。

08:22)modal window 可以设置标题,再调用 presentAsModalWindow

let hostingController = NSHostingController(rootView: ModalView())
hostingController.title = "Window Title"
viewController.presentAsModalWindow(hostingController)

关键点:

  • ModalView() 是 SwiftUI 内容。
  • hostingController.title 会成为 modal window 的窗口标题。
  • presentAsModalWindow 显示会阻塞交互的模态窗口。
  • 窗口大小会适配 SwiftUI 内容。

08:45)macOS Ventura 新增了控制自动约束的 API。默认会创建 minimum、intrinsic 和 maximum size 约束。你可以按需调整 sizingOptions

hostingController.sizingOptions = [.minSize, .intrinsicContentSize, .maxSize]

关键点:

  • sizingOptions 控制 hosting controller 自动创建哪些尺寸约束。
  • .minSize 对应最小尺寸。
  • .intrinsicContentSize 对应理想内容尺寸。
  • .maxSize 对应最大尺寸。
  • 如果外层 AppKit 已经提供约束,或你希望视图始终弹性伸缩,可以减少这些选项。

让 SwiftUI View 进入响应链和焦点系统

10:47)Shortcuts 的编辑器是 SwiftUI View,但主菜单在 AppKit 里。菜单项触发后,selector 会从 first responder 沿响应链传递。SwiftUI 中对应 first responder 的概念是 focused view。

Image(...)
    .focusable()
    .copyable { ... }
    .cuttable { ... }
    .pasteDestination(payloadType: Image.self) { ... }

关键点:

  • Image(...) 是需要接收键盘和菜单命令的 SwiftUI View。
  • .focusable() 让这个 view 可以成为焦点目标。
  • .copyable 提供复制数据到 pasteboard 的处理。
  • .cuttable 提供剪切处理。
  • .pasteDestination(payloadType:) 声明可以接收的粘贴数据类型。

11:02)除了复制、剪切、粘贴,SwiftUI 还可以处理方向键、Escape、AppKit 标准 selector 和自定义 selector。

struct ShortcutsEditorView: View {
    var body: some View {
        ScrollView { ... }
            .onMoveCommand { moveSelection(direction: $0) }
            .onExitCommand { cancelOperations() }
            .onCommand(#selector(NSResponder.selectAll(_:)) { selectAllActions() }
            .onCommand(#selector(moveActionUp(_:)) { moveSelectedAction(.up) }
            .onCommand(#selector(moveActionDown(_:)) { moveSelectedAction(.down) }
    }
}

关键点:

  • ScrollView { ... } 是 Shortcuts 编辑器主体。
  • .onMoveCommand 处理方向键移动选择。
  • .onExitCommand 处理 Escape 一类退出命令。
  • #selector(NSResponder.selectAll(_:)) 接收 AppKit 的 select all 标准命令。
  • #selector(moveActionUp(_:))#selector(moveActionDown(_:)) 接收 Shortcuts 自定义菜单命令。
  • 代码片段来自官方视频,示例省略了具体实现和部分闭合符号,重点是命令挂载的位置。

11:28)测试键盘导航时,要在 Keyboard System Settings 中分别打开和关闭 Full Keyboard Navigation。许多控件只在该设置开启时可聚焦。

在 SwiftUI 里托管 AppKit NSView

12:37)迁移方向也可以反过来。Shortcuts 的 AppleScript 编辑器是一个 AppKit control,要放入 SwiftUI 编辑器布局。SwiftUI 提供 NSViewRepresentableNSViewControllerRepresentable,用于把 AppKit view 或 view controller 描述成 SwiftUI view。

15:18)原始 AppKit 控件有自己的属性和 delegate。

class ScriptEditorView: NSView {
    var sourceCode: String
    var isEditable: Bool
    weak var delegate: ScriptEditorViewDelegate?
}

protocol ScriptEditorViewDelegate: AnyObject {
    func sourceCodeDidChange(in view: ScriptEditorView) -> Void
}

关键点:

  • ScriptEditorView 继承自 NSView,说明它是已有 AppKit 控件。
  • sourceCode 保存编辑器文本。
  • isEditable 控制是否允许编辑。
  • delegate 负责把 AppKit 侧变化通知出去。
  • sourceCodeDidChange 在源码变化时被调用。

15:40)SwiftUI 容器把源码状态放在 @State 中,Compile 按钮读取同一份状态。

struct ScriptEditorContainerView: View {
    @State var sourceCode: String = ""

    var body: some View {
        VStack {
            CompileButton { compile(code: sourceCode) }
            Divider()
            ScriptEditorRepresentable(sourceCode: $sourceCode)
        }
    }
}

关键点:

  • @State var sourceCode 是 SwiftUI 侧的单一状态来源。
  • CompileButton 的 handler 使用当前 sourceCode 编译代码。
  • Divider() 分隔按钮和编辑器。
  • ScriptEditorRepresentable(sourceCode: $sourceCode) 把 binding 传入 AppKit 包装层。

16:13NSViewRepresentable 负责创建、更新和协调 AppKit view。coordinator 接收 delegate 回调,再写回 SwiftUI binding。

struct ScriptEditorRepresentable: NSViewRepresentable {
    @Binding var sourceCode: String

    func makeNSView(context: Context) -> ScriptEditorView {
        let scriptEditor = ScriptEditorView(frame: .zero)
        scriptEditor.delegate = context.coordinator
        return scriptEditor
    }

    func updateNSView(_ nsView: ScriptEditorView, context: Context) {
        if sourceCode != scriptEditor.sourceCode {
            scriptEditor.sourceCode = sourceCode
        }
        scriptEditor.isEditable = context.environment.isEnabled
        // Make sure coordinator has a reference to the current value 
        // of the binding:
        context.coordinator.representable = self
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(representable: self)
    }
}

class Coordinator: NSObject, ScriptEditorViewDelegate {
    var representable: ScriptEditorRepresentable

    init(representable: ScriptEditorRepresentable) { ... }

    func sourceCodeDidChange(in view: ScriptEditorView) {
        representable.sourceCode = view.sourceCode
    }
}

关键点:

  • @Binding var sourceCode 让包装层读写 SwiftUI 容器状态。
  • makeNSView 创建 ScriptEditorView,适合做一次性 setup。
  • scriptEditor.delegate = context.coordinator 把 AppKit delegate 事件交给 coordinator。
  • updateNSView 在 SwiftUI state 或 environment 变化时被调用。
  • if sourceCode != scriptEditor.sourceCode 避免重复设置属性,因为设置源码会触发额外工作。
  • context.environment.isEnabled 映射到 AppKit 的 isEditable
  • context.coordinator.representable = self 保持 coordinator 内的 binding 为最新值。
  • sourceCodeDidChange 把 AppKit 编辑结果写回 SwiftUI。

官方片段里 updateNSView 的参数名是 nsView,条件里出现了 scriptEditor。这里保留视频片段原文;实际项目中应使用当前方法拿到的 AppKit view 实例。

核心启发

  • 做一个混合式 Mac 侧边栏:保留 AppKit 窗口和 split view,只把侧边栏改成 SwiftUI List。为什么值得做:导航结构常常最适合先迁移,因为它边界清晰。怎么开始:用 NSHostingController(rootView:) 包住 SwiftUI 边栏,再把共享选择状态放进 ObservableObject

  • 把复杂 cell 迁移到 SwiftUI:在 NSCollectionViewItemNSTableCellView 内使用 NSHostingView 显示卡片界面。为什么值得做:SwiftUI 写卡片布局更直接,AppKit 仍然负责高性能列表容器。怎么开始:每个 cell 缓存一个 NSHostingView,复用时只替换 rootView

  • 给 Mac 菜单命令接上 SwiftUI 编辑器:让 SwiftUI editor 响应 select all、方向键、Escape 和自定义菜单项。为什么值得做:Mac 用户期待菜单栏和键盘快捷键可用。怎么开始:给可交互 view 加 .focusable(),再使用 .onCommand.onMoveCommand.onExitCommand 接收命令。

  • 把已有 AppKit 控件放进 SwiftUI 页面:保留脚本编辑器、图像编辑器、复杂文本控件等成熟 NSView。为什么值得做:某些控件已有多年行为和 delegate 生态,重写风险高。怎么开始:创建 NSViewRepresentable,用 makeNSView 做初始化,用 updateNSView 同步 state 和 environment,用 coordinator 接 delegate。

关联 Session

评论

GitHub Issues · utterances