更好用的 sheet

653次阅读  |  发布于2年以前

前言

在 WWDC19 上,苹果推出了一种新的 present 视图控制器的方式,如下图所示,苹果将它称为 sheet。

以 sheet 方式 present 出来的视图控制器,在视觉上,能够让用户更加清晰认识到正在进行的操作的上下文,并且也自带了非常舒适的拖拽关闭的交互手势。因此从 iOS13 起,苹果将视图控制器的 modalPresentationStyle 的默认值从 UIModalPresentationFullScreen 修改为 UIModalPresentationAutomatic,对于自定义的视图控制器,系统会自动裁决为 UIModalPresentationPageSheet,即图中这种形式。在 WWDC21 上,苹果继续对 sheet 进行了一些基础性的功能优化,提供了自定义 sheet 高度、可选拖拽指示器、水平方向可变宽度等等新的能力,使得开发者可以创造出更加灵活、美观、方便的应用界面和交互方式。

本文基于 WWDC Session 10063[1] 梳理而来。

回顾

在介绍 WWDC21 上提供的这些新特性之前,让我们先简要回顾下 WWDC19 上 sheet 的特性。

生命周期方法

假设有一段代码:

let myVC = MyViewController()
let imagePicker = UIImagePickerController()
imagePicker.sourceType = .photoLibrary
myVC.present(imagePicker, animated: true)

在执行后,myVC 就成为 presentingViewController,而 imagePicker 就成为 presentedViewController。在 present 时,由于 imagePicker 的 modalPresentationStyle 没有被显式设置,因此就是默认值 UIModalPresentationAutomatic,也即 UIModalPresentationPageSheet。而在 iOS13 之前,这个默认值是 UIModalPresentationFullScreen。因此当这段程序运行在 iOS13 之前及 iOS13 上时,presentingViewControllerpresentedViewController 的生命周期调用会有一些变化。

如下是 modalPresentationStyleUIModalPresentationFullScreen 时两者的生命周期方法的调用情况。 如下是 modalPresentationStyleUIModalPresentationPageSheetUIModalPresentationFormSheet 时,两者的生命周期方法的调用情况。

比较两者可以发现,sheet 这种方式在 present 的时候,presentingViewController 的几个重要的生命周期方法都不会执行。这是由于,viewDidDisappear/viewWillAppear 这些生命周期方法,是在视图控制器从视图层级上移除或者添加时调用的,而在 sheet 中,presentingViewController 是一直存在于视图层级中的,因此这些生命周期方法就不会调用了。

因此,一些依赖于生命周期方法的埋点和展现统计逻辑,在 iOS13 可能会有不符合预期的表现,需要格外关注。

对于其他一些 presentationStyle 的生命周期调用顺序,笔者制作了一个简单的 Demo[2],这里直接给出测试结论,感兴趣的同学可以自行 clone Demo 来验证。

presentation_lifecycle

拖拽手势

以 sheet 方式 present 出来的视图控制器,系统会为它添加一个拖拽关闭的手势。但如果用户在 present 出来的视图控制器上进行了一些编辑改动,而一个不小心的拖拽导致这个视图控制器被直接 dismiss,进而丢失了刚刚的改动,那么用户可能会不太高兴。在这种情况下,你可以通过设置视图控制器的 modalInPresentation 属性为 YES 来避免视图控制器被直接 dismiss。

如果需要在拖拽视图控制器时,提醒用户是否需要保存已经编辑的内容,则可以实现 UIAdaptivePresentationControllerDelegate,这个协议有以下几个方法:

optional func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool

optional func presentationControllerWillDismiss(_ presentationController: UIPresentationController)

optional func presentationControllerDidDismiss(_ presentationController: UIPresentationController)

optional func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController)

我们用一张图来说明这些方法在什么情况下会被调用:

  1. presentedViewControllerisModalInPresentation 为 YES,当拖拽时,会调用 presentationControllerDidAttemptToDismiss:,可以在这个方法中询问用户是否需要保存编辑的内容
  2. presentedViewControllerisModalInPresentation 为 NO,当拖拽时,会先调用 presentationControllerShouldDismiss:,如果这个方法返回 NO,也会调用 presentationControllerDidAttemptToDismiss:
  3. 如果 presentationControllerShouldDismiss: 方法返回 YES,则会依次调用 presentationControllerWillDismiss:presentationControllerDidDismiss: 两个方法。

苹果也贴心地给出了一个Demo:Disabling the Pull-Down Gesture for a Sheet,来演示这个场景。

新特性

在 iOS15 上,UIViewController 新添加了一个属性 sheetPresentationController,它的类型是 UISheetPresentationController,是 UIPresentationController 的子类。如果一个视图控制器的 modalPresentationStyle 为 .formSheet 或 .pageSheet,那么这个视图控制器的 sheetPresentationController 就不为空。iOS15 上引入的若干个 sheet 的新特性,都是通过直接对视图控制器的 sheetPresentationController 的属性进行配置而得到的。

