Customize and resize sheets in UIKit
Swift & UI 进阶 20m

在 UIKit 中自定义和调整 Sheet 尺寸

Customize and resize sheets in UIKit

2021年6月10日

在 Apple 官方观看视频

一句话判断

UISheetPresentationController 的 detent 系统让你可以精确控制 Sheet 的每一个尺寸——半屏、全屏、或者任意自定义高度,用户可以丝滑地在它们之间拖拽切换。

这场 Session 讲了什么

iOS 15 引入的 UISheetPresentationController 是 Sheet 交互的重大升级。之前的 UIViewController modal 展示只有两种模式:全屏(.fullScreen)和底部弹出(.pageSheet)。现在你可以创建多级尺寸的 Sheet,用户通过拖拽在各级之间切换。

核心概念是 detent(锚点)。每个 detent 定义了 Sheet 的一个「停靠高度」。系统预设了 .medium()(大约屏幕一半)和 .large()(几乎全屏)。你还可以创建自定义 detent 来指定精确的高度。

Session 展示了几个实际应用场景:地图 app 的地点详情 Sheet(先显示概要,拖上去显示完整信息),音乐 app 的播放队列 Sheet(先显示当前歌曲,拖上去显示整个列表),以及表单的「确认」Sheet。

另外还讨论了 grabber(拖拽把手)的定制、Sheet 圆角的设置、以及在 detent 变化时如何同步更新内容。

值得深挖的点

自定义 Detent 的计算

UISheetPresentationController.Detent.custom 允许你根据 Sheet 的宽度动态计算高度。这对于在不同设备上保持一致的 UI 比例非常有用。比如你希望 Sheet 始终露出顶部导航栏的 44pt,可以这样计算:height = window.height - 44

Sheet 与 ScrollView 的嵌套

当 Sheet 内容包含 ScrollView 时,拖拽手势可能冲突。系统会自动处理大部分情况:在 Sheet 的最高 detent 时,拖拽会滚动 ScrollView;在非最高 detent 时,拖拽会调整 Sheet 尺寸。但如果你有自定义的 pan gesture recognizer,可能需要手动处理优先级。

代码片段

// 创建多级尺寸的 Sheet
class DetailViewController: UIViewController {
    var tableView: UITableView!
    var sheetPresentationController: UISheetPresentationController?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
    }
    
    func setupSheet() {
        sheetPresentationController = presentationController as? UISheetPresentationController
        sheetPresentationController?.detents = [.medium(), .large()]
        sheetPresentationController?.prefersGrabberVisible = true
        sheetPresentationController?.prefersScrollingExpandsWhenScrolledToEdge = false
        sheetPresentationController?.selectedDetentIdentifier = .medium
        sheetPresentationController?.delegate = self
        
        // 自定义圆角和 grabber 颜色
        sheetPresentationController?.grabberTopInset = 6
    }
}

extension DetailViewController: UISheetPresentationControllerDelegate {
    func sheetPresentationControllerDidChangeSelectedDetentIdentifier(
        _ sheetPresentationController: UISheetPresentationController
    ) {
        let isMedium = sheetPresentationController.selectedDetentIdentifier == .medium
        UIView.animate(withDuration: 0.25) {
            // 根据当前 detent 调整 UI
            self.tableView.isScrollEnabled = !isMedium
        }
    }
}
// 自定义 detent 高度
func presentCustomSheet() {
    let vc = InfoViewController()
    
    if let sheet = vc.sheetPresentationController {
        let smallDetent = UISheetPresentationController.Detent.custom { context in
            // 计算自定义高度:屏幕高度的 1/3
            return context.maximumDetentValue * 0.33
        }
        
        let mediumDetent = UISheetPresentationController.Detent.custom { context in
            // 精确显示 3 行内容的高度
            let rowHeight: CGFloat = 60
            let headerHeight: CGFloat = 44
            let padding: CGFloat = 20
            return headerHeight + rowHeight * 3 + padding
        }
        
        sheet.detents = [smallDetent, mediumDetent, .large()]
        sheet.prefersGrabberVisible = true
        sheet.selectedDetentIdentifier = .medium
    }
    
    present(vc, animated: true)
}
// 半屏 Sheet 适合作为快捷操作面板
func presentQuickActions() {
    let vc = QuickActionsViewController()
    
    if let sheet = vc.sheetPresentationController {
        sheet.detents = [.medium()]
        sheet.prefersGrabberVisible = true
        sheet.preferredCornerRadius = 20
        
        // 点击外部区域不自动 dismiss
        sheet.largestUndimmedDetentIdentifier = .medium
    }
    
    present(vc, animated: true)
}

最佳实践

不要在 .medium() detent 下放太多内容。medium 大约只有屏幕高度的一半,内容溢出时用户需要拖拽到 .large() 才能看到。建议 medium 只放概要信息(标题、关键数据),完整内容放在 large。

如果你需要在 detent 变化时执行动画,在 sheetPresentationControllerDidChangeSelectedDetentIdentifier 回调中触发。系统已经完成了 Sheet 的尺寸动画,你只需要更新内容的可见性和布局。

还有什么值得关注

  • prefersEdgeAttachedInCompactHeight 在 iPhone 横屏时让 Sheet 贴在屏幕边缘而不是居中。
  • widthFollowsPreferredContentSizeWhenEdgeAttached 控制 Sheet 是否跟随 preferredContentSize 的宽度。
  • 在 iPad 上,Sheet 会自动变成 Popover(如果空间允许)。你可以通过 adaptivePresentationDelegate 来自定义这个行为。
WWDC 2021