WWDC Quick Look 💓 By SwiftGGTeam
Qualities of a great Mac Catalyst app

Qualities of a great Mac Catalyst app

观看原视频

Highlight

优秀的 Mac Catalyst 应用需要关注三个层面:迁移时的架构决策(Mac idiom vs Scaled)、运行时的体验优化(多窗口/菜单/响应链)、以及发布时的分发策略,让 iPad 应用在 Mac 上真正”像原生”。

核心内容

迁移决策:Scaled 还是 Mac Optimized?

把 iPad 应用带到 Mac 时,第一个关键决策是选择界面模式。Xcode 中勾选 Mac 支持后,会出现两个选项:

  • Scaled iPad interface:iPad 界面直接放大,控件保持 iPad 风格,开发工作量最小
  • Mac optimized interface:使用 Mac idiom,100% 缩放,原生 AppKit 控件,需要额外适配

Mac idiom 是推荐选择。它带来像素级清晰的文字和图片、原生 Mac 控件外观、以及更符合 Mac 用户习惯的交互。代价是需要调整布局以适应新的控件尺寸,自定义控件可能需要重新缩放或提供新素材。

生命周期差异

Mac Catalyst 应用的生命周期与 iPad 不同。所有窗口关闭后应用仍可能保持前台运行(菜单栏仍显示应用名)。sceneDidEnterBackground 只在窗口最小化或关闭时触发,频率远低于 iPad。

如果之前用 sceneDidEnterBackground 做自动保存,需要改用定时器确保定期执行。

多窗口与场景配置

Mac 应用通常有多个窗口同时打开。通过定义不同的 scene configuration,可以为文档窗口、详情窗口、编辑窗口等创建独立的配置。双击打开新窗口是 Mac 用户的标准预期。

详细内容

原生按钮控件(06:50

Mac idiom 下,UIButton 自动变成 Mac 风格的边框按钮。通过两个属性组合可以得到四种标准按钮:

// Push Button — 默认样式
let pushButton = UIButton(type: .system)
pushButton.setTitle("Button", for: .normal)

// Pull-down Button — 点击展开菜单
let pullDownButton = UIButton(type: .system)
pullDownButton.menu = UIMenu(title: "", children: actions)
pullDownButton.showsMenuAsPrimaryAction = true

// Pop-up Button — 选择后标题跟随变化
let popupButton = UIButton(type: .system)
popupButton.menu = UIMenu(title: "", children: actions)
popupButton.showsMenuAsPrimaryAction = true
popupButton.changesSelectionAsPrimaryAction = true

关键点:

  • showsMenuAsPrimaryAction = true:左键直接展开菜单,符合 Mac 习惯
  • changesSelectionAsPrimaryAction = true:按钮标题自动反映当前选中项
  • Pop-up Button 的双箭头指示器与 Pull-down 的单箭头不同,用于互斥选项选择
  • UISwitch 设置 title 后自动变成 Checkbox 样式

响应链委托(13:20

Mac 应用依赖响应链路由菜单操作。不要覆盖 nextResponder,而是通过 target(forAction:withSender:) 将操作委托给响应链外的对象:

final class MyView: UIView {
    var myModel: Model!

    override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
        if action == #selector(Model.setAsFavorite(_:)) {
            return myModel
        }
        return super.target(forAction: action, withSender: sender)
    }
}

关键点:

  • 覆盖 nextResponder 会破坏响应链,导致菜单路由失败
  • target(forAction:withSender:) 是安全的委托方式
  • 让视图支持 canBecomeFirstRespondercanBecomeFocused,确保键盘导航和菜单操作正常

多窗口场景配置(14:43

双击打开新窗口需要配置 scene:

let viewDetailActivityType = "viewDetail"
let itemIDKey = "itemID"

// 1. 双击时请求新场景
final class MyView: UIView {
    @objc func viewDoubleClicked(_ sender: Any?) {
        let userActivity = NSUserActivity(activityType: viewDetailActivityType)
        userActivity.userInfo = [itemIDKey: selectedItem.itemID]

        UIApplication.shared.requestSceneSessionActivation(
            nil,
            userActivity: userActivity,
            options: nil,
            errorHandler: { error in
                print("Failed to activate scene: \(error)")
            }
        )
    }
}

// 2. AppDelegate 中根据 activity 选择配置
final class AppDelegate: UIApplicationDelegate {
    func application(_ application: UIApplication,
                     configurationForConnecting session: UISceneSession,
                     options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        if let activity = options.userActivities.first,
           activity.activityType == viewDetailActivityType {
            return UISceneConfiguration(name: "DetailViewer", sessionRole: session.role)
        }
        return UISceneConfiguration(name: "Default Configuration", sessionRole: session.role)
    }
}

// 3. SceneDelegate 中设置数据
final class SceneDelegate: UIWindowSceneDelegate {
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
               options connectionOptions: UIScene.ConnectionOptions) {
        let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity
        if let userActivity = activity,
           let itemId = userActivity.userInfo?[itemIDKey] as? ItemIDType {
            // 设置到根视图控制器
            (scene as? UIWindowScene)?.rootViewController?.setItemID(itemId)
        }
    }
}

