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。它给出一组接缝:NSHostingController、NSHostingView、ObservableObject、NSViewRepresentable、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:)把 SwiftUISidebarView变成 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:35)NSHostingController 和 NSHostingView 会根据 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 提供 NSViewRepresentable 和 NSViewControllerRepresentable,用于把 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:13)NSViewRepresentable 负责创建、更新和协调 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:在
NSCollectionViewItem或NSTableCellView内使用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
- Use SwiftUI with UIKit — 同样讲混合 UI 框架,只是对象换成 UIKit,适合对照
UIHostingController和 cell 集成方式。 - Bring your iOS app to the Mac — 介绍把 iOS 应用带到 Mac 的路径,可与 AppKit/SwiftUI 混合迁移策略一起阅读。
- The SwiftUI cookbook for navigation — 讲 SwiftUI 的导航模型,适合补充理解边栏、分栏和导航状态。
- Compose custom layouts with SwiftUI — 讲 SwiftUI 布局能力,适合深入理解托管视图尺寸和布局行为。
评论
GitHub Issues · utterances