WWDC Quick Look 💓 By SwiftGGTeam
Modernize your AppKit app

Modernize your AppKit app

观看原视频

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:、维护拖拽状态、处理菜单弹出。NSClickGestureRecognizerNSPanGestureRecognizer 直接挂在 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: 来做选择跟踪,应该改用 NSCollectionViewItemNSTableRowViewselected 属性,或者监听 NSTableViewDelegateNSOutlineViewDelegate 的选择变化回调。

右键菜单也有现成方案。NSView.defaultMenu 给所有实例共享一个菜单,NSResponder.menu 给每个响应者单独设置,NSView.menuForEvent 则根据事件动态创建菜单。三种场景都覆盖了,不需要自己算坐标。

03:35)拖拽同样如此。用 tableView(_:pasteboardWriterForRow:) 返回一个 NSPasteboardItem,系统会处理后续。NSCollectionViewNSOutlineViewNSBrowser 都有对应的现代拖拽代理方法。

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)
    }
}

关键点:

  • cornerConfigurationNSView 的可覆盖属性
  • .containerConcentric 根据容器视图自动计算半径
  • minimumCornerRadius 确保即使远离角落也有最小圆角
  • .uniformCorners 让四个角使用相同的半径
  • 系统会自动计算视图与容器角落的距离,越近越匹配

核心启发

1. 给旧 App 做一次 mouseDown 大扫除

全局搜索 mouseDown:mouseDragged:mouseUp:。如果只是为了处理点击、拖拽、选择,换成手势识别器或控制事件。这不仅能减少代码量,还能让 SwiftUI 嵌入时事件不再打架。

入口:NSClickGestureRecognizerNSPanGestureRecognizerNSControl.addTarget(_:action:for:)

2. 给每个窗口加上状态恢复

用户重启 Mac 后,App 应该回到退出前的样子。给主窗口、偏好设置窗口、图片查看窗口各加一个 NSWindowRestoration 实现。只存 UI 状态(选中了哪一项、滚动到什么位置),不存文档数据。

入口:window.isRestorable = trueencodeRestorableState(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