WWDC Quick Look 💓 By SwiftGGTeam
What’s new in AppKit

What’s new in AppKit

观看原视频

Highlight

macOS Sequoia 让 AppKit 的 SwiftUI 集成更进一步:NSHostingMenu 共享菜单定义、NSView 支持 SwiftUI Animation 类型做可中断动画,同时新增 Text Entry Suggestions、系统光标、Save Panel 文件格式选择器等标准组件。


核心内容

macOS 上的 app 长期面临一个矛盾:AppKit 提供完整的系统级控制力,SwiftUI 带来声明式开发的效率,但两者之间的边界一直不够顺畅。菜单要用 NSMenu 手写,动画要靠 NSAnimationContext 的旧式 API,文本输入建议每个 app 各自实现一套——这些缝隙拖慢了开发速度,也增加了维护成本。

Sequoia 针对这些缝隙逐一填补。NSHostingMenu 让你用 SwiftUI View 定义菜单内容,同一个菜单定义在 AppKit 和 SwiftUI 之间共享。NSAnimationContext.animate(with:) 现在接受 SwiftUI Animation 类型,包括自定义动画,而且动画天然支持中断和重定向。NSTextField 新增 suggestionsDelegate,用一套标准协议就能提供自定义输入建议,搜索框、文本框都能用。系统光标(frame resize、column/row resize、zoom in/out)终于公开到 SDK,不用再自己画了。

系统级功能方面,Writing Tools 自动作用于标准 NSTextView,Genmoji 以内联图片形式进入文本流,Image Playground 通过 ImagePlaygroundViewController 几行代码就能集成。Window Tiling 对所有现有 app 自动生效,但开发者需要检查窗口的最小/最大尺寸约束是否合理。


详细内容

