WWDC Quick Look 💓 By SwiftGGTeam
Build a desktop-class iPad app

Build a desktop-class iPad app

观看原视频

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 会把可用的系统动作放进 titleMenuProvidersuggested 参数。
  • 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 时,系统使用默认编辑菜单。

核心启发

  1. 做什么:给文档类 app 加标题菜单

    • 为什么值得做:标题菜单能同时承载文档信息、拖拽、分享、重命名、复制和移动,适合文档编辑器、笔记、绘图工具。
    • 怎么开始:从 UIDocumentProperties(url:) 开始,把 navigationItem.documentProperties 接到现有文档 URL;再用 renameDelegate 接入重命名。
  2. 做什么:把隐藏工具迁移到可定制导航栏中心区

    • 为什么值得做:用户可以把高频操作留在工具栏,把低频操作移到自定义面板或 overflow menu。
    • 怎么开始:先设置 navigationItem.style = .editorcustomizationIdentifier,再把现有 UIBarButtonItem 分成 fixed group、optional group 和非默认 optional group。
  3. 做什么:把侧边栏批量操作改成轻量多选菜单

    • 为什么值得做:用户不需要进入 edit mode,也能对多个目录项、文件、标签或任务执行批量操作。
    • 怎么开始:开启 allowsMultipleSelectionallowsFocusselectionFollowsFocus,把单击跳转逻辑迁移到 collectionView(_:performPrimaryActionForItemAt:)
  4. 做什么:给文本编辑器补上系统查找替换

    • 为什么值得做:Command-F 是桌面用户的肌肉记忆,系统 Find and Replace 可以直接覆盖普通 UITextView 场景。
    • 怎么开始:先设置 textView.isFindInteractionEnabled = true;如果需要自定义查找体验,再继续研究 UIFindInteraction
  5. 做什么:检查 Mac Catalyst 菜单映射

    • 为什么值得做:系统标题菜单动作会进入 Mac 的 File 菜单,自定义标题菜单动作不会自动进入,需要单独处理。
    • 怎么开始:把 duplicate、move、rename 这类系统动作先接好;Export 等自定义动作再通过 main UIMenuSystem 加到 Mac 菜单中。

关联 Session

评论

GitHub Issues · utterances