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:)是安全的委托方式- 让视图支持
canBecomeFirstResponder和canBecomeFocused,确保键盘导航和菜单操作正常
多窗口场景配置(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.userActivities和session.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 自动支持连续互通相机
核心启发
-
做什么:为现有的 iPad 文档编辑器添加 Mac Catalyst 支持,实现多窗口编辑
- 为什么值得做:Mac 用户习惯同时打开多个文档窗口,通过 scene configuration 和
requestSceneSessionActivation几行代码即可实现 - 怎么开始:定义 Document 和 DetailViewer 两种 scene configuration,双击文档列表项时请求新场景
- 为什么值得做:Mac 用户习惯同时打开多个文档窗口,通过 scene configuration 和
-
做什么:在 Mac 版图片管理应用中加入完整的菜单栏和键盘快捷键
- 为什么值得做:Mac 用户通过菜单栏发现功能,UIMenuBuilder 同时服务 Mac 菜单栏和 iPad 快捷键浮层
- 怎么开始:用
UIMenuBuilder构建菜单结构,为每个菜单项绑定UIKeyCommand,确保视图支持canBecomeFirstResponder
-
做什么:为 Mac 版笔记应用添加连续互通相机支持
- 为什么值得做:Mac 用户可以直接用 iPhone 拍照插入笔记,无需先传到电脑
- 怎么开始:在笔记编辑视图设置
pasteConfiguration接受 UIImage,实现paste(itemProviders:)
-
做什么:在 Mac 版文件管理器中使用 Pop-up Button 替换 Segmented Control 做视图切换
- 为什么值得做:Pop-up Button 是 Mac 标准控件,节省空间且符合用户习惯
- 怎么开始:用
UIButton+changesSelectionAsPrimaryAction+showsMenuAsPrimaryAction创建,菜单项对应不同视图模式
关联 Session
- What’s new in Mac Catalyst — Mac Catalyst 新 API 详细介绍
- Optimize the interface of your Mac Catalyst app — Mac Catalyst 界面优化深度指南
- Focus on iPad keyboard navigation — 键盘导航与焦点管理基础
评论
GitHub Issues · utterances