在 SwiftUI 中显示视图的方式有多种,最基本的就是 sheet 工作表 (在现有视图之上呈现的新视图)。在 iOS 上,这会自动为我们提供一个类似卡片的实例,其中当前视图稍微滑向远处,新视图以动画方式显示在顶部。
sheet 与 alert 相似,我们要定义应显示 sheet 的条件,当这些条件为真或为假时,将分别显示或隐藏 sheetsheet 出现时会覆盖整个画面;在 iOS 13之后,sheet 视图预设的显示样式是像卡片一样,不会覆盖全画面,而是部分覆蓋了內容视图,还可以看到父视图的上方边缘,这可以提醒用户 UX 的上下文NavigationLink 不同,sheet 不需要导航堆栈即可工作。最简单的调起 sheet 的方式是,用 isPresented 属性绑定一个布尔值。
// 1. 创建主视图
struct ContentView: View {
var body: some View {
Button("Show Sheet") {
//希望点击按钮,能通过 sheet 展示新视图
}
}
}
// 2. 创建 sheet 视图(该视图没有什么特别之处,它不知道将会被显示在 Sheet 中,也不需要知道)
struct SecondView: View {
var body: some View {
Text("Second View")
}
}
// 3. 在主视图添加状态属性【布尔值】,来跟踪 sheet 是否正在显示,后续在点击按钮时进行切换
@State private var showingSheet = false
var body: some View {
Button("Show Sheet") {
showingSheet.toggle()
}
}
// 4. 添加 sheet 修饰符,绑定状态属性
// 将 sheet 附加到视图层次结构的某个位置,使用 isPresented 与状态属性进行双向绑定
@State private var showingSheet = false
var body: some View {
Button("Show Sheet") {
showingSheet.toggle()
}
.sheet(isPresented: $showingSheet) {
//5. 然后把之前定义好的二级视图,放进 sheet 里
SecondView()
}
}
// 往 sheet 视图传入参数
// 当使用 sheet 方式呈现视图时,还可以传入它所需的任何参数。例如,我们可以要求向 SecondView 发送一个可以显示的名称:
struct SecondView: View {
let name: String
var body: some View {
Text("Hello, \(name)!")
}
}
//现在,仅在 sheet 中使用 SecondView() 还不够,还需要传入要显示的名称字符串。例如:
.sheet(isPresented: $showingSheet) {
SecondView(name: "twostraws")
}
除了绑定布尔值,另外一种方式是,用 .sheet(item: self.$...) 属性绑定一个 Optional 值,这种方式很适合传参
nilplace.name 而不需要自己解开可选值,或使用 nil 合并方式// 首先新建状态属性,储存用户到底选了哪个文章。默认值为 nil
@State var selectedArticle: Article?
NavigationView {
List(articles) {
article in
ArticleRow(article: article)
.onTapGesture {
self.selectedArticle = article
}
}
// 给 List 加上 sheet 修改器,并将前面的 optional 的状态属性绑定到 item 属性中,表示只有所選文章有值,才會展示 sheet
.sheet(item: self.$selectedArticle) { article in
ArticleDetailView(article: article)
}
}
// 绑定 optional 值的方法,比绑定布尔值的方法更简单、更安全。如果使用旧的 .sheet(isPresented:) 方法会比较啰嗦
// 原方法:
@State private var selectedUser: User? = nil
@State private var isShowingUser = false
var body: some View {
Text("Hello, World!")
.onTapGesture {
selectedUser = User()
isShowingUser = true
}
.sheet(isPresented: $isShowingUser) {
Text(selectedUser?.id ?? "Unknown")
}
}
// 新方法:绑定 selectedUser 可选值,当它被赋值时,sheet弹窗就会弹出。而且一旦工作表被关闭,selectedUser 就会设置回 nil
Text("Hello, World!")
.onTapGesture {
selectedUser = User()
}
.sheet(item: $selectedUser) { user in
//自动已经解包,所以可以直接访问对象的属性
Text(user.id)
}
当使用 sheet() 修饰符时,实际上是要为 SwiftUI 提供一个可以运行的闭包,该闭包函数返回要在 sheet 中显示的视图。
// 以下例子中,sheet 就是后面跟着一个闭包,其内部返回 EditCards 视图
// 在 SwiftUI 的语法糖定义中,我们实际上是将 “视图结构” 看成是函数(Swift 会默默地将其看成是对“视图初始化程序”的调用)
// 所以实际上,我们是在编写 EditCards.init() ,只是以更短的方式。
.sheet(item: self.$selectedArticle){
EditCards()
}
// 当闭包内只有一个视图的时候,可以采用简写的方法(即将目标视图初始化方法直接传递给sheet):
.sheet(isPresented: $showingEditScreen, onDismiss: resetCards, content: EditCards.init)
// content 参数:即后面跟着目标视图的初始化函数
// 此简写方法仅在:目标视图具有不接受参数的初始值设定项时才有效;如果目标视图需要传递特定值,则还是要用回基于闭包的方法。
我们还可以将一个【函数】添加到 sheet ,该函数将会在 sheet 关闭时,自动调用运行。如果你需要从 sheet 回传数据时,这个方法无效;但如果不涉及传参,该方法就特别适合。
// onDismiss 参数:可以设定一个回调函数。让 sheet 在被关闭的时候,自动执行该函数
.sheet(isPresented: $showingEditScreen, onDismiss: resetCards, content: EditCards.init)
在 Sheet 内显示内容,通常会用 ScrollView。SwiftUI 会自动协调 ScrollView 的滚动行为和 Sheet 的拖动行为。
这种协调机制已内置于系统中,无需我们采取额外步骤。
如果想在同一个 SwiftUI 视图中显示多个工作表 sheet ,如果是通过第一个工作表内部触发第二个工作表,那没问题。
// 使用这种方法,两张表都将正确显示
struct ContentView: View {
@State private var showingFirst = false
@State private var showingSecond = false
var body: some View {
VStack {
Button("Show First Sheet") {
showingFirst = true
}
}
.sheet(isPresented: $showingFirst) {
Button("Show Second Sheet") {
showingSecond = true
}
.sheet(isPresented: $showingSecond) {
Text("Second Sheet")
}
}
}
}
但是注意:如果希望在同一个视图中放置多个 sheet 修饰符时,那
toolbar 外、或者在 Menu 中,或者在 ForEach 中,这些做法都容易出现一些奇怪的情况sheet 放在一个稳定的容器外面only presenting a single sheet is supported. The next sheet will be presented when the currently presented sheet gets dismissed. 也就是不支持的。而 iOS18 以上没有这个问题fullScreenCover ,全屏弹窗控件是没有这个问题的参考这个制作可拖拽的底部 Sheet 教程:https://tanaschita.com/swiftui-draggable-bottom-sheet/
对 Sheet 使用 presentationDetents() 修饰符,可以让从视图底部向上滑动的 sheet 仅占据屏幕的一部分,具体多少取决于我们需要。
presentationDetents() 修饰符可以传入一个【集合】,不是数组,集合里面是不同档位的高度值。
// 指定内置尺寸 .medium(大约一半屏幕)和 .large(整个屏幕),SwiftUI 将创建一个调整大小手柄,让用户在这两种尺寸之间调整
// 如果您不要求任何 detents ,则默认为 .large
.sheet(item: self.$selectedArticle){
Text(selectedUser.id)
.presentationDetents([.medium, .large])
}
// 指定 0 到 1 范围内的自定义百分比。例如,这将创建一个占据屏幕底部 15% 的工作表
.sheet(isPresented: $showingCredits) {
Text("This app was brought to you by Hacking with Swift")
.presentationDetents([.fraction(0.15)])
}
// 指定精确的点高度
.sheet(isPresented: $showingCredits) {
Text("This app was brought to you by Hacking with Swift")
.presentationDetents([.height(300)])
}
// 还可以根据需要将任意数量的定位器附加到视图上(只需将它们全部添加到定位器组中,SwiftUI 将处理其余的事情)
// 例如,这允许用户以 10% 的步长在 10% 和 100% 之间移动:
struct ContentView: View {
@State private var showingCredits = false
let heights = stride(from: 0.1, through: 1.0, by: 0.1).map { PresentationDetent.fraction($0) }
var body: some View {
Button("Show Credits") {
showingCredits.toggle()
}
.sheet(isPresented: $showingCredits) {
Text("This app was brought to you by Hacking with Swift")
.presentationDetents(Set(heights))
}
}
}
<aside> 💡
注意:即使设置了自定义 detents,当在紧凑的高度尺寸类别(例如横向iPhone)下,sheet 也会自动占满整个屏幕。如果您支持这种情况,请确保提供一种关闭工作表的方法。
</aside>
控制是否显示 Sheet 上的拖动指示器:
.sheet(isPresented: $showingCredits) {
Text("This app was brought to you by Hacking with Swift")
.presentationDetents([.medium, .large])
// 在弹窗隐藏提供调整大小的手柄
.presentationDragIndicator(.hidden)
.presentationDragIndicator(.visible)
}
控制弹出 Sheet 视图上的圆角,这里要注意是否上下圆角都需要…
.sheet(isPresented: $showingCredits) {
Text("This app was brought to you by Hacking with Swift")
.presentationDetents([.medium, .large])
// 在弹窗隐藏提供调整大小的手柄
.presentationDragIndicator(.hidden)
// 设置弹窗视图的圆角
.presentationCornerRadius(30)
}
SwiftUI 提供了 interactiveDismissDisabled() 修饰符,来控制用户是否可以向下滑动以关闭工作表。尽管滑动关闭通常都需要,但有时这是您不希望的,这时使用该修饰符就可以【禁用下滑手势解除】
.sheet(isPresented: $showingBottomSheet) {
ScrollView {
// Add content of the bottom sheet here.
}
.interactiveDismissDisabled()
}
如果您愿意,还可以将布尔值绑定到修饰符,以允许仅在成功满足某些条件时才取消滑动。因此,我们的条款和条件示例可能如下所示:
struct ExampleSheet: View {
@Environment(\.dismiss) var dismiss
@State private var termsAccepted = false
var body: some View {
VStack {
Text("Terms and conditions")
.font(.title)
Text("Lots of legalese here.")
Toggle("Accept", isOn: $termsAccepted)
}
.padding()
.interactiveDismissDisabled(!termsAccepted)
}
func close() {
dismiss()
}
}
struct ContentView: View {
@State private var showingSheet = false
var body: some View {
Button("Show Sheet") {
showingSheet.toggle()
}
.sheet(isPresented: $showingSheet, content: ExampleSheet.init)
}
}
使用该修饰符,可以让用户在 Sheet 没有完全覆盖的情况下,与 Sheet 后面的内容进行交互。例如地图应用中经常用到这个特性。
.presentationBackgroundInteraction(
.enabled(upThrough:.large)
)
要消除另一个视图,需要另一个属性包装器 @Environment 。@Environment 允许我们创建存储外部提供给我们的值的属性。我们要求环境撤回 sheet 视图,因为它可能以多种不同的方式呈现。因此,我们实际上是在要求 “请弄清楚我的视图是如何呈现的,然后适当地撤回它。”
第一个解除方法是:使用呈现模式的环境键,让视图自行消失。任何视图都可以使用 @Environment(\.dismiss) 自行关闭,无论其呈现方式如何,将该属性作为函数调用,将导致视图被关闭。
struct SecondView: View {
// 读取环境键 \.dismiss ,赋值给后面的 dismissIt 变量
// 您会注意到我们没有为此指定类型 - Swift 可以通过 @Environment 属性包装器来获知其类型
@Environment(\.dismiss) var dismissIt
let name: String
var body: some View {
VStack {
Text("Hello, \(name)!")
Button("Dismiss") {
// 在 sheet 视图中加个按钮,定义它点击后的代码,点击后调用前面的属性(实际上是一个函数)
// 执行该方法,就是让该视图消失
dismissIt()
}
}
}
}
<aside>
💡 可以这样理解,\.dismiss 实际上是 @Environment 环境中的一个内置函数,swiftUI 写好了解除视图的逻辑,这里是将函数赋值给一个属性。那执行的时候,自然就调用这个属性,后面加 ( ) 括号即可执行。
</aside>