如何设计和实现 iPad app 的多窗口功能

2019/9/21 posted in  Design Develop comments

在 iOS 13 及之后的版本中,iPad app 可以支持多窗口功能。例如,在一个具有文档创建功能的 iPad app 中,人们可以同时打开多个文档窗口。这篇文章会从设计和实现层面讲述 iPad 多窗口功能的触发方法、样式以及功能等主题。

注意:如果你想把你的 iPad app 带到 Mac 中去,想让 Mac 版本支持多窗口功能,那就必须在 iPad app 中支持多窗口功能。有关于把 iPad app 带到 Mac 中去,参见 「把 iPad 上的 app 带到 Mac 中去(上)」「把 iPad 上的 app 带到 Mac 中去(下)」

目录

人们可以好多种方式打开一个新的窗口,例如:

  • 从 Dock 栏拖拽 app 的图标到屏幕的一边,选择一个当前的窗口或者创建一个新的窗口;
  • 拖拽一个对象到屏幕的一边,把它释放到系统提供的可供释放的目标上;
  • 长按 Dock 栏上或者首页上一个 app 的图标,点击情景菜单中出现的「显示所有窗口」,再点击添加按钮;
  • 长按一个对象直到出现一个情景菜单,其中包括在新窗口中查看对象的选项。

通常情况下,iPad app 使用两种类型的窗口。「Primary window 主要窗口」包括了多个 app 对象以及与之相关的动作,通常人们会一直与「primary window」保持交互。「Auxiliary window 辅助窗口」包含了一个对象以及与之相关联的动作,人们在关闭辅助窗口前通常会与其只交互一次。例如,在「邮件」app 中,主要窗口包括了邮箱,而一条单独的信息会展示在辅助窗口中。

  • 支持主要窗口和辅助窗口的多窗口体验:因为主要窗口总是包含高层级的对象,人们会从打开展示了不同区域内容的多窗口中受益。例如,人们可能想要首要邮件窗口一个展示他们的收件箱,另一个展示草稿箱。正如你所期待的,多个辅助窗口让用户更容易浏览和在多个条目间工作,例如多条邮件信息。
  • 确保一个辅助窗口自己是有用的:辅助窗口应该给人们 app 的内容和功能的额外视图。避免只是使用辅助窗口去提供主要窗口内容的选项或工具。
  • 在辅助窗口使用一个「Done 完成」或者「Close 关闭」按钮:因为辅助窗口只包含了一个任务或对象的内容和动作,人们期望在完成时能够关闭它。不要在按钮中使用「Back 后退」来关闭窗口。你可以在按钮中使用「Back 后退」去帮助人们返回窗口中的上一个视图。

设计意图

在 iOS 12 和之前的版本中,如果你进入切换 app 界面,你会看到下面这种 app 的网格布局,你可以点击其中一个进入那个 app。

在 iOS 13 中,一切看上去还跟以前一样,但在这个界面中,不再全是一个个的应用,而是一个个的窗口。

首先来看下多窗口功能在 app 中是什么样子的,从中解答两个问题,我的 app 应该支持多窗口功能吗?如果需要支持,这些多窗口应该是怎样的?具体的我应该在哪里添加多窗口功能?它们应该怎么表现?用户是如何思考它们的?我们将通过看几个 Apple 原生应用的例子来回答上面的问题。
先来看「Safari 浏览器」,Safari 是多窗口功能的早产儿,因为在 iOS 13 之前的版本就已经基本具备了多窗口功能,下图中展示了在 iOS 12 中,Safari 通过分隔视图实现了多窗口功能。

在 iOS 13 或者 iPadOS 13 中,是下面这个样式,实际上看起来和之前没有什么不同。因为在之前,在 Safari 中能使用多窗口功能打开多个网页就已经非常重要,现在我们将多窗口功能带到了整个系统中。

