在 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 上时,presentingViewController
和 presentedViewController
的生命周期调用会有一些变化。
如下是 modalPresentationStyle
为 UIModalPresentationFullScreen
时两者的生命周期方法的调用情况。
如下是 modalPresentationStyle
为 UIModalPresentationPageSheet
或UIModalPresentationFormSheet
时,两者的生命周期方法的调用情况。
比较两者可以发现,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)
我们用一张图来说明这些方法在什么情况下会被调用:
presentedViewController
的 isModalInPresentation
为 YES,当拖拽时,会调用 presentationControllerDidAttemptToDismiss:
,可以在这个方法中询问用户是否需要保存编辑的内容presentedViewController
的 isModalInPresentation
为 NO,当拖拽时,会先调用 presentationControllerShouldDismiss:
,如果这个方法返回 NO,也会调用 presentationControllerDidAttemptToDismiss:
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
的属性进行配置而得到的。
在 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,就可以阻止这个行为。
如果给 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 上,popover 相较于 present,是更加自然的交互。但是由于 iPad 支持分屏并调整分屏宽度,因此应用的实际宽度也是变化的。因此我们希望在应用实际宽度较宽的时候,让视图控制器以 popover 的方式显示; 而在应用实际宽度较窄时,以 sheet 的方式进行显示。
popover
为了达到此目的,我们只需要对之前的代码进行很少的修改: 可见,为了适配 iPad,我们需要:
modalPresentationStyle
显式指定为 .popoverpopoverPresentationController
popoverPresentationController
上获取 adaptiveSheetPresentationController
,之后的配置等同于 sheetPresentationController
在 WWDC21 中,苹果对 sheet 进行了一次篇幅不小的更新,加上了很多实用的新特性。有了这些新特性的加持,我们能够为用户提供更加精美舒适的应用界面和交互体验。快点尝试这些新特性,让你的用户耳目一新吧~
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8