-
升级改造你的 AppKit App
根据最新的 macOS 设计惯例升级改造你的 AppKit App。深入探索如何利用控制事件和手势识别器来处理输入,告别传统的追踪循环。增强 App 中的键盘导航功能,在重启后顺畅实现状态恢复;并利用新的边角同心性 API,让你的界面与 macOS 的美学设计无缝融合。
章节
- 0:00 - Introduction
- 1:06 - Modern input
- 1:27 - Modern event handling with gesture recognizers
- 2:25 - Selection, context menus, and drag and drop
- 3:52 - Text selection in custom views
- 4:26 - Control events and gesture recognizers
- 5:51 - Keyboard navigation and status items
- 8:57 - Continuity across launches
- 9:08 - Graceful app termination
- 9:55 - State restoration
- 14:09 - Design updates
- 14:24 - Liquid Glass updates in macOS 27
- 15:41 - Concentricity
- 16:59 - Next steps
资源
- Use SwiftUI with AppKit
- Restoring your app’s state with AppKit
- Gestures
- TN3212: Adopting gesture recognizers for Sidecar touch support
- NSControl.Events
相关视频
WWDC26
-
搜索此视频…
你好! 我是Ujjaini 是Mac UI框架工程师 这里是"Modernize Your AppKit App" 现代化的App能充分利用 AppKit与Mac的交互方式 使其形式与功能 与系统其他部分和谐统一 这种和谐体现在三个方面 在于人们驱动你的App的方式 在于系统管理它的方式 以及它在屏幕上的 外观与体验 今天我将分享三个方面的技巧 首先介绍现代精准输入方式 你的App应适配这些输入 然后探讨在启动间 保持连续性的重要性 即你的App如何在系统需要时 优雅地终止 并从上次离开的地方恢复 最后介绍macOS 27中 外观与体验的更新 其中许多变化无需重新构建 你的App就能看到 我将从Mac的起点开始讲起 从输入设备开始 精准输入设备从一开始就是 Mac的核心 第一台Mac附带了 键盘和鼠标!
随着时间推移 处理各种 输入的API也在不断演进 变得更加便捷 mouseDown与追踪循环 一直是标准模式 用于在AppKit App中 实现交互行为 然而 AppKit并非Mac上 唯一的框架 SwiftUI、Mac Catalyst上的UIKit 以及AppKit协同工作 共同打造Mac体验 它们都依赖手势识别器 提供跨三个框架通用的 事件处理方式 手势识别器赋予AppKit 提供高级行为的能力 无需自行构建 处理事件的现代方式 就是手势识别器 我将介绍三种与之 兼容的解决方案 基于视图的API、控制事件 以及自定义手势识别器 这些方案提供与追踪循环和 mouseDown重写相同的自定义能力 并支持跨框架兼容 我会告诉你何时使用哪种方案 常见的mouseDown重写 用于实现选择追踪 显示上下文菜单 拖放以及文本选择 如果你目前正在重写mouseDown AppKit提供了专用API 能更可靠地处理这些行为 并充分利用现代Mac平台
mouseDown通常被重写 以追踪选择状态 改用以下方式 观察NSCollectionViewItem和 NSTableRowView等类型上的selected属性 或使用在选择变化时 收到通知的代理回调 例如NSTableViewDelegate 和NSOutlineViewDelegate 要在视图中显示上下文菜单 你有几种选择
使用NSView上的 类属性.defaultMenu 这样视图的所有实例 都会显示相同的菜单 使用NSResponder上的 实例属性.menu 为每个响应者 提供不同的菜单 或使用NSView上的 实例方法.menuForEvent 根据事件动态创建菜单 如果你的App使用 集合容器视图 使用现代拖拽代理方法 如tableView pasteboardWriterForRow 创建pasteboardItem 设置数据并返回 NSCollectionView、NSOutlineView 和NSBrowser也有类似方法
如果你需要在NSTextView 之外实现文本选择行为 请使用NSTextSelectionManager 这是macOS 27中的新API 利用了手势识别器 将经典macOS文本选择行为 带到任意视图 将其附加到视图上 并设置文本选择数据源 你将获得双向选择的支持 文本拖放、切换 以及更多功能
下一个解决方案 可能看起来有些熟悉 如果你熟悉UIKit中的控制事件 控制事件现已支持AppKit! 控制事件可以添加到 标准Mac控件上 如按钮或滑块 它们让你的代码能够响应 用户驱动的追踪状态变化 而无需实现复杂的 mouseDown追踪逻辑 当控制事件触发时 AppKit会调用已注册的目标和动作 大多数控制事件从 OS 10.11起就已支持!
这里是NSControlEvents的示例 实例化按钮并为控制事件 注册目标和动作 注意无需子类化NSButton 即可实现这一功能! 要对视图中的交互 拥有更多控制 可添加标准手势识别器 要获得更高灵活性 可创建自定义手势识别器子类 详情请参阅"Gestures"文档 由于手势识别器作用于 视图及其子视图 重叠的兄弟视图可能 会悄悄阻挡鼠标事件 如果某个控件似乎 对点击没有响应 请确保没有兄弟视图 与该按钮重叠 要解决此问题 可调整视图大小使其不重叠 如果视图不应调整大小 因为它是一个覆盖层 请重写hitTest并返回nil 以便点击测试可以 穿透到下方内容
现在我将重点介绍键盘导航 你的App需要无缝响应 键盘输入 以实现高效操作和无障碍功能 控件的键盘导航可以在 系统设置中开启 开启后 焦点可通过Tab或 Shift-Tab在控件间移动 键视图循环是控件 被循环切换的顺序 当按下Tab键时 要自动重新计算循环 每次在层级中添加 或移除视图时 在窗口上启用 .autorecalculatesKeyViewLoop 如果不设置此值 你需要负责创建和维护 键视图循环
键盘导航还可以 超出App窗口的范围 进入菜单栏和状态栏项目 在状态栏项目间导航 与主菜单项目略有不同 点击时显示菜单的 状态栏项目 已表现得像菜单栏中的菜单 但状态栏项目也可以是触发器 用于执行操作或显示某种 临时UI 要触发操作 请修改 NSStatusItem的button属性 添加目标和动作 以及可选的图像 这就像一个普通按钮 动作会自动触发 当键盘导航时 按下Return键即可 要为状态菜单项 使用自定义视图 请使用状态栏项目的 view属性来设置视图 然后为状态栏项目 添加目标和动作 以启用执行该动作 状态栏项目也可以是触发器 例如显示自定义窗口! 当状态栏项目显示其窗口时 AppKit需要知道 该UI何时处于活跃状态 以使键盘焦点 能够正确运作 使用扩展界面会话API 追踪自定义UI的生命周期 首先设置代理 在创建项目时 接收开始和结束调用 以显示或关闭窗口 在代理中 实现statusItem didBegin ExpandedInterfaceSession 以及statusItemDidEnd_ ExpandedInterfaceSession 这些方法由AppKit调用 用于管理扩展界面会话 的生命周期 在didBegin调用中 显示窗口 在didEnd调用中 隐藏窗口
当需要关闭会话时 例如 因为已选择了某个操作 在.expandedInterfaceSession? 上调用.cancel 注意 会话可能被自动取消 如果焦点自然移动到了其他地方 SwiftUI菜单栏附加功能 会帮你完成很多这方面的工作! 请查看WWDC26视频 "Use SwiftUI with AppKit and UIKit" 了解AppKit App如何使用 SwiftUI菜单栏附加功能 确保你的App通过键盘操作 与通过鼠标同样流畅 对于选择Mac的众多专业用户 这一点尤为重要 提供无缝过渡体验 在App内外皆如此 是赋能用户的又一方式 说到无缝过渡 优秀的Mac App能够 无缝退出并快速恢复 退出时没有阻力 恢复时如同从未退出过!
人们应该能够 随时退出他们的App 有时是因为他们想退出 有时是因为 系统需要重启 这可能发生在 夜间软件更新期间 因此你的App应该只在 确实必要时阻止退出 当你的App显示Sheet时 窗口可能无法关闭 而窗口无法关闭时 App就无法退出 NSWindow属性 preventsApplicationTerminationWhenModal 默认为true 这是有充分理由的! 确保你的App不会丢失数据 非常重要 例如当文档需要保存时 将此属性设置为false 对于不需要强制干预的 所有模态或Sheet 以允许更优雅的 应用终止 处理好优雅终止后 下一步是恢复 使用NSWindowRestoration 自定义App的恢复方式 状态恢复需要3个步骤 选择启用状态恢复 编码UI状态 以及解码状态 以恢复窗口和UI 我将介绍一些使用 NSWindowRestoration的代码 首先 在窗口控制器中 为窗口设置标识符 对于常见窗口 如主窗口 或偏好设置窗口 设置自动保存名称 这有助于将窗口恢复到 具有相同框架的活动空间 文档窗口无需设置 自动保存名称 然后确保 window.isRestorable设置为true 这样AppKit可以在你的窗口上调用 encodeRestorableState和restoreState 这还让AppKit能够 自动恢复窗口状态 如哪个窗口被最小化 哪个在最前面 以及哪个是全屏 此外 设置window.restorationClass 这将在App重新启动时被调用 以恢复窗口本身 使用encodeRestorableState 保存所需的一切内容 以重建窗口状态 也要调用super的实现 以确保状态正确恢复 在本示例中 所选项目的标识符使用 productIdentifier键进行编码
避免编码存储在文档 或数据库中的数据 状态恢复的目标是 能够重建UI的状态 而不是重新序列化整个App 所有NSResponder都有 encodeRestorableState方法可供重写 因此也要管理你视图的状态
.encodeRestorableState只在对象 状态被标记为失效时调用 每次视图层级发生变化 且应更改保存的状态时 调用.invalidateRestorableState() 在本示例中 当侧边栏中选择了不同的产品时 会调用此方法 稍后 encodeRestorableState将在所有 被标记为失效的对象上被调用
这就是你的App在退出前 保存UI状态所需的一切 当App重新启动时 你需要解码所有信息 以恢复UI 首先恢复你的窗口 然后恢复这些窗口的状态! 在窗口恢复类中 实现restoreWindow withIdentifier方法 以重建App中的窗口 此方法会在每个 被恢复的窗口上调用 其参数包括窗口的标识符 以及需要以对应窗口调用的 completionHandler 使用标识符 重建窗口控制器和窗口 .mainWindow已在App代理的 .mainWindowController上可用 以现有的.window 调用completionHandler 对于其他窗口 实例化窗口控制器 并将其.window 传入completionHandler 如果窗口创建失败 仍需以 错误信息调用completionHandler AppKit会等待每个可恢复窗口 因此务必调用completionHandler 如果无法在此方法内调用它 请保存处理程序稍后调用 但请务必确保调用它! 一旦窗口恢复完成 最后一步是恢复 每个窗口的UI 在窗口控制器的 restoreState方法中 AppKit将传入包含你之前 编码的键的相同coder对象 这里是获取重建App状态 所需任何数据的地方 解码标识符并将其传递给 对应的视图控制器 完成后 你的窗口应该与 之前的状态相同 让退出和重新启动 感觉无缝衔接 帮助人们从上次 离开的地方继续 无论他们是退出了App 还是重启了Mac 要在实践中学习状态恢复 请查看代码示例 "Restoring your app's state with AppKit"
掌握了输入和恢复之后 还有一个App与Mac 交汇的领域:UI 在macOS 26中引入的 Liquid Glass材质 继续演进 你的App将自动受益于 许多更新 如果你在macOS 26中 采用了Liquid Glass 在macOS 27上运行时 你的App将获得一些变化 自动NSScrollEdgeEffectStyle 会解析为硬边效果 当存在自由浮动文本时 例如标题栏中的窗口标题
侧边栏延伸到窗口边缘 侧边栏中的选中项使用 半粗体文字样式以示强调
内容仍在其后面流动
侧边栏上方的带边框工具栏项目 也采用了Liquid Glass macOS 27新增了一种 可应用于Glass的效果 点击时Glass会轻微弹动 传达控件正在响应 交互的感觉 Maps将此用于 其部分自定义控件
这并不适用于App中 所有Glass的使用场景 将此效果用于控件和按钮 或交互控件的 Glass容器 适量使用效果更佳!
数十年来 圆角矩形一直是 Apple生态系统的标志 从硬件边框到macOS中的 控件和容器 AppKit为同心圆角 提供了新API 位于角落的内容可以 适应其容器的形状 而不是与窗口的 其他部分格格不入 例如 Maps中的本地天气视图 与窗口保持同心圆角
当视图位于 容器角落附近时 其自身的圆角应该 跟随容器的曲线 视图越靠近容器角落 其半径就应越匹配
要使你的按钮或视图同心 请使用cornerConfiguration API 首先 创建自定义视图子类 在自定义视图上 重写cornerConfiguration 以返回NSViewCornerConfiguration?
对于半径 在NSViewCornerRadius上 使用.containerConcentric 这将根据容器视图计算半径 也要设置一个最小值 以确保每个角 始终是圆角
你可以为配置选择 多种不同的工厂方法 要保持所有4个角 具有相同半径的圆角矩形 请使用.uniformCorners
这些是帮助你的App 在现代macOS上和谐运行的几个要点 让我为你快速总结 从哪里开始 找出App中重写mouseDown的地方 改用视图API、控制事件 或手势识别器 优先考虑用户意图 而非追踪循环 确保你的App通过键盘操作 与通过鼠标同样流畅 让退出和重新启动 感觉无缝衔接 这样你的App就能从 人们离开的地方继续 并评估你的视图层级 在视图和按钮中采用同心圆角
非常感谢你的观看 无论你的App是为在校学习 如何使用电脑的学生 还是为构建世界上最重要 工具和艺术的专业用户 你的App在这段体验中 扮演了核心角色 继续创作吧!
-
-
3:35 - Modern dragging delegate
// Modern dragging delegate methods func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { let pasteboardItem = NSPasteboardItem() pasteboardItem.setString(..., forType: .string) return pasteboardItem } -
4:55 - Control events
// Use control events let button = NSButton() button.addTarget( self, action: #selector(trackingEndedOutsideHandler), for: .trackingEndedOutside ) -
5:44 - hitTest override
override func hitTest(_ point: NSPoint) -> NSView? { return nil } -
6:24 - autorecalculatesKeyViewLoop
window.autorecalculatesKeyViewLoop = true -
7:37 - Expanded interface delegate — setup
// Set the expanded interface delegate @main class LightAppDelegate: NSObject, NSApplicationDelegate { lazy var lightStatusItem: NSStatusItem = { ... }() func applicationDidFinishLaunching(_ notification: Notification) { // ... lightStatusItem.expandedInterfaceDelegate = self } } -
7:52 - Expanded interface delegate — methods
// Implement the delegate methods 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 // Cancel session to request window dismissal lightStatusItem.expandedInterfaceSession?.cancel() } } -
8:16 - Expanded interface delegate — cancel
// Cancel the session when dismissing 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 // Cancel session to request window dismissal lightStatusItem.expandedInterfaceSession?.cancel() } } -
9:45 - preventsApplicationTerminationWhenModal
window.preventsApplicationTerminationWhenModal = false -
10:18 - Set window identifiers for state restoration
// Set window identifiers for state restoration @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 // ... } } -
11:04 - encodeRestorableState
// Preserve state to recreate the UI @MainActor class MainWindowController: NSWindowController, NSWindowDelegate { // ... override func encodeRestorableState(with coder: NSCoder) { super.encodeRestorableState(with: coder) // ... coder.encode(selectedProduct?.identifier.uuid, forKey: RestorationKeys.productIdentifier) // ... } // ... } -
11:50 - invalidateRestorableState
// Invalidate restorable state when the view hierarchy changes @MainActor class MainWindowController: NSWindowController, NSWindowDelegate { // ... convenience init() { // ... splitViewController.onProductSelected = { [weak self] product in self?.invalidateRestorableState() } } } -
12:26 - restoreWindow(withIdentifier:)
// Restore windows 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) } } } -
13:29 - restoreState
// Restore window UI @MainActor class MainWindowController: NSWindowController, NSWindowDelegate { //... 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 } //... } } -
16:11 - cornerConfiguration
// Subclass NSView to override cornerConfiguration class LocalWeatherView: NSView { // ... override var cornerConfiguration: NSViewCornerConfiguration? { let radius: NSViewCornerRadius = .containerConcentric(minimumCornerRadius) return .uniformCorners(radius: radius) } // ... }
-
-
- 0:00 - Introduction
A modern app takes advantage of how AppKit interfaces with Mac so that its form and function feel in harmony with the rest of the system. That harmony shows up in precision input, continuity across launches, and look and feel.
- 1:06 - Modern input
Precision input devices have been at the heart of the Mac since the very beginning. Learn modern APIs for handling mouse events, keyboard navigation, and status items.
- 1:27 - Modern event handling with gesture recognizers
Gesture recognizers are the modern way to handle mouse events in AppKit. mouseDown: overrides and tracking loops must be replaced with modern APIs.
- 2:25 - Selection, context menus, and drag and drop
AppKit has dedicated view-based APIs for the most common mouseDown: use cases: observe selected on collection and table types for selection, use menuForEvent: or .menu for context menus, and use modern pasteboard delegate methods for drag and drop.
- 3:52 - Text selection in custom views
NSTextSelectionManager brings classic macOS text selection behaviors to any view outside of NSTextView. Attach it to a view to get bidirectional selection, drag and drop, and toggling.
- 4:26 - Control events and gesture recognizers
Control events let you react to user-driven tracking state changes on standard controls. For custom interactions, use NSGestureRecognizer.
- 5:51 - Keyboard navigation and status items
Enable autorecalculatesKeyViewLoop on your window to keep Tab navigation correct as views change. For status items with custom UI, use the expanded interface session API so AppKit can manage keyboard focus correctly.
- 8:57 - Continuity across launches
A great Mac app seamlessly quits and quickly restores. Learn how to handle graceful app termination and state restoration using NSWindowRestoration.
- 9:08 - Graceful app termination
Apps should quit without blocking, especially during system reboots. Set preventsApplicationTerminationWhenModal to false on any sheet or modal that does not strictly require user intervention.
- 9:55 - State restoration
Use NSWindowRestoration to save and recover your app's UI across launches, so it picks up exactly where people left off.
- 14:09 - Design updates
There's one more area where your app and the Mac meet: the UI. Learn about Liquid Glass updates in macOS 27 and the new concentricity API.
- 14:24 - Liquid Glass updates in macOS 27
Liquid Glass continues to evolve in macOS 27, and many updates apply automatically. Sidebars, scroll edge effects, and toolbar items all receive refinements, and a new interactive glass effect gives controls a physical sense of response when clicked.
- 15:41 - Concentricity
The NSViewCornerConfiguration API lets views near a container's corners automatically match the container's corner radius using .containerConcentric, so views adapt to the shape of their container instead of feeling at odds with the rest of the window.
- 16:59 - Next steps
Prioritize gesture recognizers and view-based APIs over mouseDown:, ensure your app is fully keyboard-navigable, make quit and relaunch feel seamless, and adopt concentricity in your view hierarchies.