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)

修饰符:presentationDetents 设置大小

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

呈现多个 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")
            }
        }
    }
}

<aside> 💡

但如果是希望在同一个 视图中放置多个 sheet,那将多个 sheet 修饰符附加到同一个父视图、或者将 sheet 放在 toolbar 外、或者放置在 Menu 中,或者放置在 ForEach 中,这些做法都容易出现一些奇怪的情况。建议最好将 sheet 放在一个稳定的容器外面。

</aside>


Sheet 视图的转场设置

对于 sheet 视图添加常规的 .transition().animation() 修饰符,不会影响 sheet 的呈现方式。因为 Sheet 的呈现是由系统控制的,SwiftUI 默认提供了一个标准的动画。要为 sheet 添加自定义转场效果,需要使用不同的方法。以下是一些可以尝试的修改:

  1. 使用 .presentationDetents().presentationDragIndicator() 来自定义 sheet 的呈现方式:

    .sheet(isPresented: $showAddProjectView) {
        AddProjectPageView(showAddProjectView: $showAddProjectView)
            .presentationDetents([.medium, .large])
            .presentationDragIndicator(.visible)
    }
    
  2. 如果想要更多的控制,可以考虑使用 .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)
    }
    
  3. 另一种方法是使用自定义的 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)
    

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:绑定同一布尔值

另一个方法是将绑定从父视图传递到子视图中,这样只要将绑定的值更改回 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 全屏弹窗

fullScreenCover() 在 macOS 上不可用。

从iOS 13开始,sheet 默认不会覆盖整个屏幕。如果想要显示整个屏幕的弹窗,或者不希望在 iOS 上通过向下拖动来关闭 sheet ,请改用 fullScreenCover() 修饰符。它的基本用法和 sheet 一样。

.fullScreenCover(item: self.$selectedArticle){
	article in 
	ArticleDetailView(article:article)
}

Popover 视图

SwiftUI 有一个用于显示弹出窗口的专用修改器,在 iPadOS 上显示为浮动气球,在 iOS 上则像纸张一样滑到屏幕上。

要显示 popover ,您需要某种状态来确定 popover 当前是否可见,但仅此而已。与 AlertSheet 不同,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()
        }
    }
}

Popover 和 Sheet 的区别

两者的主要区别可能是在 macOS 和 iPadOS 上,而不是手机上(手机上几乎没什么区别):