WWDC Quick Look 💓 By SwiftGGTeam
Customize and resize sheets in UIKit

Customize and resize sheets in UIKit

观看原视频

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 常驻

核心启发

  1. 做半屏常驻工具面板

    • 做什么:在绘图、笔记类 App 中,把工具栏做成 medium detent 的 Sheet,用户选工具时主画布始终可见
    • 为什么值得做:以前工具栏要么占底部固定空间,要么全屏弹出打断创作流。半屏 Sheet 既不占空间又不打断
    • 怎么开始:用 detents = [.medium()] + smallestUndimmedDetentIdentifier = .medium 实现非模态工具面板
  2. 做可折叠的详情页

    • 做什么:地图类 App 中,地点详情用 medium/large 双 detent,默认半屏显示基本信息,上拉看完整内容
    • 为什么值得做:系统地图 App 已经验证了这个交互模式。用 UISheetPresentationController 可以直接复刻,不需要自定义转场
    • 怎么开始detents = [.medium(), .large()] + prefersGrabberVisible = true,用户拖动即可切换
  3. 做跨设备的自适应弹窗

    • 做什么:iPad 上用 popover,iPhone 上自动变成定制 Sheet,一套代码适配两种设备
    • 为什么值得做adaptiveSheetPresentationController 让 popover 的 compact 自适应行为可定制,不再需要写两套逻辑
    • 怎么开始:设置 modalPresentationStyle = .popover,然后配置 popover.adaptiveSheetPresentationController
  4. 做实时预览的配置面板

    • 做什么:在照片编辑、滤镜类 App 中,调整参数的面板用半屏 Sheet 打开,调整时主图实时变化
    • 为什么值得做:去掉 dimming 后用户可以同时看到面板和主图,调整参数的体验更直观
    • 怎么开始smallestUndimmedDetentIdentifier = .medium + 不 dismiss,参数变更直接反映到背后视图
  5. 替换现有的自定义卡片

    • 做什么:如果你现在用 UIViewController 自定义转场实现半屏卡片,迁移到 UISheetPresentationController
    • 为什么值得做:系统实现的手势、动画、键盘避让都更完善,代码量从几百行降到十几行
    • 怎么开始:删掉自定义转场代码,present 时配置 sheetPresentationController 的属性即可

关联 Session

评论

GitHub Issues · utterances