具体来看,Safari 的每个窗口都是完全一样的,它们就像是另一个界面的克隆。每一个窗口都可以做 app 所有的事情,这很重要,因为在 iPad 上用户应该在他们想要的那个窗口中做任何的事情。如果人们觉得需要,还可以创建更多的窗口。但是需要注意,如果你的 app 必须依赖多窗口功能才能工作,那就有些问题了。所以说每一个窗口都是另一个窗口的克隆并不是必须的,但用户打开的第一个窗口应该可以完成所有的事情更加重要。而在 Safari 的这个例子中,每一个窗口都是一样的,在大多数的 app 中也是这样。
在 iOS 13 的 Safari 中,你可以随时从多窗口的 Split View 模式转换到 Slide Over 模式,并将屏幕边缘的窗口滑出去,以进行一会儿更加专注的工作。

第二个例子是一个基于文档的 app,Pages。在任何基于文档的 app 中,用户都会希望能够同时在不同的窗口中查看多个文档。所以你很有必要支持多窗口功能。但有一个点你需要注意,在每个窗口的左上角,有一个「文档」按钮,你可以通过这个按钮访问你想要的其他文档。这也跟 Safari 一样,每一个窗口都是一样的。当然,并不是每一个基于文档的 app 都要做成这样,但在这里,这是讲得通的。

第三个例子是「Maps 地图」,它也是只有一种多窗口类型的 app,要在这里提这个 app 的原因,是它更可以展示需要支持多窗口功能的必要性。通常情况下,你打开地图,去到某个地方然后关闭它。但当你计划你的晚上安排时,你可能想要先去吃晚饭,晚饭后再去看一个演出,在此时使用多窗口功能就很有帮助,你可以在两者之间思考并改变它们。所以我们不能确保在任何时候多窗口功能都是有用的,但我们知道有时候会用上它。

同时,多窗口功能是系统级别的。当你已经确定了要去哪里吃晚饭后,你可以关闭右边的地图,将其替换成「Notes 备忘录」app,从而去完成其他事情。

第四个例子是「Mail 邮件」,这是第一个有不同类型多窗口功能的 app。当你回复一条消息时,你可以把这个模态窗口变成一个单独的窗口,以 Slide Over 或者 Split View 的方式展示。你可以看到在单独的这个信息窗口中有一个发送按钮和一个取消按钮,你不能在这个窗口中回到上一级的邮件列表中,这种窗口是经过特殊设计的,当你点击发送或者取消按钮时,这个窗口就会关闭,关闭时会有一个过渡动画,这也可以应用在你的 app 中。

你可以通过滑动窗口底部的知识条在多个 Slide Over 的窗口中切换。

第五个例子是「Messages 信息」,它也有不同类型的多窗口。当你把一条消息拖动到屏幕边缘时,就可以开启一个单独的窗口,一个只属于那个对话的窗口。你会在窗口顶部看到一个完成按钮,点击就可以完成这个任务。在浏览一条信息时可以同时查看另一条信息作为参考是非常有帮助的。所以在这种需要另一个页面同时作为参考时,就需要支持多窗口功能。

最后一个例子是「Calendar 日历」,日历已经支持了拖放功能,但现在通过多窗口功能,你可以在不同的窗口中同时查看两个不同周的事项,你还可以从一边拖拽一个事项到另一边。所以如果你的 app 支持了多窗口的拖放功能,你也可以获得上面这个功能的强大能力。

今年我们介绍了将 iPad app 带到 Mac 上去,Mac app 都有多个窗口,如果没有多窗口功能,Mac app 会非常奇怪。但现在有了 iOS 13 的多窗口功能,这件事就变得更加顺理成章了。

那具体用户可以通过什么样的交互打开多窗口功能呢?
首先来看下系统提供了哪些交互。在 App Expose 中,右上角会有一个小按钮用来打开新的窗口,这是系统自带的功能。

另外一个就是可以通过拖拽 app 的图标到屏幕的边缘开启多窗口功能,因为当你那样操作时,就像是确切的在说我要在这里开启一个新的窗口。

再来看下用户会根据已有的东西做出哪些动作来想开启多窗口功能。用户可以直接拖拽 Safari 的某个 tab 到屏幕边缘来开启多窗口功能,这种交互系统不能自动帮你实现,但你可以通过 API 适配实现这个交互。

