这里每天分享一个 iOS 的新知识,快来关注我吧
前言
昨天写一个外卖类 App,产品同学甩过来一张 Apple Maps 的截图:「就要这种底部抽屉,能拖到一半,能拖到顶,下面地图还能滑」。
我第一反应是:又得自己撸 DragGesture 了。结果摸了一圈发现,SwiftUI 从 iOS 16 开始原生就支持 Bottom Sheet,核心就靠一个 modifier —— presentationDetents。配合 interactiveDismissDisabled 和 presentationBackgroundInteraction,三行代码就出效果,手势冲突也不用自己处理。

这篇把这套写法、坑和边界一次说清楚。
先看效果,再看为什么要用原生
苹果地图、高德、滴滴打车,这类应用底部那块卡片,业内一般叫可拖动底部抽屉。它有几条硬性要求:
- 至少三个停靠点:折叠(只露一点)、中等高度、几乎全屏
- 抽屉里如果是 ScrollView,要能跟外层拖拽手势协调,不能打架
以前在 UIKit 里实现这套,要么自己接 UIPanGestureRecognizer,要么用 UISheetPresentationController 配一堆 delegate。SwiftUI 把这事抹平了,下面这套写法基本是目前的最优解。
三个 modifier 解决主要问题
直接上代码,场景换成「附近的餐厅列表」:
struct NearbyRestaurantsView: View {
@State private var sheetVisible = true
var body: some View {
MapContainer()
.sheet(isPresented: $sheetVisible) {
ScrollView {
RestaurantList()
}
.interactiveDismissDisabled()
.presentationDetents([.height(80), .medium, .large])
.presentationBackgroundInteraction(
.enabled(upThrough: .large)
)
.presentationDragIndicator(.visible)
}
}
}就这点东西。每个 modifier 单独说一下,因为每一行都在解决一个非常具体的问题。
.presentationDetents([.height(80), .medium, .large])
定义抽屉的停靠点。这里给了三档:固定 80 点高度(只露搜索框和拖拽条)、屏幕一半、几乎全屏。用户拖动时会自动吸附到最近的那一档,不用自己写 withAnimation,也不用自己算速度。
.interactiveDismissDisabled()
这行很关键。默认 sheet 往下一拉就关掉了,但地图类应用底部抽屉是「常驻」的,不能消失。加了这个 modifier 之后,用户怎么拖都不会让 sheet 消失,最低只会停在最小那档。
.presentationBackgroundInteraction(.enabled(upThrough: .large))
这个我第一次看到的时侯愣了一下。它的意思是:当 sheet 处于 .large 及以下任意停靠点时,底层视图(也就是地图)依然可以接收手势。不加这一行,sheet 一旦显示,下面的地图就完全锁死了,那这个 UI 就没什么意义了。
ScrollView 和拖拽手势会不会打架?
不会,系统帮你处理掉了。
具体行为:当 ScrollView 滚到顶部、你还继续往下拉,系统自动把手势交给 sheet 本身,把整个抽屉往下拽到下一个停靠点。反过来,抽屉拖到 .large 之后再继续向上滑,手势又交还给 ScrollView 去滚动内容。
这套交接逻辑在 UIKit 里要写一堆 gestureRecognizer:shouldRecognizeSimultaneouslyWith:,SwiftUI 这边是默认行为,零代码。
自定义停靠点和拖拽条
如果 .medium 和 .large 不够用,presentationDetents 还接受比例和具体数值:
.presentationDetents([
.height(80),
.fraction(0.4), // 屏幕高度的 40%
.fraction(0.85)
])presentationDragIndicator(.visible) 会在抽屉顶部画一条小灰条,提示用户「这里可以拖」。不是非加不可,但加了之后辨识度高一截,建议默认开着。
这套方案的问题在哪?
SwiftUI 原生 sheet 有一个绕不开的限制:它会盖住整个视图层级,包括 TabBar。
也就是说,如果你的 App 是带底部 TabBar 的结构,sheet 弹出来之后会把 TabBar 也遮住。Apple Maps 自己那个抽屉之所以能飘在 TabBar 上方又不挡住 TabBar,是因为用的是私有 API,不是我们能用的 .sheet。
如果产品需求是「抽屉飘在 TabBar 之上」,只能自己用 GeometryReader + DragGesture + offset 撸一个,或者上 UIViewControllerRepresentable 桥 UISheetPresentationController 来配。但只要你的页面是单页结构,.sheet 配三行 modifier 完全够用,没必要自己造轮子。
最后
我自己最近做的一个工具类 App 已经把这套搬上去了,大概写了十几行就跑通,比预想的简单很多。
你觉得这套方案怎么样?评论聊聊。
这里每天分享一个 iOS 的新知识,快来关注我吧