在 SwiftUI 中显示视图的方式有多种,最基本的就是 sheet
工作表 (在现有视图之上呈现的新视图)。在 iOS 上,这会自动为我们提供一个类似卡片的实例,其中当前视图稍微滑向远处,新视图以动画方式显示在顶部。
sheet
与 alert
相似,我们要定义应显示 sheet
的条件,当这些条件为真或为假时,将分别显示或隐藏 sheet
sheet
出现时会覆盖整个画面;在 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 值,这种方式很适合传参
nil
place.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)
presentationDetents()
修饰符让我们创建从视图底部向上滑动的 sheet
,但仅占据屏幕的一部分,具体多少取决于我们,我们可以根据需要进行尽可能多或尽可能少的控制。首次显示工作表时将使用初始尺寸,但 iOS 会显示一个小手柄,让用户进行调整:
// 指定内置尺寸
// 通过支持 `.medium` (大约一半屏幕)和 `.large` (整个屏幕),SwiftUI 将创建一个调整大小手柄,让用户在这两种尺寸之间调整
// 如果您不要求任何制动 detents ,则默认为 `.large`
// 即使设置了自定义 detents,当存在紧凑的高度尺寸类别(例如横向的 iPhone)时,表格也会自动占据整个屏幕
// 如果您支持这种情况,请确保提供一种关闭工作表的方法。
.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))
}
}
}
// 如果您不希望提供【调整大小的手柄】,请将 presentationDragIndicator(.hidden) 添加到工作表的内容中
.sheet(isPresented: $showingCredits) {
Text("This app was brought to you by Hacking with Swift")
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
}
如果想在同一个 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")
}
}
}
}
<aside> 💡
但如果是希望在同一个 视图中放置多个 sheet
,那将多个 sheet
修饰符附加到同一个父视图、或者将 sheet
放在 toolbar
外、或者放置在 Menu
中,或者放置在 ForEach
中,这些做法都容易出现一些奇怪的情况。建议最好将 sheet 放在一个稳定的容器外面。
</aside>
对于 sheet 视图添加常规的 .transition()
和 .animation()
修饰符,不会影响 sheet 的呈现方式。因为 Sheet 的呈现是由系统控制的,SwiftUI 默认提供了一个标准的动画。要为 sheet 添加自定义转场效果,需要使用不同的方法。以下是一些可以尝试的修改:
使用 .presentationDetents()
和 .presentationDragIndicator()
来自定义 sheet 的呈现方式:
.sheet(isPresented: $showAddProjectView) {
AddProjectPageView(showAddProjectView: $showAddProjectView)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
如果想要更多的控制,可以考虑使用 .fullScreenCover()
代替 .sheet()
,然后在弹窗视图中添加自定义的动画:
.fullScreenCover(isPresented: $showAddProjectView) {
AddProjectPageView(showAddProjectView: $showAddProjectView)
}
// 然后在弹窗内容视图 AddProjectPageView 中添加动画,修改为:
var body: some View {
NavigationStack {
Form {
// 原有内容
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white)
.transition(.move(edge: .bottom))
}
.transition(.opacity)
.animation(.easeInOut(duration: 0.3), value: showAddProjectView)
}
另一种方法是使用自定义的 overlay 视图来模拟 sheet 效果,这样你就可以完全控制动画:
// 例如:
ZStack {
// 原有内容
if showAddProjectView {
Color.black.opacity(0.3)
.edgesIgnoringSafeArea(.all)
.onTapGesture {
showAddProjectView = false
}
AddProjectPageView(showAddProjectView: $showAddProjectView)
.transition(.move(edge: .bottom))
.zIndex(1)
}
}
.animation(.easeInOut, value: showAddProjectView)
要消除另一个视图,需要另一个属性包装器 @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>
另一个方法是将绑定从父视图传递到子视图中,这样只要将绑定的值更改回 false
,显示的视图就会消失。
struct DismissingView2: View {
@Binding var isPresented: Bool
var body: some View {
Button("Dismiss Me") {
isPresented = false
}
}
}
// 主视图
struct ContentView: View {
// 1.主视图声明一个布尔值状态属性
@State private var showingDetail = false
var body: some View {
Button("Show Detail") {
showingDetail = true
}
// 2.详细视图绑定前面的属性
.sheet(isPresented: $showingDetail) {
// 3. 并且详细视图中也有一个布尔值,和主视图的布尔值绑定
DismissingView2(isPresented: $showingDetail)
}
}
}
除了视觉效果不同之外,还可以通过在画面上任一位置向下滑动来关闭 sheet
视图。不需要写任何一行代码就可以支持该手势,这完全是内建的,由 iOS 生成。如果你要通过其他按钮来关闭 sheet
也是允许的。
SwiftUI 提供了 interactiveDismissDisabled()
修饰符,来控制用户是否可以向下滑动以关闭工作表。尽管滑动关闭通常很好,但有时这是您不允许的,例如,如果用户必须接受某些条款和条件,那么他们必须在关闭工作表之前采取某种操作。
// 在视图上添加以下修饰符:
struct ExampleSheet: View {
@Environment(\\.dismiss) var dismiss
var body: some View {
VStack {
Text("Sheet view")
Button("Dismiss", action: close)
}
.interactiveDismissDisabled()
}
func close() {
dismiss()
}
}
如果您愿意,还可以将布尔值绑定到修饰符,以允许仅在成功满足某些条件时才取消滑动。因此,我们的条款和条件示例可能如下所示:
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)
}
}
// 用回 dismiss 也是可以的
@Environment(\\.dismiss) var dismiss
// 1.先在目标弹窗视图里声明 presentationMode 参数来取得环境值:
@Environment(\\.presentationMode) var presentationMode
// 2. 在自定义返回按钮的 action 內,插入這行程式,即可实现返回:
self.presentationMode.wrappedValue.dismiss()
fullScreenCover()
在 macOS 上不可用。
从iOS 13开始,sheet
默认不会覆盖整个屏幕。如果想要显示整个屏幕的弹窗,或者不希望在 iOS 上通过向下拖动来关闭 sheet
,请改用 fullScreenCover()
修饰符。它的基本用法和 sheet
一样。
.fullScreenCover(item: self.$selectedArticle){
article in
ArticleDetailView(article:article)
}
SwiftUI 有一个用于显示弹出窗口的专用修改器,在 iPadOS 上显示为浮动气球,在 iOS 上则像纸张一样滑到屏幕上。
要显示 popover
,您需要某种状态来确定 popover
当前是否可见,但仅此而已。与 Alert
和 Sheet
不同,popover
可以包含您想要的任何类型的视图。因此,只需将您需要的任何内容放入弹出窗口中,SwiftUI 就会处理剩下的事情。
struct ContentView: View {
@State private var showingPopover = false
var body: some View {
Button("Show Menu") {
showingPopover = true
}
.popover(isPresented: $showingPopover) {
Text("Your content here")
.font(.headline)
.padding()
}
}
}
两者的主要区别可能是在 macOS 和 iPadOS 上,而不是手机上(手机上几乎没什么区别):
popover
:以浮动框的形式显示在当前视图上方,通常在较大屏幕设备上使用。popover
:适合在不完全离开当前视图的情况下显示临时信息或进行简单交互。popover
:用户可以在弹出框和当前视图之间自由切换,弹出框外点击可关闭。