如果用户可以拖拽起某个对象,并且打开一个新的窗口可以讲得通的话,那用户就期望有这么一个功能,你应该去实现他。比较普遍的例子就是任何形式的「master-detail view 主要-详情视图」,比如在「Mail 邮件」中,左侧主视图中的每一个 cell 都代表着一条消息,如果点击一条消息,那就可以在详情视图中看到完整的消息。所以用户就会期望当拖起 table view 中的一条消息到屏幕边缘时可以打开一个新的窗口。

你也可以通过一个确切的动作创建一个新的窗口。在像 Safari 这样有链接的应用中,可以通过长按一个链接显示一个弹窗,在弹窗中有一个按钮,通过点击这个按钮可以在新窗口中打开链接。

用户不应该被强制使用多窗口功能,应该需要一个用户触发的确切的动作才能开启多窗口功能。

UIScene 生命周期

要在 iPadOS 中实现多窗口功能,你需要特别关注两个类:UIWindowScene 和 UISceneSession。你可能在之前已经熟悉了在 UIKit 中的 UI 是如何构建的。你有一个 Screen,然后有多个 Window,每个 Window 下又有不同的 View。

而现在,UIWindowScene 插入到了 Screen 和 Window 之间,这可以让多个 Window 属于用户界面的一个单独的实例,而不需要强制改变应用或用户界面结构太多。

从基本上来看,一个 Scene 包括了根据你的需要由系统创建的用户界面。无论用户在什么时候执行了一个拖拽动作,都会根据你的用户界面请求一个新的窗口打开。之后,如果那个 Scene 应该进入后台,不再与其发生交互,那么系统就可以决定挂起它、不再需要它、销毁这个 Scene。

当我们不需要那个 Scene 时,我们可以将其移到后台,但在 switcher 中用户知道它还在。你需要一种方法去理解什么东西实际上还在 switcher 中。那就是 UISceneSession 该出现的地方。UISceneSession 代表了一种存留的用户状态,表示用户最后进行操作的那个状态。现在,它们有了一个清晰的系统角色,这可能是一个你可以在真实的设备上与其进行交互的标准的应用界面,也可能是一个外部显示器界面。每次系统上创建一个新的窗口,你的应用就会被软件代理通知有一个新的 session 被创建。无论是通过 API 交互还是用户在 switcher 中上划,每当用户销毁/关闭掉一个窗口时,你也会被通知那个 session 被销毁。UIScene 通过你的应用的生命周期与这些 session 相连接或断开连接。

为了更好地了解这对一个 app 的生命周期的影响,这里用一个图表进一步说明。在应用中有三个 Session,它们代表着系统中三种不同的空间,在这些空间中应用正在展示它们的界面。现在,三个 session 都断开了连接,它们处于后台线之下,而此时整个 app 的状态也是在后台中,如图所示。

现在,如果我激活一个空间,一个 Session 也被激活,整个 app 也会随那个连接在一起的 Scene 进入前台活跃状态。

当我让一个 Scene 回到后台中,整个应用的状态也会随之进入后台。而如果我切换到另外两个 Scene,我的应用状态就会变成前台活跃状态。

说到 UIApplication 和 UIApplicationDelegate 两个类,我们习惯于把系统的其他界面、ApplicationDelegate 中的生命周期和 Application 对象组合在一起,现在如果还是这样就会有些不清楚。

所以我们进行进一步的拆分。Application 继续表示作为一个系统进程的系统状态,ApplicationDelegate 可以获得事件和有关于进程、事件、运行、中断的代理通知。而现在 Scene 概括了 UI 状态,SceneDelegate 可以得到在特定场景打开 URL、从后台返回到前台等等这样的通知。SceneSession 代表存留的 UI 状态。

由于上面概念上的变化,许多 API 需要进行修改,但大部分都是相似的,基本上都是从 application 到 scene 的变化。

进一步实现

