Sheet 视图

在 SwiftUI 中显示视图的方式有多种,最基本的就是 sheet 工作表 (在现有视图之上呈现的新视图)。在 iOS 上,这会自动为我们提供一个类似卡片的实例,其中当前视图稍微滑向远处,新视图以动画方式显示在顶部。


参数:isPresented 绑定 Bool

最简单的调起 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")
}

参数:item 绑定 Optional

除了绑定布尔值,另外一种方式是,用 .sheet(item: self.$...) 属性绑定一个 Optional 值,这种方式很适合传参

// 首先新建状态属性,储存用户到底选了哪个文章。默认值为 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 参数:即后面跟着目标视图的初始化函数
// 此简写方法仅在:目标视图具有不接受参数的初始值设定项时才有效;如果目标视图需要传递特定值,则还是要用回基于闭包的方法。

参数:onDismiss 解除函数

我们还可以将一个【函数】添加到 sheet ,该函数将会在 sheet 关闭时,自动调用运行。如果你需要从 sheet 回传数据时,这个方法无效;但如果不涉及传参,该方法就特别适合。

// onDismiss 参数:可以设定一个回调函数。让 sheet 在被关闭的时候,自动执行该函数
.sheet(isPresented: $showingEditScreen, onDismiss: resetCards, content: EditCards.init)

结合 ScrollViews 使用

在 Sheet 内显示内容,通常会用 ScrollView。SwiftUI 会自动协调 ScrollView 的滚动行为和 Sheet 的拖动行为。

这种协调机制已内置于系统中,无需我们采取额外步骤。

呈现多个 Sheet 的 Bug

如果想在同一个 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 修饰符时,那


Sheet 视图常用修饰符

参考这个制作可拖拽的底部 Sheet 教程:https://tanaschita.com/swiftui-draggable-bottom-sheet/

.presentationDedents

对 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>

.presentationDragIndicator

控制是否显示 Sheet 上的拖动指示器:

.sheet(isPresented: $showingCredits) {
		Text("This app was brought to you by Hacking with Swift")
				.presentationDetents([.medium, .large])
				// 在弹窗隐藏提供调整大小的手柄
				.presentationDragIndicator(.hidden)
				.presentationDragIndicator(.visible)
}

.presentationCornerRadius

控制弹出 Sheet 视图上的圆角,这里要注意是否上下圆角都需要…

.sheet(isPresented: $showingCredits) {
		Text("This app was brought to you by Hacking with Swift")
				.presentationDetents([.medium, .large])
				// 在弹窗隐藏提供调整大小的手柄
				.presentationDragIndicator(.hidden)
				// 设置弹窗视图的圆角
				.presentationCornerRadius(30)
}

.interactiveDismissDisabled

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)
    }
}

.presentationBackgroundInteraction

使用该修饰符,可以让用户在 Sheet 没有完全覆盖的情况下,与 Sheet 后面的内容进行交互。例如地图应用中经常用到这个特性。

.presentationBackgroundInteraction(
		.enabled(upThrough:.large)
)

Sheet 视图的解除

要消除另一个视图,需要另一个属性包装器 @Environment@Environment 允许我们创建存储外部提供给我们的值的属性。我们要求环境撤回 sheet 视图,因为它可能以多种不同的方式呈现。因此,我们实际上是在要求 “请弄清楚我的视图是如何呈现的,然后适当地撤回它。”

方法1:环境值 dismiss 解除

第一个解除方法是:使用呈现模式的环境键,让视图自行消失。任何视图都可以使用 @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>

方法2:绑定同一布尔值