Highlight
Apple 在 macOS 27 中为 AppKit 引入手势识别器统一输入模型、控制事件(Control Events)替代 mouseDown 追踪循环、NSWindowRestoration 状态恢复机制,以及 NSViewCornerConfiguration 同心圆角 API,让 AppKit App 在交互、状态管理和视觉风格上与 SwiftUI 和 UIKit 对齐。
核心内容
AppKit 是 macOS 上历史最悠久的 UI 框架,很多核心 API 从 NeXT 时代延续至今。如果你维护着一个有年头的 Mac App,大概率见过这样的代码:重写 mouseDown: 自己写追踪循环,子类化 NSButton 就为了知道鼠标有没有移出按钮区域,窗口关闭后再打开时用户又得重新找刚才看的那张图片。
这些代码当年是对的,但现在是负担。SwiftUI 和 UIKit 用手势识别器处理交互,用声明式状态管理界面,而 AppKit 还在用同一套 30 年前的模式。更实际的问题是:当你想在 AppKit 窗口里嵌入一个 SwiftUI 视图时,两套事件模型会打架。鼠标事件被 AppKit 的追踪循环吃掉,SwiftUI 的手势识别器永远收不到。
Apple 今年没有推翻 AppKit,而是做了三件事:把 SwiftUI 和 UIKit 已经验证过的模式搬过来,让旧代码逐步迁移,同时保留 AppKit 的灵活性。
第一件事:统一输入模型。 手势识别器(Gesture Recognizers)从 iOS 借来,现在正式成为 AppKit 的一等公民。你不再需要为了一次点击重写 mouseDown:、维护拖拽状态、处理菜单弹出。NSClickGestureRecognizer 和 NSPanGestureRecognizer 直接挂在 NSView 上用,和 SwiftUI 的 onTapGesture 底层走同一套逻辑。这意味着混合使用 AppKit 和 SwiftUI 时,事件不再互相拦截。
第二件事:控制事件。 UIKit 开发者熟悉的 addTarget(_:action:for:) 来到了 AppKit。以前想知道用户在按钮上按下鼠标后移出了按钮区域,必须子类化 NSButton、重写三个鼠标方法、自己维护 mouseDownInside 状态。现在一行代码:.trackingEndedOutside,连子类都不用写。更关键的是 API 签名和 UIKit 完全一致,iOS 开发者转 macOS 几乎没有学习成本。
第三件事:状态恢复。 macOS 用户习惯 App 记住自己的状态:重启后窗口还在原来的位置、侧边栏还展开着、刚才看的那张图片还在。以前这需要大量样板代码,很多开发者干脆不做。macOS 27 把 NSWindowRestoration 的流程简化成了三步:给窗口一个标识符、在退出前编码关键状态、启动时解码恢复。系统夜间自动更新重启时,用户的上下文不会丢失。
这些改变让 AppKit App 在现代 macOS 生态里保持竞争力。你可以继续用 NSView 画自定义内容,继续用 CALayer 做复杂动画,但在交互、状态管理和视觉风格上,终于和系统其他部分对齐了。
详细内容
从 mouseDown 到现代输入模型
很多 AppKit 开发者还在用 mouseDown: 和追踪循环(tracking loop)处理点击、拖拽、选择。这套模式从 Mac 诞生之初就有了,但它有一个根本问题:和 SwiftUI、UIKit 的手势识别器(Gesture Recognizers)不兼容。
Mac 上同时跑着 AppKit、SwiftUI 和 Mac Catalyst(UIKit)。三个框架需要一套共同的事件处理语言。手势识别器就是这个桥梁。
(01:27)演讲者指出,如果你还在重写 mouseDown: 来做选择跟踪,应该改用 NSCollectionViewItem 和 NSTableRowView 的 selected 属性,或者监听 NSTableViewDelegate 和 NSOutlineViewDelegate 的选择变化回调。
右键菜单也有现成方案。NSView.defaultMenu 给所有实例共享一个菜单,NSResponder.menu 给每个响应者单独设置,NSView.menuForEvent 则根据事件动态创建菜单。三种场景都覆盖了,不需要自己算坐标。
(03:35)拖拽同样如此。用 tableView(_:pasteboardWriterForRow:) 返回一个 NSPasteboardItem,系统会处理后续。NSCollectionView、NSOutlineView 和 NSBrowser 都有对应的现代拖拽代理方法。
func tableView(_ tableView: NSTableView,
pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? {
let pasteboardItem = NSPasteboardItem()
pasteboardItem.setString(..., forType: .string)
return pasteboardItem
}
控制事件来到 AppKit
UIKit 开发者对控制事件很熟悉。macOS 27 把它带到了 AppKit。
(04:55)以前想知道用户按下按钮后把鼠标移出了按钮区域,你得子类化 NSButton,重写 mouseDown:、mouseDragged:、mouseUp:,自己维护状态。现在直接给按钮加一个 .trackingEndedOutside 控制事件,连子类都不用写。
let button = NSButton()
button.addTarget(
self,
action: #selector(trackingEndedOutsideHandler),
for: .trackingEndedOutside
)
关键点:
addTarget(_:action:for:)和 UIKit 的 API 签名一致,跨框架迁移的学习成本很低.trackingEndedOutside表示用户在按钮上按下鼠标,但在按钮区域外抬起- 不需要子类化
NSButton,直接在实例上注册目标和动作 - 大多数控制事件从 macOS 10.11 起就已可用
手势识别器与事件穿透
手势识别器挂在视图层级上运行。如果一个兄弟视图覆盖了按钮,它会静默吃掉鼠标事件。
(05:44)如果覆盖层只是视觉装饰,不需要接收事件,重写 hitTest(_:) 返回 nil,让事件穿透到下方的内容视图。
override func hitTest(_ point: NSPoint) -> NSView? {
return nil
}
关键点:
hitTest(_:)决定哪个视图接收鼠标事件- 返回
nil表示”这里没有任何视图要处理这个事件” - 事件会继续向下传递到被覆盖的视图
- 这个覆盖层自身也无法接收任何鼠标事件
键盘导航与状态栏扩展界面
Mac 用户很多是键盘重度使用者。Tab 键在控件之间循环的焦点顺序叫 key view loop。
(06:24)在窗口上设置 autorecalculatesKeyViewLoop = true,每次视图层级变化时系统自动重新计算焦点顺序。如果不设置,你需要手动维护整个循环。
window.autorecalculatesKeyViewLoop = true
状态栏图标(status item)也可以参与键盘导航。如果状态栏图标点击后弹出自定义窗口,需要告诉 AppKit 这个 UI 的生命周期,否则键盘焦点会乱飞。
(07:37)设置 expandedInterfaceDelegate,实现三个方法:会话开始时显示窗口,会话结束时隐藏窗口,用户选择动作后取消会话。
@main class LightAppDelegate: NSObject, NSApplicationDelegate {
lazy var lightStatusItem: NSStatusItem = { ... }()
func applicationDidFinishLaunching(_ notification: Notification) {
lightStatusItem.expandedInterfaceDelegate = self
}
}
extension LightAppDelegate: NSStatusItemExpandedInterfaceDelegate {
func statusItem(_ statusItem: NSStatusItem, didBegin session:
NSStatusItemExpandedInterfaceSession) {
// Show window
}
func statusItemDidEndExpandedInterfaceSession(
_ statusItem: NSStatusItem, animated: Bool) {
// Hide window
}
func selectedAction() {
// Take the action
lightStatusItem.expandedInterfaceSession?.cancel()
}
}
关键点:
expandedInterfaceDelegate在状态项创建时设置didBegin中显示自定义窗口,didEnd中将其移出cancel()请求系统关闭扩展界面会话- 如果焦点自然移到了别处,系统会自动取消会话
优雅退出与状态恢复
用户应该能在任何时候退出 App。系统夜间更新重启时,App 不应该阻拦关机。
(09:45)当 App 展示 sheet 时,窗口可能无法关闭,App 也就无法退出。preventsApplicationTerminationWhenModal 默认是 true,这是为了防止数据丢失。但对于不需要用户干预的 sheet,应该设为 false。
window.preventsApplicationTerminationWhenModal = false
状态恢复分三步:启用恢复、编码状态、解码恢复。
(10:18)第一步,给窗口设置标识符和自动保存名称,启用 isRestorable,指定 restorationClass。
@MainActor class MainWindowController: NSWindowController, NSWindowDelegate {
convenience init() {
let window = NSWindow( ... )
window.identifier = NSUserInterfaceItemIdentifier(WindowIdentifiers.mainWindow)
window.setFrameAutosaveName(WindowIdentifiers.mainWindow)
window.isRestorable = true
window.restorationClass = WindowRestorationHandler.self
}
}
关键点:
identifier是窗口的唯一标识,恢复时用来匹配setFrameAutosaveName让窗口记住在屏幕上的位置和大小- 文档窗口不需要设置 autosave name
restorationClass指定负责恢复窗口的类
(11:04)第二步,在 encodeRestorableState 中保存重建 UI 所需的最小信息。不要保存文档数据或数据库内容,只保存 UI 状态。
override func encodeRestorableState(with coder: NSCoder) {
super.encodeRestorableState(with: coder)
coder.encode(selectedProduct?.identifier.uuid,
forKey: RestorationKeys.productIdentifier)
}
(11:50)视图层级变化时调用 invalidateRestorableState(),告诉系统状态已过期,下次退出前需要重新编码。
splitViewController.onProductSelected = { [weak self] product in
self?.invalidateRestorableState()
}
(12:26)第三步,在 WindowRestorationHandler 中恢复窗口。
class WindowRestorationHandler: NSObject, NSWindowRestoration {
static func restoreWindow(
withIdentifier identifier: NSUserInterfaceItemIdentifier,
state: NSCoder,
completionHandler: @escaping (NSWindow?, Error?) -> Void
) {
if identifier == .mainWindow, let window = appDelegate.mainWindowController?.window {
completionHandler(window, nil)
} else if identifier == .imageWindow {
let controller = ImageWindowController()
appDelegate.imageWindowControllers.append(controller)
completionHandler(controller.window, nil)
} else {
completionHandler(nil, error)
}
}
}
关键点:
- 每个需要恢复的窗口都会调用一次这个方法
- 必须调用
completionHandler,AppKit 会等待所有窗口恢复完成 - 主窗口通常已经存在,直接传它的
window - 其他窗口需要新建 controller,再传其
window - 恢复失败也要调用
completionHandler,传入错误
(13:29)最后,在窗口 controller 的 restoreState 中解码状态,恢复 UI。
override func restoreState(with coder: NSCoder) {
super.restoreState(with: coder)
if let productId = coder.decodeObject(
of: [NSString.self],
forKey: RestorationKeys.productIdentifier) as? String {
splitViewController?.selectedProductId = productId
}
}
关键点:
coder中包含之前encodeRestorableState写入的所有键值- 解码标识符后交给对应的 view controller
- 数据本身从数据库或文件系统读取,不要编码在恢复状态中
视觉更新:Liquid Glass 与同心圆角
macOS 26 引入的 Liquid Glass 材质在 macOS 27 继续演进。很多更新是自动的:侧边栏延伸到窗口边缘、选中项使用半粗体、内容仍然从后方流过。
(15:06)macOS 27 新增了一个交互效果:点击时玻璃会轻微回弹,给用户反馈。这个效果只适用于控件和按钮,不要滥用。
(16:11)同心圆角(concentricity)是 macOS 27 的新 API。子视图靠近父容器角落时,其圆角半径会自动匹配父容器的曲线。
class LocalWeatherView: NSView {
override var cornerConfiguration: NSViewCornerConfiguration? {
let radius: NSViewCornerRadius = .containerConcentric(minimumCornerRadius)
return .uniformCorners(radius: radius)
}
}
关键点:
cornerConfiguration是NSView的可覆盖属性.containerConcentric根据容器视图自动计算半径minimumCornerRadius确保即使远离角落也有最小圆角.uniformCorners让四个角使用相同的半径- 系统会自动计算视图与容器角落的距离,越近越匹配
核心启发
1. 给旧 App 做一次 mouseDown 大扫除
全局搜索 mouseDown:、mouseDragged:、mouseUp:。如果只是为了处理点击、拖拽、选择,换成手势识别器或控制事件。这不仅能减少代码量,还能让 SwiftUI 嵌入时事件不再打架。
入口:NSClickGestureRecognizer、NSPanGestureRecognizer、NSControl.addTarget(_:action:for:)
2. 给每个窗口加上状态恢复
用户重启 Mac 后,App 应该回到退出前的样子。给主窗口、偏好设置窗口、图片查看窗口各加一个 NSWindowRestoration 实现。只存 UI 状态(选中了哪一项、滚动到什么位置),不存文档数据。
入口:window.isRestorable = true、encodeRestorableState(with:)、restoreWindow(withIdentifier:state:completionHandler:)
3. 用同心圆角修复视觉瑕疵
检查所有带圆角的自定义视图。如果它们位于另一个圆角容器的角落附近,用 NSViewCornerConfiguration 替代手动计算的 cornerRadius。Maps 的本地天气视图就是典型用法。
入口:覆盖 cornerConfiguration 属性,返回 .uniformCorners(radius: .containerConcentric(minimum))
4. 让状态栏图标支持键盘导航
如果你的 App 有菜单栏图标,检查它是否能在”键盘导航”开启时用 Tab 键聚焦。如果点击后弹出自定义窗口,实现 NSStatusItemExpandedInterfaceDelegate,让键盘焦点正确进出。
入口:statusItem.expandedInterfaceDelegate = self
5. 给非文本视图添加原生文本选择
macOS 27 新增的 NSTextSelectionManager 可以把 macOS 经典的双向选择、拖拽、切换行为带到任何自定义视图。如果你的 App 有自定义的文本渲染视图,不再需要自己实现整套选择逻辑。
入口:NSTextSelectionManager,附加到视图并设置 text selection data source
关联 Session
- SwiftUI + AppKit 互操作 — 在 AppKit 窗口中嵌入 SwiftUI 视图,事件模型如何统一
- UIKit 现代化 — UIKit 对应的现代化改造,很多概念和 AppKit 相通
- Xcode 27 — 新 Xcode 对 AppKit 项目的开发体验改进
- SwiftUI — 声明式框架的设计思路,帮助理解 AppKit 为什么要向手势识别器靠拢
- Swift — Swift 语言新特性,很多 API 在 Swift 6 模式下的使用方式
评论
GitHub Issues · utterances