自定义 sheet 的高度和宽度

在 iOS15 前,开发者无法控制 sheet present 出来后的尺寸,sheet 在竖屏弹出时,高度是固定的,宽度和屏幕等宽;在横屏弹出时,sheet 则会占据全屏。

sheet_horizontal

而在 iOS15 之后,开发者能够自定义 sheet 的大小了。

首先,在竖屏方向上弹出时,可以通过设置 detents 来指定 sheet 的高度。detents 是一个数组,表示 sheet 能够展示的高度的种类。系统预置了 medium 和 large 两种高度类型。medium 表示 sheet 的高度占据大约一半的屏幕高度,而 large 的高度则和之前 sheet 的默认高度相同。如果 detents 数组只有一个元素,则 sheet 的高度是固定的,如果有两个元素,则用户可以通过拖拽将 sheet 的高度在 medium 和 large 间进行调节。

resize

其次,在水平方向上弹出时,可以将 prefersEdgeAttachedInCompactHeight 设置为 true,这个属性表示水平方向上 sheet 的宽度将和安全区域的宽度一致。如果再将 widthFollowsPreferredContentSizeWhenEdgeAttached 设置为 true,那么 sheet 的宽度就将等于 preferredContentSize 所指定的宽度。

控制拖拽行为

一些用户可能没有意识到能够通过拖拽改变 sheet 的高度,所以为了给用户更加直接明了的提示:“嘿,我这个视图可是能拖拽的哦”,可以设置 prefersGrabberVisible 为 true。设置之后,在 sheet 上就会出现一个拖拽指示器:

grabber

另外,当 sheet 处于 medium 状态时,如果 sheet 里面有个可滚动的 scrollView,那么在比较靠近 sheet 头部边缘的位置滚动 scrollView 时,很有可能会导致 sheet 的高度也被意外地调整,此时可以设置 prefersScrollingExpandsWhenScrolledToEdge 为 false,就可以阻止这个行为。

动态改变 sheet 的高度

如果给 detents 传入了多个元素,那么除了让用户来拖拽改变 sheet 的高度外,还可以使用 selectedDetentIdentifier 来在代码中指定某些状态下 sheet 的高度,例如 Keynote 中图片选择器的 Demo 里,当用户从处于 large 模式的图片选择器中选中一张图片后,可以在回调中设置 selectedDetentIdentifier 为 medium 来将图片选择器调整到半屏高度。这个设置被包裹在 animateChanges 中的话,高度的变化还会自动拥有漂亮的动画过渡效果。

func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
    // assign result to imageView.image
    if let sheet = picker.popoverPresentationController?.adaptiveSheetPresentationController {
        sheet.animateChanges {
            sheet.selectedDetentIdentifier = .medium
        }
    }
}

detent_select

和背景交互

这个特性也许是本次 sheet 的改变中最为重要的一个特性:可以和 sheet 背后的视图控制器进行交互了。在之前,sheet 在 present 后,系统会在 presentingViewController 和 sheet 之间加上一个暗色的遮盖视图,这个遮盖视图的存在会阻止用户和 presentingViewController 之间进行交互。现在则可以通过设置 sheet 的 largestUndimmedDetentIdentifier 来指定一个最小高度,当 sheet 处于这个最小高度时,系统不会添加遮盖视图,当 sheet 的高度大于这个值时,会被加上一个暗色遮盖视图。

Xcode 13 实际装载的 iOS 15 SDK 中,smallestUndimmedDetentIdentifier 被修改为 largestUndimmedDetentIdentifier,但其行为仍然和上述表述保持一致。

func showImagePicker() {
    let picker = PHPickerViewController()
    picker.delegate = self
    if let sheet = picker.sheetPresentationController {
        sheet.detents = [.medium(), .large()]
        sheet.prefersScrollingExpandsWhenScrolledToEdge = false
        sheet. largestUndimmedDetentIdentifier = .medium
    }
    present(picker, animated: true)
}

兼容 iPad

在 iPad 上,popover 相较于 present,是更加自然的交互。但是由于 iPad 支持分屏并调整分屏宽度,因此应用的实际宽度也是变化的。因此我们希望在应用实际宽度较宽的时候,让视图控制器以 popover 的方式显示; 而在应用实际宽度较窄时,以 sheet 的方式进行显示。

popover

为了达到此目的,我们只需要对之前的代码进行很少的修改: 可见,为了适配 iPad,我们需要:

  1. 将视图控制器的 modalPresentationStyle 显式指定为 .popover
  2. 获取视图控制器的 popoverPresentationController
  3. popoverPresentationController上获取 adaptiveSheetPresentationController,之后的配置等同于 sheetPresentationController

总结

在 WWDC21 中,苹果对 sheet 进行了一次篇幅不小的更新,加上了很多实用的新特性。有了这些新特性的加持,我们能够为用户提供更加精美舒适的应用界面和交互体验。快点尝试这些新特性,让你的用户耳目一新吧~

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8