Highlight
这场 Session 以一个 Markdown 编辑器为例,完整展示了怎么把一个 iPadOS 15 的 App 升级到桌面级体验。核心改动围绕三块展开:导航栏、多选交互、文本编辑。
核心内容
这场演示从一个 iPadOS 15 的 Markdown 编辑器开始。这个 app 已经把常用按钮放在导航栏里,次要功能放进菜单和弹窗里。问题在于,用户接上键盘、触控板,或把 app 跑到 Mac Catalyst 后,会期待更密集、更可定制、更直接的操作方式。
Apple 在 iPadOS 16 给 UIKit 补上了几组桌面级 API:导航栏样式、标题菜单、可定制中心工具项、多选菜单、查找替换和编辑菜单。演示的路线很清楚:先把文档操作搬到导航栏,再让工具栏可定制,然后让侧边栏支持轻量多选,最后增强文本编辑。
这不是一次视觉换皮。每一步都有对应的交互问题:文档信息藏得太深,批量处理需要进入编辑模式,文本查找要自己做,Mac Catalyst 上的菜单也要能对得上系统习惯。UIKit 的新 API 把这些问题拆开处理。
详细内容
选择 editor 导航样式
(03:34)iPadOS 16 把导航栏样式分成 Navigator、Browser 和 Editor。Navigator 适合层级数据,Browser 适合文件和文档浏览,Editor 适合聚焦查看或编辑单个文档。这个 Markdown 编辑器正在编辑单个文档,所以选择 Editor。
navigationItem.style = .editor
关键点:
navigationItem是当前 view controller 的导航项。style控制导航栏采用哪种桌面级布局。.editor会把标题靠左放置,并释放导航栏中间区域,后面可以放工具项。
(03:52)原来的 trailing Done 按钮可以改成标准返回动作。演示中用 backAction 表达“回到文档选择器”的行为。
navigationItem.backAction = UIAction(…)
关键点:
backAction定义导航栏返回按钮触发的动作。UIAction包装 dismiss 或回到文档浏览界面的逻辑。- 这让返回入口使用系统标准外观,而不是额外放一个 Done 按钮。
用标题菜单承载文档信息和文档操作
(04:48)标题菜单从导航栏标题处打开,适合放文档元数据和作用于整个文档的操作。示例 app 是 UIDocument backed,所以可以从 fileURL 创建 UIDocumentProperties。
let properties = UIDocumentProperties(url: document.fileURL)
if let itemProvider = NSItemProvider(contentsOf: document.fileURL) {
properties.dragItemsProvider = { _ in
[UIDragItem(itemProvider: itemProvider)]
}
properties.activityViewControllerProvider = {
UIActivityViewController(activityItems: [itemProvider], applicationActivities: nil)
}
}
navigationItem.documentProperties = properties
关键点:
UIDocumentProperties(url:)用文档 URL 创建标题菜单的文档头部信息。NSItemProvider(contentsOf:)为同一个文件 URL 创建可拖拽、可分享的数据提供者。dragItemsProvider返回UIDragItem,用户可以拖动标题菜单里的文档图标,把文档复制到 app 外部。activityViewControllerProvider返回UIActivityViewController,标题菜单里的分享按钮会打开系统分享界面。navigationItem.documentProperties把这组文档信息接到导航栏标题菜单上。
(06:36)重命名也是标题菜单的一部分。系统提供内建 rename UI,但真正修改数据模型仍然由 app 完成。
override func viewDidLoad() {
navigationItem.renameDelegate = self
}
func navigationItem(_ navigationItem: UINavigationItem, didEndRenamingWith title: String) {
// Rename document using methods appropriate to the app’s data model
}
关键点:
- 在
viewDidLoad()中把当前对象设为renameDelegate。 - 设置代理后,标题菜单可以出现系统提供的 Rename 动作。
didEndRenamingWith title在用户提交新标题后调用。- 注释里的重命名逻辑要接到 app 自己的数据模型或文件系统操作上。
(07:09)标题菜单有两类动作:系统建议动作和 app 自定义动作。系统动作有本地化标题和 SF Symbols 图标;自定义动作适合 Export 这类业务功能。
override func duplicate(_ sender: Any?) {
// Duplicate document
}
override func move(_ sender: Any?) {
// Move document
}
func didOpenDocument() {
...
navigationItem.titleMenuProvider = { [unowned self] suggested in
var children = suggested
...
return UIMenu(children: children)
}
}
关键点:
duplicate(_:)和move(_:)是系统动作对应的方法。- UIKit 会把可用的系统动作放进
titleMenuProvider的suggested参数。 children = suggested保留系统建议项。- 返回
UIMenu(children:)后,标题菜单就会显示这些动作。
func didOpenDocument() {
...
navigationItem.titleMenuProvider = { [unowned self] suggested in
var children = suggested
children += [
UIMenu(title: "Export…",
image: UIImage(systemName: "arrow.up.forward.square"),
children: [
UIAction(title: "HTML", image: UIImage(systemName: "safari")) { ... },
UIAction(title: "PDF", image: UIImage(systemName: "doc")) { ... }
])
]
return UIMenu(children: children)
}
}
关键点:
children += [...]在系统建议项后追加自定义菜单。UIMenu(title:image:children:)创建 Export 子菜单。- 两个
UIAction分别代表导出 HTML 和 PDF。 - 演示强调:
titleMenuProvider在 Mac Catalyst 上不会被调用,自定义动作要手动加到main UIMenuSystem才会进入 Mac 的 File 菜单。
把常用工具放进可定制中心区域
(09:35)Editor 样式释放了导航栏中间区域。这个区域适合放原本藏在菜单里的工具,比如同步滚动、插入链接、格式化文本。要开启用户自定义,先给 navigationItem 一个持久化标识。
navigationItem.customizationIdentifier = "editorView"
关键点:
customizationIdentifier开启导航栏中心项的自定义能力。- 字符串需要稳定,系统用它保存用户的工具栏配置。
- 同一个 app 中不同界面应使用不同标识,避免配置互相覆盖。
(10:00)不能缺席的功能适合固定分组。示例里的 Sync Scrolling 没有其他入口,所以设为 fixed group。
UIBarButtonItem(title: "Sync Scrolling", ...).creatingFixedGroup()
关键点:
UIBarButtonItem创建单个工具按钮。creatingFixedGroup()把按钮包装成固定的UIBarButtonItemGroup。- 固定分组不能被用户移除或移动。
(10:23)可替代、非关键的功能适合 optional group。Add Link 可以通过手写 Markdown 链接完成,所以放进可定制项。
UIBarButtonItem(title: "Add Link", ...).creatingOptionalGroup(customizationIdentifier: "addLink")
关键点:
creatingOptionalGroup创建用户可移动、可移除的工具项。customizationIdentifier: "addLink"用来持久化这个按钮的自定义状态。- 这类按钮可以默认显示,也可以由用户移出工具栏。
(10:56)低优先级工具可以出现在自定义面板里,但不默认显示。示例中的 Format 组包含 Bold、Italics、Underline。
UIBarButtonItemGroup.optionalGroup(customizationIdentifier: "textFormat",
isInDefaultCustomization: false,
representativeItem: UIBarButtonItem(title: "Format", ...)
items: [
UIBarButtonItem(title: "Bold", ...),
UIBarButtonItem(title: "Italics", ...),
UIBarButtonItem(title: "Underline", ...),
])
关键点:
optionalGroup直接创建一个可选按钮组。isInDefaultCustomization: false表示它不在默认工具栏中显示。representativeItem给自定义面板和紧凑状态提供代表按钮。items是组内真正执行操作的按钮。
(13:16)导航栏空间不足时,UIKit 会把放不下的中心项放进 overflow menu。普通按钮能自动转成菜单表示,带 custom view 的按钮需要自己提供菜单表示。
sliderGroup.menuRepresentation = UIMenu(title: "Text Size",
preferredElementSize: .small,
children: [
UIAction(title: "Decrease",
image: UIImage(systemName: "minus.magnifyingglass"),
attributes: .keepsMenuPresented) { ... },
UIAction(title: "Reset",
image: UIImage(systemName: "1.magnifyingglass"),
attributes: .keepsMenuPresented) { ... },
UIAction(title: "Increase",
image: UIImage(systemName: "plus.magnifyingglass"),
attributes: .keepsMenuPresented) { ... },
])
关键点:
menuRepresentation指定按钮组进入 overflow menu 后的表示方式。UIMenu(title:preferredElementSize:children:)创建一个紧凑菜单。.small让 iPad 上的菜单项以更紧凑的横向形式出现。.keepsMenuPresented让 Decrease、Reset、Increase 执行后不关闭菜单,用户可以连续调整文本大小。
用轻量多选替代编辑模式
(15:10)示例 app 的侧边栏是目录列表。iOS 16 之前,批量编辑通常要进入单独 edit mode,再把批量操作放到工具栏。iPadOS 16 的多选菜单更适合桌面级交互:用户直接多选,然后在所选项目上打开菜单。
// Enable multiple selection
collectionView.allowsMultipleSelection = true
// Enable keyboard focus
collectionView.allowsFocus = true
// Allow keyboard focus to drive selection
collectionView.selectionFollowsFocus = true
关键点:
allowsMultipleSelection = true允许 collection view 同时选中多个项目。allowsFocus = true让键盘焦点进入 collection view。selectionFollowsFocus = true允许键盘焦点驱动选择,适合外接键盘场景。
(16:11)打开多选后,旧的 didSelectItemAtIndexPath 会在每次选择时触发,导致编辑器滚动到对应标题。解决方式是把“跳转到标题”的逻辑移到新的 primary action 方法里。
func collectionView(_ collectionView: UICollectionView,
performPrimaryActionForItemAt indexPath: IndexPath) {
// Scroll to the tapped element
if let element = dataSource.itemIdentifier(for: indexPath) {
delegate?.outline(self, didChoose: element)
}
}
关键点:
performPrimaryActionForItemAt只处理用户对单个 item 的主操作。dataSource.itemIdentifier(for:)从 index path 找到对应目录元素。delegate?.outline(self, didChoose:)执行滚动到对应内容的业务逻辑。- 选择多个项目时不再触发这段跳转逻辑。
(16:56)iPadOS 16 用新的多项目菜单方法替代旧的单项目 context menu 方法。方法参数里的 indexPaths 数量决定菜单类型。
func collectionView(_ collectionView: UICollectionView,
contextMenuConfigurationForItemsAt indexPaths: [IndexPath],
point: CGPoint) -> UIContextMenuConfiguration? {
if indexPaths.count == 0 {
// Construct an empty space menu
}
else if indexPaths.count == 1 {
// Construct a single item menu
}
else {
// Construct a multi-item menu
}
}
关键点:
indexPaths.count == 0表示用户在空白区域打开菜单。indexPaths.count == 1表示菜单作用于单个项目。indexPaths.count > 1表示菜单作用于多个已选项目。- 同一个 delegate 方法覆盖空白区、单项、多项三种菜单入口。
开启系统查找替换和文本编辑菜单
(18:12)示例 app 用 UITextView 做编辑器,也不需要自定义查找替换行为。系统默认查找替换只需要打开一个属性。
textView.isFindInteractionEnabled = true
关键点:
textView是 Markdown 正文编辑区域。isFindInteractionEnabled = true开启系统 Find and Replace 体验。- 设置后,用户编辑文本时按 Command-F 就能打开查找替换 UI。
(18:34)如果 app 需要在文本编辑菜单里加入业务动作,可以实现新的 UITextViewDelegate 方法。示例在用户选中文本时加入 Hide 动作。
func textView(_ textView: UITextView,
editMenuForTextIn range: NSRange,
suggestedActions: [UIMenuElement]) -> UIMenu? {
if textView.selectedRange.length > 0 {
let customActions = [ UIAction(title: "Hide", ... ) { ... } ]
return UIMenu(children: customActions + suggestedActions)
}
return nil
}
关键点:
editMenuForTextIn range在系统准备显示文本编辑菜单时调用。suggestedActions是系统建议的复制、粘贴等菜单项。selectedRange.length > 0保证只有用户选中文本时才加入 Hide。customActions + suggestedActions把自定义动作放在系统动作前面。- 返回
nil时,系统使用默认编辑菜单。
核心启发
-
做什么:给文档类 app 加标题菜单
- 为什么值得做:标题菜单能同时承载文档信息、拖拽、分享、重命名、复制和移动,适合文档编辑器、笔记、绘图工具。
- 怎么开始:从
UIDocumentProperties(url:)开始,把navigationItem.documentProperties接到现有文档 URL;再用renameDelegate接入重命名。
-
做什么:把隐藏工具迁移到可定制导航栏中心区
- 为什么值得做:用户可以把高频操作留在工具栏,把低频操作移到自定义面板或 overflow menu。
- 怎么开始:先设置
navigationItem.style = .editor和customizationIdentifier,再把现有UIBarButtonItem分成 fixed group、optional group 和非默认 optional group。
-
做什么:把侧边栏批量操作改成轻量多选菜单
- 为什么值得做:用户不需要进入 edit mode,也能对多个目录项、文件、标签或任务执行批量操作。
- 怎么开始:开启
allowsMultipleSelection、allowsFocus、selectionFollowsFocus,把单击跳转逻辑迁移到collectionView(_:performPrimaryActionForItemAt:)。
-
做什么:给文本编辑器补上系统查找替换
- 为什么值得做:Command-F 是桌面用户的肌肉记忆,系统 Find and Replace 可以直接覆盖普通
UITextView场景。 - 怎么开始:先设置
textView.isFindInteractionEnabled = true;如果需要自定义查找体验,再继续研究UIFindInteraction。
- 为什么值得做:Command-F 是桌面用户的肌肉记忆,系统 Find and Replace 可以直接覆盖普通
-
做什么:检查 Mac Catalyst 菜单映射
- 为什么值得做:系统标题菜单动作会进入 Mac 的 File 菜单,自定义标题菜单动作不会自动进入,需要单独处理。
- 怎么开始:把 duplicate、move、rename 这类系统动作先接好;Export 等自定义动作再通过
main UIMenuSystem加到 Mac 菜单中。
关联 Session
- Meet desktop-class iPad — 这场是桌面级 iPad API 总览,本篇是把这些 API 应用到 Markdown 编辑器的实战。
- Adopt desktop-class editing interactions — 深入讲解本文最后提到的 Find interaction 和 Edit Menu。
- Use SwiftUI with UIKit — 如果现有 app 是 UIKit 架构,但想逐步引入 SwiftUI,可以接着看这场。
- Bring multiple windows to your SwiftUI app — 从 SwiftUI 和窗口管理角度补齐桌面级 app 的结构设计。
评论
GitHub Issues · utterances