之前,用户在 swticher 中会看到一个 app 的四个窗口,这也是用户自然想到的。但作为开发者,我们鼓励你把他们直接看做是 scene 和 scene session。这之间的区别是很重要的,因为用户看到的窗口只是一张截图,应用的 scene 可能并没有在你的 app 中加载,它们可以根据需要出现和关闭,而 session 对你来说是总是可用的。正因如此,我们使用 session 在程序上控制窗口。

因为多窗口功能,我们增加了新的 API,可以通过这些 API 在程序上创建新窗口、更新 app switcher 中的截图、响应用户手势或者某个文档过期等事件而关闭窗口。

下面看一些代码实例。
第一个是 requestSceneSessionActivation API,这可以让你在前台打开已有的 scene 或者创建一个新的 scene。在下面的这个例子中是打开一个新的文档。首先检查应用中是否有存在的 scene,如果有打开它,如果没有,就创建一个新的。

// Open a New Window

@IBAction func handleLongPress(forDocumentAt url: URL) {

    if let existingSession = findSession(for: url) {
        UIApplication.shared.requestSceneSessionActivation(existingSession, userActivity: nil,
options: nil)
    } else {
        let activity = NSUserActivity(activityType: “com.example.MyApp.EditDocument”)
        activity.userInfo[“url”] = url

        UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity,
options: nil)
        }

}

第二个是 requestSceneSessionRefresh API,你可以在你接到一个信息的推送通知的地方或者在像日历这样的 app 中当一个事件发生改变时使用这个 API。当你调起这个方法,UIKit 会安排一个在未来时间点的更新,那时候会连接上后台中的 scene。你将有机会更新 UI,一个新的截图被截取并且保存到之后的 app switcher 中。

// Update App Switcher Snapshot

func application(_ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable:Any],
    fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {

    let session = findSession(for: userInfo)
    application.requestSceneSessionRefresh(session)

}

最后一个是 requestSceneSessionDestructionAPI,你可以使用它关闭 scene,并且可以根据语义定义关闭时的动画过渡效果。你可以在「邮件」应用中的写邮件窗口看到这个实例,当用户发送一条消息时,窗口会从上面滑出屏幕,而当用户保存所写内容为草稿时,窗口会从下面滑出并提醒用户已经保存。你也可以在你的 app 的 scene 中使用相同的动画过渡效果。

// Close a Window
func closeWindow(and action: DraftAction) {
    let options = UIWindowScene.DestructionRequestOptions()

    switch action {
    case .send: options.windowDismissalAnimation = .commit
    case .save: options.windowDismissalAnimation = .decline
    case .delete: options.windowDismissalAnimation = .standard
    }
    let session = view.window!.windowScene!.session
    UIApplication.shared.requestSceneSessionDestruction(session, options: options)

}

最佳实践

正如前面所说,我们已经从 UIApplicationDelegate 中拆分了用户界面状态和进程的生命周期责任,也拆分了 UIApplication。你可以同时看到应用的多个 scene,你可能会有一个浅色的状态栏内容,还有一个深色的状态栏内容。如果对于状态栏还是只返回一个值那就讲不通了。所以,原有基于应用的状态栏 API 被舍弃,引入了新的基于 window scene 的状态栏 API。
其他类似的 API 也是这样。
即使你现在不准备适配多窗口功能,我们也鼓励你现在就开始直接使用新的 API,因为如果你之后需要适配这个功能,就不用再花更多的功夫了。

调试建议

  • 每一个 app 都有独一无二的挑战:你的 app 中会有许多自定义的代码,虽然已经有许多系统框架帮助你,但我们无法预测你需要做出多少改变,但一定会有。
  • 做出改变:仔细思考过去写下的代码,适配新的生命周期,特别是适配多个 scene 的情景。
  • 一遍一遍的测试:如果你有自动化测试,那很好,但即使通过,用户可能也会遇到 bug。找出问题最好的方式就是把玩你的 app,同时观察 app 的两个界面,快速找出问题。
  • 更加注意多个 scene 的情景:因为现在不再只是一个界面,不再只有一个 view controller 的实例。

参考链接

如果你觉得这篇文章对你有所帮助,欢迎请我喝杯咖啡,感谢你的支持😁