认识桌面级 iPad
Meet desktop-class iPad
2022年6月6日
一句话判断
UINavigationBar 在 iOS 16 终于有了 navigator/browser/editor 三种风格模式,配合 centerItemGroups 和 titleMenu,iPad 应用的导航栏可以做到和桌面端工具栏一样强大——而且 Mac Catalyst 几乎零成本自动适配。
这场 Session 讲了什么
Session 聚焦在 UINavigationBar 的全面升级,分三个板块:界面可发现性、文档支持、搜索增强。
首先是三种导航栏风格。UINavigationItem 新增了 style 属性,可以选择 navigator(默认,传统居中标题)、browser(标题左对齐,适合 Files/Safari 这类有历史导航的场景)、editor(标题左对齐且有返回按钮,适合文档编辑器)。browser 和 editor 风格把标题挪到左侧,释放了中间的大量空间,iOS 16 就利用这个空间引入了 centerItemGroups。
centerItemGroups 是一整套按钮组织系统。UIBarButtonItem 现在被组织成 UIBarButtonItemGroup,分为三种类型:fixed group(固定位置不可移动)、movable group(可移动但不可移除)、optional group(可移动也可移除)。optional group 还能指定 representativeItem,空间不够时 UIKit 会自动把整个组折叠成一个下拉菜单。用户可以通过自定义 UI 重新排列这些组,自定义配置会通过 customizationIdentifier 自动持久化。
然后是文档相关功能。titleMenuProvider 闭包让你给导航栏标题加上菜单,默认提供复制、移动、重命名、导出、打印五个系统命令。配合 UIDocumentProperties 还能从标题菜单发起拖拽和分享。重命名通过 UINavigationItemRenameDelegate 实现,UIKit 直接在标题视图内提供内联编辑体验。
最后是搜索。iPadOS 上搜索栏现在默认内联到导航栏里,而不是像之前那样独占一整行。新增的 searchSuggestions API 让你能在用户输入时实时更新搜索建议。
值得深挖的点
centerItemGroups 的三层层级设计。 fixed、movable、optional 三种 group 类型不是随意分的,它们对应了不同的自定义粒度。fixed group 适合核心操作(比如插入图片),用户无论如何都不能把它移走。movable group 适合重要但位置灵活的操作(比如画笔),用户可以调整位置但不能删除。optional group 适合进阶功能(比如形状工具),用户可以完全从工具栏移除。这种分层让 App 在不同屏幕尺寸下都有合理的降级策略——空间不够时先折叠 optional group 的 representativeItem,再不够就移入 overflow 菜单。
Mac Catalyst 的自动 NSToolbar 映射。 这可能是整个 Session 里最被低估的功能。你在 iPad 上配置的 centerItemGroups,到了 Mac Catalyst 上会自动转译成 NSToolbar 的 item。leading、center、trailing 三组按顺序排列,自定义属性也完全保留。这意味着你写一套 UIBarButtonItemGroup 配置,iPad 和 Mac 两个平台都能用,而且 Mac 端的自定义体验和原生 NSToolbar 一模一样。
搜索建议的双方法设计。 UISearchResultsUpdating 现在有两个 updateSearchResults 方法:一个响应查询文本变化(用于更新建议列表),一个响应建议被选中(用于执行搜索)。这种分离很聪明——更新建议不需要真正执行搜索,只是改变候选列表;选中建议才真正触发搜索行为。这避免了每次击键都发起网络请求的问题。
代码片段
配置 centerItemGroups,包含三种类型的按钮组:
navigationItem.customizationIdentifier = "com.myapp.editor" // 启用自定义持久化
navigationItem.centerItemGroups = [
// 固定组:始终显示,不可移动
UIBarButtonItem(title: "Insert", image: UIImage(systemName: "photo"),
primaryAction: UIAction { _ in }).creatingFixedGroup(),
// 可移动组:可以调整位置,但不能移除
UIBarButtonItem(title: "Draw", image: UIImage(systemName: "scribble"),
primaryAction: UIAction { _ in }).creatingMovableGroup(customizationIdentifier: "Draw"),
// 可选组:可以移动、移除,空间不够时折叠为菜单
.optionalGroup(customizationIdentifier: "Shapes",
representativeItem: UIBarButtonItem(title: "Shapes", image: UIImage(systemName: "square.on.circle")),
items: [
UIBarButtonItem(title: "Square", image: UIImage(systemName: "square"), primaryAction: UIAction { _ in }),
UIBarButtonItem(title: "Circle", image: UIImage(systemName: "circle"), primaryAction: UIAction { _ in }),
]),
// 不在默认自定义中的可选组
.optionalGroup(customizationIdentifier: "Format",
isInDefaultCustomization: false, // 默认不显示,需要用户手动添加
representativeItem: UIBarButtonItem(title: "BIU", image: UIImage(systemName: "bold.italic.underline")),
items: [
UIBarButtonItem(title: "Bold", image: UIImage(systemName: "bold"), primaryAction: UIAction { _ in }),
UIBarButtonItem(title: "Italic", image: UIImage(systemName: "italic"), primaryAction: UIAction { _ in }),
])
]
isInDefaultCustomization: false 是个很有用的参数——它让某些高级功能默认不出现在工具栏,但用户可以通过自定义 UI 手动添加。
配置 titleMenu 和文档属性,支持拖拽和分享:
// 标题菜单:在系统建议的操作之后追加自定义项
navigationItem.titleMenuProvider = { suggestedActions in
var children = suggestedActions
children += [
UIAction(title: "Comments", image: UIImage(systemName: "text.bubble")) { _ in }
]
return UIMenu(children: children)
}
// 文档属性:启用拖拽和分享
let documentProperties = UIDocumentProperties(url: documentURL)
if let itemProvider = NSItemProvider(contentsOf: documentURL) {
documentProperties.dragItemsProvider = { _ in
[UIDragItem(itemProvider: itemProvider)]
}
documentProperties.activityViewControllerProvider = {
UIActivityViewController(activityItems: [itemProvider], applicationActivities: nil)
}
}
navigationItem.documentProperties = documentProperties
实现搜索建议:
extension ViewController: UISearchResultsUpdating {
// 查询文本变化时更新建议
func updateSearchResults(for searchController: UISearchController) {
let suggestions = fetchSuggestions(for: searchController.searchBar.text)
searchController.searchSuggestions = suggestions.map { name, icon in
UISearchSuggestionItem(localizedSuggestion: name, iconImage: icon)
}
}
// 用户选中建议时执行搜索
func updateSearchResults(for searchController: UISearchController,
selecting searchSuggestion: UISearchSuggestion) {
if let suggestion = searchSuggestion.localizedSuggestion {
searchController.searchBar.text = suggestion
// 执行实际搜索
}
}
}
最佳实践
carefully 选择导航栏风格。大多数 App 用默认的 navigator 就够了。只有当你有明确的”历史导航”或”文档编辑”场景时才切换到 browser 或 editor。别为了”看起来更桌面”就用 editor 风格——如果用户实际上不在编辑文档,editor 风格的返回按钮反而会造成困惑。
给 optional group 一定要设置 representativeItem。没有 representativeItem 的 optional group 在空间不足时会直接被藏到 overflow 菜单里,用户很难发现。有了 representativeItem,UIKit 至少能折叠成一个可点击的按钮,让功能保持可发现。
传 identifier 而不是整个模型给 titleMenu 和 document 操作。和 SwiftUI 的 openWindow 一样,值类型会被复制。用 identifier 让 model store 成为单一数据源。
搜索栏位置在 iPad 上默认是内联的,如果你需要恢复旧行为(独占一行),用 navigationItem.preferredSearchBarPlacement。
还有什么值得关注
- Session 是三集系列的中间一集,另外两集是 “Adopt desktop-class editing interactions”(编辑菜单和查找替换)和 “Build a desktop-class iPad app”(完整示例)
- Mac Catalyst 下,titleMenuProvider 添加的自定义项不会出现在 File 菜单里,需要用 UIMenuBuilder 手动添加
- UIDocumentBrowserViewController 新增了 renamed API,配合 renameDelegate 使用更方便
- UISearchTextField 也有独立的 searchSuggestions 属性,不依赖 UISearchController