Image Playground 集成(02:09

extension DocumentCanvasViewController {

    @IBAction
    func importFromImagePlayground(_ sender: Any?) {
        let playground = ImagePlaygroundViewController()
        playground.delegate = self

        playground.concepts = [.text("birthday card")]
        playground.sourceImage = NSImage(named: "balloons")

        presentAsSheet(playground)
    }

}

extension DocumentCanvasViewController: ImagePlaygroundViewController.Delegate {

    func imagePlaygroundViewController(
        _ imagePlaygroundViewController: ImagePlaygroundViewController,
        didCreateImageAt resultingImageURL: URL
    ) {
        if let image = NSImage(contentsOf: resultingImageURL) {
            imageView.image = image
        }
        dismiss(imagePlaygroundViewController)
    }

}

关键点:

  • ImagePlaygroundViewController 是整个体验的入口,设置 delegate 监听生命周期事件
  • concepts 接受文本概念描述,sourceImage 提供图形参考——两个都是可选的,用来给用户一个起点
  • 生成完成后回调返回的是沙盒临时目录中的文件 URL,需要你自己读取并持久化
  • presentAsSheet(_:) 弹出,创建完成后在 delegate 回调中 dismiss

Window Tiling 与窗口尺寸(05:50

Window Tiling 把窗口拖到屏幕边缘自动填充半屏或四分之一屏,Option + 拖动预览最近边缘吸附效果。两个并排窗口同步调整大小。新窗口通过 cascadingReferenceFrame 获取已有窗口的非吸附 frame 来计算级联偏移。对于终端类 app,resizeIncrements 限制窗口按字符宽高步进:

window.resizeIncrements = NSSize(width: characterWidth, height: characterHeight)

NSHostingMenu:SwiftUI 菜单用于 AppKit(07:05

struct ActionMenu: View {

    var body: some View {
        Toggle("Use Groups", isOn: $useGroups)
        Picker("Sort By", selection: $sortOrder) {
            ForEach(SortOrder.allCases) { Text($0.title) }
        }.pickerStyle(.inline)
        Button("Customize View…") { <#Action#> }
    }

}

let menu = NSHostingMenu(rootView: ActionMenu())

let pullDown = NSPopUpButton(image: image, pullDownMenu: menu)

关键点:

  • 菜单内容用标准 SwiftUI 声明:Toggle 做开关、Picker 做选择、Button 做动作
  • NSHostingMenu 是新的 NSMenu 子类,把 SwiftUI View 包装成 NSMenu
  • 包装后的 menu 可以用在任何接受 NSMenu 的 AppKit 上下文中,比如 NSPopUpButton 的下拉菜单

NSView 的 SwiftUI 动画(07:43

NSAnimationContext.animate(with: .spring(duration: 0.3)) {
    drawer.isExpanded.toggle()
}

搭配 @Invalidating 属性包装器,让属性变更自动触发重排:

class PaletteView: NSView {

    @Invalidating(.layout)
    var isExpanded: Bool = false

    private func onHover(_ isHovered: Bool) {
        NSAnimationContext.animate(with: .spring) {
            isExpanded = isHovered
            layoutSubtreeIfNeeded()
        }
    }

}

关键点:

  • NSAnimationContext.animate(with:) 接受任意 SwiftUI Animation 类型,包括 CustomAnimation
  • 动画天然可中断、可重定向——用户中途改变方向不会卡顿
  • @Invalidating(.layout) 标记属性变更后自动标记 view 需要重新布局

文本高亮(10:31

let attributes: [NSAttributedString.Key: Any] = [
    .textHighlight: NSAttributedString.TextHighlightStyle.systemDefault,
    .textHighlightColorScheme: NSAttributedString.TextHighlightColorScheme.pink,
]

在支持富文本的 NSTextView 中自动可用,右键 Font 菜单的 Highlight 子菜单可选择颜色方案。

Text Entry Suggestions(17:49

class MuseumTextSuggestionsController: NSTextSuggestionsDelegate {

    typealias SuggestionItemType = Museum

    func textField(
        _ textField: NSTextField,
        provideUpdatedSuggestions responseHandler: @escaping ((ItemResponse) -> Void)
    ) {
        let searchString = textField.stringValue

        func museumItem(_ museum: Museum) -> Item {
            var item = NSSuggestionItem(representedValue: museum, title: museum.name)
            item.secondaryTitle = museum.address
            return item
        }

        let favoriteMuseums = Museum.favorites.filter({
            $0.matches(searchString)
        })
        let favorites = NSSuggestionItemSection(
            title: NSLocalizedString("Favorites", comment: ""),
            items: favoriteMuseums.map(museumItem(_:))
        )
        var response = NSSuggestionItemResponse(itemSections: [favorites])
        response.phase = .intermediate
        responseHandler(response)

        Task {
            let otherMuseums = await Museum.allMatching(searchString)
            let nonFavorites = NSSuggestionItemSection(items: otherMuseums.map(museumItem(_:)))
            var response = NSSuggestionItemResponse(itemSections: [favorites, nonFavorites])
            response.phase = .final
            responseHandler(response)
        }
    }

}

关键点:

  • 设置 textField.suggestionsDelegate 即可启用,支持 NSTextField 及其子类(如 NSSearchField)
  • 可以分两阶段返回:先给 .intermediate 同步结果,再异步补全 .final 结果
  • NSSuggestionItem 支持 secondaryTitle 显示附加信息,NSSuggestionItemSection 支持分组

核心启发

  • 做什么:在 NSTextField 中接入 Text Entry Suggestions,提供搜索建议或自动补全。为什么值得做:用户期望输入时有即时反馈,这套 API 是系统标准方案,支持同步+异步分阶段返回,比自建弹窗更一致。怎么开始:实现 NSTextSuggestionsDelegate 协议,设置 textField.suggestionsDelegate,先返回 .intermediate 本地结果,再异步返回 .final 远程结果。

  • 做什么:用 NSHostingMenu 把菜单定义迁移到 SwiftUI,跨 AppKit/SwiftUI 共享。为什么值得做:菜单逻辑只维护一份,Toggle、Picker、Button 的声明式写法比 NSMenuItem 手动构建更清晰。怎么开始:创建一个 SwiftUI View 作为菜单 body,用 NSHostingMenu(rootView:) 包装,替换现有 NSMenu 构建代码。

  • 做什么:用 SwiftUI Animation 替换 NSAnimationContext 的旧式动画。为什么值得做:Spring 动画、自定义动画一次声明即可使用,且天然支持中断和重定向,交互体验更好。怎么开始:把 NSAnimationContext.runAnimationGroup 替换为 NSAnimationContext.animate(with: .spring(duration:)),配合 @Invalidating(.layout) 标记需要动画的属性。

  • 做什么:集成 Image Playground 作为图片输入源。为什么值得做:如果你的 app 已经支持从照片库或 Finder 插入图片,Image Playground 是零摩擦的第三条路径,ImagePlaygroundViewController 几行代码就能弹出生成界面。怎么开始:创建 ImagePlaygroundViewController,设置 delegate 和可选的 concepts/sourceImage,以 sheet 方式弹出,在 delegate 回调中读取文件 URL。

  • 做什么:用系统光标 API 替换自绘光标。为什么值得做:系统光标自动支持无障碍大尺寸和指针颜色自定义,自绘光标需要额外处理这些场景。怎么开始:用 NSCursor.frameResize(position:directions:)NSCursor.columnResize(directions:)NSCursor.zoomIn/.zoomOut 替换自定义光标图片。


关联 Session

评论

GitHub Issues · utterances