关键点:

  • 在 Info.plist 的 Application Scene Manifest 中定义多个 scene configuration
  • requestSceneSessionActivation 触发系统创建新窗口
  • configurationForConnecting 根据 user activity 返回对应的 scene 配置
  • 同时处理 connectionOptions.userActivitiessession.stateRestorationActivity 支持状态恢复

分享功能集成(20:20

// 场景级别的分享配置
final class RootViewController: UIViewController {
    override var activityItemsConfiguration: UIActivityItemsConfigurationReading? {
        UIActivityItemsConfiguration(objects: [image])
    }
}

// 视图级别的分享(上下文菜单)
final class MyView: UIView {
    override var activityItemsConfiguration: UIActivityItemsConfigurationReading? {
        UIActivityItemsConfiguration(objects: images)
    }

    func setupContextMenu() {
        let contextMenuInteraction = UIContextMenuInteraction(delegate: self)
        addInteraction(contextMenuInteraction)
    }
}

关键点:

  • activityItemsConfiguration 同时服务 Mac 工具栏分享按钮和 iOS Siri 分享
  • Mac Catalyst 工具栏中的 NSSharingServicePickerToolbarItem 自动读取该配置
  • 添加上下文菜单交互后,系统会自动加入 Copy 和 Share 选项

连续互通相机(22:08

final class MyView: UIView {
    override var pasteConfiguration: UIPasteConfiguration? {
        UIPasteConfiguration(forAcceptingClass: UIImage.self)
    }

    override func willMove(toWindow newWindow: UIWindow?) {
        super.willMove(toWindow: newWindow)
        let contextMenuInteraction = UIContextMenuInteraction(delegate: self)
        addInteraction(contextMenuInteraction)
    }

    override func paste(itemProviders: [NSItemProvider]) {
        Task {
            for itemProvider in itemProviders {
                if itemProvider.canLoadObject(ofClass: UIImage.self) {
                    if let image = try? await itemProvider.loadObject(ofClass: UIImage.self) {
                        insertImage(image)
                    }
                }
            }
        }
    }
}

关键点:

  • UIPasteConfiguration 声明视图可接受的粘贴类型
  • 配置接受图片后,右键菜单自动显示”从 iPhone 导入”选项
  • 同时启用 Paste 菜单项和拖拽接收功能
  • UITextView 在 macOS Monterey 自动支持连续互通相机

核心启发

  1. 做什么:为现有的 iPad 文档编辑器添加 Mac Catalyst 支持,实现多窗口编辑

    • 为什么值得做:Mac 用户习惯同时打开多个文档窗口,通过 scene configuration 和 requestSceneSessionActivation 几行代码即可实现
    • 怎么开始:定义 Document 和 DetailViewer 两种 scene configuration,双击文档列表项时请求新场景
  2. 做什么:在 Mac 版图片管理应用中加入完整的菜单栏和键盘快捷键

    • 为什么值得做:Mac 用户通过菜单栏发现功能,UIMenuBuilder 同时服务 Mac 菜单栏和 iPad 快捷键浮层
    • 怎么开始:用 UIMenuBuilder 构建菜单结构,为每个菜单项绑定 UIKeyCommand,确保视图支持 canBecomeFirstResponder
  3. 做什么:为 Mac 版笔记应用添加连续互通相机支持

    • 为什么值得做:Mac 用户可以直接用 iPhone 拍照插入笔记,无需先传到电脑
    • 怎么开始:在笔记编辑视图设置 pasteConfiguration 接受 UIImage,实现 paste(itemProviders:)
  4. 做什么:在 Mac 版文件管理器中使用 Pop-up Button 替换 Segmented Control 做视图切换

    • 为什么值得做:Pop-up Button 是 Mac 标准控件,节省空间且符合用户习惯
    • 怎么开始:用 UIButton + changesSelectionAsPrimaryAction + showsMenuAsPrimaryAction 创建,菜单项对应不同视图模式

关联 Session

评论

GitHub Issues · utterances