Highlight
iOS 15 为 UIKit Sheet 引入
UISheetPresentationController,支持 medium/large 双高度停靠(detent)、非模态交互、自定义圆角与 grabber,让半屏卡片式 UI 成为系统级原生能力。
核心内容
以前想在 iOS 里做一个半屏可拖动的卡片,基本要自己动手写一套自定义转场动画。系统自带的 Sheet 只有全屏一种高度,用户选张图片、改个设置,整个界面就被盖住,做完操作还得重新打开。
iOS 15 给 Sheet 加了一套完整的定制 API。核心思路是:Sheet 不再只有「全屏」一种状态,而是可以在多个「停靠点」(detent)之间切换。系统预定义了两种停靠点:medium(约半屏)和 large(全屏)。你可以让 Sheet 同时支持这两个高度,也可以只保留其中一个。
更关键的是,你可以去掉背景遮罩(dimming view)。这意味着 Sheet 浮在半空时,用户仍然可以和背后的内容交互。比如图片选择器以半屏方式打开,用户点选照片后,照片直接显示在背后的明信片上,Sheet 保持打开状态,随时可以换另一张图。
获取 Sheet 控制器
(01:36)
定制 Sheet 的第一步是拿到 UISheetPresentationController 实例。它的获取方式和 popoverPresentationController 一样:在 present 之前读取目标 view controller 的 sheetPresentationController 属性。
if let sheet = viewController.sheetPresentationController {
// Customize the sheet
}
present(viewController, animated: true)
关键点:
sheetPresentationController只在modalPresentationStyle为.formSheet或.pageSheet时返回非 nil- 默认就是
.pageSheet,所以大部分情况下直接读就行
设置停靠点
(02:21)
detents 属性决定 Sheet 支持哪些高度。默认只有 [.large()],所以不设置就是全屏。
if let sheet = picker.sheetPresentationController {
sheet.detents = [.medium(), .large()]
}
present(picker, animated: true)
三种常见配置:
[.large()]— 默认行为,全屏 Sheet[.medium(), .large()]— 可拖动切换半屏和全屏[.medium()]— 固定半屏,无法拖到全屏
非模态图片选择器
(03:27)
看一个实际场景:用 PHPickerViewController 选图片。iOS 14 的写法是 present 全屏 picker,选完 dismiss。iOS 15 可以改成半屏常驻:
func showImagePicker() {
let picker = PHPickerViewController()
picker.delegate = self
if let sheet = picker.sheetPresentationController {
sheet.detents = [.medium(), .large()]
}
present(picker, animated: true)
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
// assign result to imageView.image
// 不再调用 dismiss
}
关键点:
detents设为[.medium(), .large()],picker 以半屏方式打开- 去掉
dismiss(animated:),picker 保持打开,用户可继续选图 - 用户仍可拖动 Sheet 到全屏,浏览更多照片
禁止滚动自动展开
(05:34)
默认行为下,当 scroll view 滚动到顶部时继续下拉,Sheet 会自动展开到 large。对于图片选择器这种场景,你可能希望滚动只影响内容,不影响 Sheet 高度:
if let sheet = picker.sheetPresentationController {
sheet.detents = [.medium(), .large()]
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
}
关键点:
prefersScrollingExpandsWhenScrolledToEdge默认为 true- 设为 false 后,只有拖动 Sheet 顶部的 bar 才能改变高度
程序化切换停靠点
(06:12)
用户选完照片后,让 Sheet 自动缩回 medium 高度,既给出反馈,又让背后的内容可见:
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
// assign result to imageView.image
if let sheet = picker.sheetPresentationController {
sheet.animateChanges {
sheet.selectedDetentIdentifier = .medium
}
}
}
关键点:
selectedDetentIdentifier控制当前停靠点- 必须用
animateChanges包裹,否则切换没有动画 animateChanges会同时处理堆叠中其他 Sheet 的联动动画
移除背景遮罩
(07:12)
去掉 dimming view 是实现非模态交互的关键:
if let sheet = picker.sheetPresentationController {
sheet.detents = [.medium(), .large()]
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
sheet.smallestUndimmedDetentIdentifier = .medium
}
关键点:
smallestUndimmedDetentIdentifier设为 medium 后,medium 高度不显示遮罩- 用户拖动到 large 时,遮罩会淡入
- 去掉遮罩后,用户可以同时和 Sheet 内外的内容交互
横屏与视觉定制
(08:44)
iOS 13 的 Sheet 在横屏下默认全屏。iOS 15 提供了底部吸附的替代外观:
if let sheet = fontPicker.sheetPresentationController {
sheet.prefersEdgeAttachedInCompactHeight = true
sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
}
显示 grabber 指示条:
if let sheet = fontPicker.sheetPresentationController {
sheet.prefersGrabberVisible = true
}
自定义圆角:
if let sheet = fontPicker.sheetPresentationController {
sheet.preferredCornerRadius = 20.0
}
关键点:
prefersEdgeAttachedInCompactHeight让横屏 Sheet 底部吸附而非全屏widthFollowsPreferredContentSizeWhenEdgeAttached让宽度跟随preferredContentSize- 系统会自动保持堆叠 Sheet 的圆角一致性
Popover 自适应为定制 Sheet
(10:05)
iPad 上用 popover,iPhone 上自动变成定制 Sheet:
func showImagePicker(_ sender: UIBarButtonItem) {
let picker = PHPickerViewController()
picker.delegate = self
picker.modalPresentationStyle = .popover
if let popover = picker.popoverPresentationController {
popover.barButtonItem = sender
let sheet = popover.adaptiveSheetPresentationController
sheet.detents = [.medium(), .large()]
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
sheet.smallestUndimmedDetentIdentifier = .medium
}
present(picker, animated: true)
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
if let sheet = picker.popoverPresentationController?.adaptiveSheetPresentationController {
sheet.animateChanges {
sheet.selectedDetentIdentifier = .medium
}
}
}
关键点:
adaptiveSheetPresentationController获取 popover 在 compact size class 下自适应的 Sheet- 配置方式和普通 Sheet 完全一致
- 在 delegate 方法里也要通过
popoverPresentationController?.adaptiveSheetPresentationController访问
详细内容
Detent 机制详解
UISheetPresentationController.Detent 是 iOS 15 引入的新类型,表示 Sheet 的自然停靠高度。系统目前提供两个预定义值:
.medium()— 约占全屏高度的 50%.large()— 全屏高度
detents 属性是一个数组,顺序不影响功能。selectedDetentIdentifier 表示当前激活的停靠点,可以在运行时通过代码切换。
动画与交互细节
animateChanges 是一个关键方法。它接受一个 closure,在 closure 中修改 Sheet 属性会产生平滑动画。这个方法会协调整个 Sheet 堆栈的动画,包括底层 Sheet 的缩放回弹。
键盘避让也是内置的。当 Sheet 处于 medium 高度时,如果内部文本框获得焦点弹出键盘,Sheet 会自动向上扩展以避开键盘,键盘收起后自动回弹。
完整配置示例
把以上所有特性组合起来,一个典型的非模态图片选择器配置如下:
func showImagePicker() {
let picker = PHPickerViewController()
picker.delegate = self
if let sheet = picker.sheetPresentationController {
// 支持半屏和全屏
sheet.detents = [.medium(), .large()]
// 滚动不自动展开
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
// 半屏时不显示遮罩,允许和背后内容交互
sheet.smallestUndimmedDetentIdentifier = .medium
// 显示 grabber
sheet.prefersGrabberVisible = true
// 自定义圆角
sheet.preferredCornerRadius = 20.0
}
present(picker, animated: true)
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
// 处理选中的图片
if let result = results.first {
result.itemProvider.loadObject(ofClass: UIImage.self) { image, error in
DispatchQueue.main.async {
self.imageView.image = image as? UIImage
}
}
}
// 选完后自动缩回半屏
if let sheet = picker.sheetPresentationController {
sheet.animateChanges {
sheet.selectedDetentIdentifier = .medium
}
}
}
关键点:
detents定义可用高度prefersScrollingExpandsWhenScrolledToEdge控制滚动行为smallestUndimmedDetentIdentifier控制遮罩范围animateChanges包裹属性变更以产生动画- 不调用
dismiss,保持 Sheet 常驻
核心启发
-
做半屏常驻工具面板
- 做什么:在绘图、笔记类 App 中,把工具栏做成 medium detent 的 Sheet,用户选工具时主画布始终可见
- 为什么值得做:以前工具栏要么占底部固定空间,要么全屏弹出打断创作流。半屏 Sheet 既不占空间又不打断
- 怎么开始:用
detents = [.medium()]+smallestUndimmedDetentIdentifier = .medium实现非模态工具面板
-
做可折叠的详情页
- 做什么:地图类 App 中,地点详情用 medium/large 双 detent,默认半屏显示基本信息,上拉看完整内容
- 为什么值得做:系统地图 App 已经验证了这个交互模式。用
UISheetPresentationController可以直接复刻,不需要自定义转场 - 怎么开始:
detents = [.medium(), .large()]+prefersGrabberVisible = true,用户拖动即可切换
-
做跨设备的自适应弹窗
- 做什么:iPad 上用 popover,iPhone 上自动变成定制 Sheet,一套代码适配两种设备
- 为什么值得做:
adaptiveSheetPresentationController让 popover 的 compact 自适应行为可定制,不再需要写两套逻辑 - 怎么开始:设置
modalPresentationStyle = .popover,然后配置popover.adaptiveSheetPresentationController
-
做实时预览的配置面板
- 做什么:在照片编辑、滤镜类 App 中,调整参数的面板用半屏 Sheet 打开,调整时主图实时变化
- 为什么值得做:去掉 dimming 后用户可以同时看到面板和主图,调整参数的体验更直观
- 怎么开始:
smallestUndimmedDetentIdentifier = .medium+ 不 dismiss,参数变更直接反映到背后视图
-
替换现有的自定义卡片
- 做什么:如果你现在用
UIViewController自定义转场实现半屏卡片,迁移到UISheetPresentationController - 为什么值得做:系统实现的手势、动画、键盘避让都更完善,代码量从几百行降到十几行
- 怎么开始:删掉自定义转场代码,present 时配置
sheetPresentationController的属性即可
- 做什么:如果你现在用
关联 Session
- What’s new in UIKit — UIKit 年度新特性总览,包含 Sheet 在内的所有更新
- Meet UIKit for Mac — 了解 Sheet 在 Mac 上的行为差异
- The process of inclusive design — 非模态交互对无障碍体验的影响
评论
GitHub Issues · utterances