精通 SwiftUI - iOS 16 版

第 12 章
实现强制回应视图、浮动按钮与警告提示视窗

在前一章中,我们建立了一个导航介面,让使用者从内容视图导航至细节视图。视图转场动画很精巧,并且完全由iOS 负责。当使用者触发转场时,细节视图会流畅地从右至左滑动。导航 UI 只是常用的 UI 模式之一,在本章中,我将向你介绍另一个强制显示内容的设计技巧。

对于 iPhone 的使用者,你应该非常熟悉强制回应视图了。强制回应视图的一种常见用途是显示输入表单,例如:行事历 App 为使用者显示一个强制回应视图来建立一个新事件。系统内建的提醒事项与联络人 App 也使用强制回应视图来要求使用者输入。

图 12.1. 行事历、提醒事项与联络人 App 的强制回应视图示例
图 12.1. 行事历、提醒事项与联络人 App 的强制回应视图示例

从使用者体验的角度来看,这个强制回应视图通常是通过点击按钮来触发。同样的, 强制回应视图的转场动画是由 iOS 所处理。当显示全屏幕的强制回应视图时,它会流畅地从画面底部向上滑动。

如果你是 iOS 的长期使用者,你可能会发现如图 12.1 所示的强制回应视图的外观及感觉和平常不太一样。在 iOS 13 之前,显示强制回应视图时会覆盖整个画面,自 iOS 13 起,强制回应视图默认是以卡片式的形式显示,其不会覆盖全画面,而是部分覆盖了底层内容视图,你仍然可看到内容 / 父视图的顶部边缘。除了视觉变动之外,现在可从画面的任意位置向下滑动来解除强制回应视图。你不需要撰写任何一行代码,即可启动这个手势。它完全是内建且由 iOS 产生。当然,若是你想通过按钮来解除强制回应视图,则依然可以这样做。

那么,我们将在本章中要实现什么呢?

我教你如何使用强制回应视图显示和在前一章中我们实现过的相同细节视图,虽然强制回应视图通常用于显示表单,这并不表示你不能使用它们来显示其他资讯。除了强制回应视图之外,你还将学习如何在细节视图中建立浮动按钮。虽然可通过滑动手势来解除强制回应视图,但我想提供一个“Close”按钮来供使用者解除细节视图。另外,我们也将研究警告提示视窗(Alert ),这是另一种强制回应视图。

图 12.2. 使用强制回应视图来显示细节画面
图 12.2. 使用强制回应视图来显示细节画面

我们在本章中有许多要讨论的内容。让我们开始吧 !

了解 SwiftUI 的工作表

工作表(sheet )的表现风格看起来为一张卡片,其部分覆盖了底层内容,并使所有未覆盖到的地方变暗,以防止与其互动。在目前卡片的后面可看见父视图或上一张卡片的顶部边缘,以帮助人们记住他们开启卡片时暂停的任务

- Apple 的官方文件(https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/modality/)

在深入钻研如何实现之前,让我先简要介绍一下强制回应视图的卡片式外观。在 SwiftUI 中,卡片外观是使用工作表的表现风格来实现,这是强制回应视图的默认表示风格。

基本上,要显示强制回应视图,你可应用 sheet 修饰器,如下所示:

.sheet(isPresented: $showModal) {
    DetailView()
}

它采用布林值来指示是否显示强制回应视图,如果 isPresented 设定为 true,则强制回应视图将自动以卡片形式显示。

显示强制回应视图的另一种方式,可以写成如下代码:

.sheet(item: $itemToDisplay) {
    DetailView()
}

sheet 修饰器也让你可通过传送一个 Optional 绑定来触发强制回应视图的显示。如果 Optional有一个值,iOS 会弹出强制回应视图,如果你还记得我们在前一章中对于 actionSheet 的讨论,你应该会发现 sheet 的用法与 actionSheet 非常相似。

准备初始项目

以上是背景资讯,我们来继续实际运行示例项目。首先,,请至 https://www.appcoda.com/resources/swiftui4/SwiftUIModalStarter.zip。 下载初始项目,下载后开启项目,并看一下预览,如图 12.3 所示。你应该非常熟悉示例 App 了,该 App 仍有一个导航列,但导航链接已被删除。

图 12.3. 初始项目
图 12.3. 初始项目

使用 isPresented 实现强制回应视图

如前所述,sheet 修饰器提供我们两种显示模式的方式。我将展示这两种方法如何工作,我们从 isPresented 方法开始,对于这个方法,我们需要一个Bool 类型的状态变量来追踪强制回应视图的状态。在 ContentView中声明这个变量:

@State var showDetailView = false

默认情况下,它设定为 false。当点击其中一列时,该变量的值将会设定为 true。稍后, 我们会在代码中做这个修改。

当显示细节视图时,该视图需要我们传送所选的文章,因此我们也需要声明一个状态来储存使用者的选择。在ContentView 中,为此声明另一个状态变量如下:

@State var selectedArticle: Article?

为了实现强制回应视图,我们将 sheet 修饰器加到 List 上,如下所示:

NavigationStack {
    List(articles) { article in
        ArticleRow(article: article)

        .listRowSeparator(.hidden)
    }
    .listStyle(.plain)
    .sheet(isPresented: $showDetailView) {

        if let selectedArticle = self.selectedArticle {
            ArticleDetailView(article: selectedArticle)
        }
    }

    .navigationTitle("Your Reading")
}

强制回应视图的显示取决于 showDetailView 属性的值,这就是为何我们在 isPresented 参数中指定它的原因。该 sheet 修饰器的闭包声明要显示的视图布局。这里我们将显示 ArticleDetailView

最后一个问题是我们如何侦测触控呢?当建立导航UI 时,我们利用 NavigationLink 来处理触控, 然而此特殊按钮是为导航介面所设计。在 SwiftUI 中, 有一个名为 onTapGesture 的处理器,可以用来识别触控手势,因此你可以将此处理器加到每个 ArticleRow 来侦测使用者的触控。现在修改 body 变量中的 NavigationStack,如下所示:

NavigationStack {
    List(articles) { article in
        ArticleRow(article: article)
            .onTapGesture {
                self.showDetailView = true
                self.selectedArticle = article
            }

        .listRowSeparator(.hidden)
    }
    .listStyle(.plain)
    .sheet(isPresented: $showDetailView) {

        if let selectedArticle = self.selectedArticle {
            ArticleDetailView(article: selectedArticle)
        }
    }

    .navigationTitle("Your Reading")
}

onTapGesture 的闭包中,我们将 showDetailView 设定为 true,这是用于触发强制回应视图的显示。我们也将所选的文章储存在 selectedArticle 变量中。

现在于预览画布上运行这个 App。你应该能够以强制回应模式弹出细节视图,如图 12.4 所示。

注意:当你第一次打开模态视图时,它显示的是一个空白视图。 擦拭对话框以关闭它,然后选择另一篇文章(不是同一篇文章),App应该会显示正确的文章。 这是一个已知问题,我们将在后面的部分讨论如何改善。

图 12.4. 以强制回应模式来显示细节视图
图 12.4. 以强制回应模式来显示细节视图

使用 Optional 绑定实现强制回应视图

sheet 修饰器还提供另一种显示强制回应视图的方式。这里不使用布林值来控制强制回应视图的外观,这个修饰器让你使用一个 Optional 绑定来实现相同的目标。

你可以将 sheet 修饰器替换为下列代码:

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

在这种情况下,sheet 修饰器需要你传送一个 Optional 绑定。这里我们指定为 selectedArticle 绑定,这表示只有当所选的文章有值,iOS 才会弹出强制回应视图。闭包中的代码指定强制回应视图的外观,但它和我们之前所撰写的有些不同。

对于这个方法,sheet 修饰器将闭包中所选的文章传送给我们。article 参数包含了所选的文章,且该文章确保有一个值,这就是为何我们可以使用它来初始化 ArticleDetailView

由于我们不再使用 showDetailView 变量,因此你可以删除下列这行代码:

@State var showDetailView = false

另外,从 .onTapGesture 闭包中删除 self.showDetailView = true:

.onTapGesture {
    self.showDetailView = true
    ...
}

修改代码,你可以再次测试这个App。运作一如往常,但底层比原来更简洁。

建立浮动按钮来解除强制回应视图

强制回应视图具有向下滑动手势的内建支持。现在,你可以向下滑动视图来关闭它,我想这对于 iPhone 长期使用者而言很自然,因为如 Facebook 之类的 App 已使用这个手势来解除视图,但是新的使用者可能对此一无所知,我们最好开发一个“关闭”按钮作为解除强制回应视图的替代方式。

图 12.5. 浮动按钮
图 12.5. 浮动按钮

现在切换到 ArticleDetailView.swift, 我们将“关闭”按钮加入至视图中,如图 12.5 所示。

你知道如何将按钮放在右上角吗?试着不直接遵守我的代码,而是提出自己的实现。

好的,回到实现部分。

NavigationStack 类似,我们可以使用 dismiss 环境值来解除模式。因此,首先在ArticleDetailView 中声明下列的变量:

@Environment(\.dismiss) var dismiss

对于“关闭”按钮,我们可以将 overlay 修饰器加到滚动视图上(在ignoresSafeArea之前添加),如下所示:

.overlay(

    HStack {
        Spacer()

        VStack {
            Button {
                dismiss()
            } label: {
                Image(systemName: "chevron.down.circle.fill")
                    .font(.largeTitle)
                    .foregroundColor(.white)
            }

            .padding(.trailing, 20)
            .padding(.top, 40)

            Spacer()
        }
    }
)

如此,按钮将会覆盖在滚动视图上方,以浮动按钮的形式显示。即使你向下滚动视图,按钮也会停留在相同的位置。要将按钮放在右上角,这里我们使用 HStackVStack, 然后加上 Spacer 作为辅助。要解除视图, 你可以调用 dismiss() 函数。

图 12.9. 实现“关闭”按钮
图 12.9. 实现“关闭”按钮

现在于模拟器中实现 App,或切换至 ContentView,并在画布中运行。你应该能点选“关闭”按钮来解除强制回应视图。

使用警告提示视窗

除了卡片式的强制回应视图,“警告提示视窗”( Alert )是另一种强制回应视图,当它显示时,整个画面会被锁住,如果你不选择其中一个选项,将会无法离开。图 12.7 为一个警告提示视窗的示例,这是我们将在示例项目中实现的内容,而我们所要做的是,当使用者点击“关闭”按钮后,显示一个警告提示视窗。

图 12.7 显示一个警告提示视窗
图 12.7 显示一个警告提示视窗

在 SwiftUI 中,你可以使用 Alert 结构来建立一个警告提示视窗,下列是 Alert 的示例用法:

.alert("Warning", isPresented: $showAlert, actions: {
    Button {
        dismiss()
    } label: {
        Text("Confirm")
    }

    Button(role: .cancel, action: {}) {
        Text("Cancel")
    }
}, message: {
    Text("Are you sure you want to leave?")
})

示例代码初始化一个标题为“警告”的警告提示视图,警告提示视窗还向使用者显示“你确定要离开吗”。在警告提示视图中有两个按钮:“确认”(Confirm )与“取消”(Cancel )。

要建立如图 12.7 的警告提示视窗,代码如下所示:

.alert("Reminder", isPresented: $showAlert, actions: {
    Button {
        dismiss()
    } label: {
        Text("Yes")
    }

    Button(role: .cancel, action: {}) {
        Text("No")
    }

}, message: {
    Text("Are you sure you are finished reading the article?")
})

除了主按钮具有 action 参数之外,它和先前的代码类似。这个警告提示视窗询问使用者是否已阅读完文章,若是使用者选择“是”( Yes ),它会继续关掉强制回应视图,否则强制回应视图将保持开启。

现在,我们已有了建立警告提示视窗的代码,问题是如何触发警告提示视窗的显示呢? SwiftUI 提供一个可加到任何视图的 alert 修饰器。同样的,你使用一个布林变量来控制警告提示视窗的显示,因此在 ArticleDetailView中声明一个状态变量:

@State private var showAlert = false

接下来,将以上的 alert 修饰器加到 ScrollView 上。

还剩下一件事,我们应该何时触发警告提示视窗呢?换句话说,我们何时要将 showAlert 设定为 true

显然的,当某人点击“关闭”按钮时,App 应该显示警告提示视窗。因此,替换按钮动作的代码如下:

Button {
    self.showAlert = true
} label: {
    Image(systemName: "chevron.down.circle.fill")
        .font(.largeTitle)
        .foregroundColor(.white)
}

我们没有直接解除强制回应视图,而是通过将 showAlert 设定为 true,来指示 iOS 显示警告提示视窗。现在你可以测试 App 了,当你点击“关闭”按钮时,你将看到警告提示视窗,如图12.10 所示。若是你选择“是”(Yes ),强制回应视图将解除。

图 12.8. 点击“关闭”按钮将显示警告提示视窗
图 12.8. 点击“关闭”按钮将显示警告提示视窗

全屏幕强制回应视图的呈现

自从 iOS 13 开始,强制回应视图默认是不使用全屏幕的覆盖形式。如果想要以全屏幕来呈现强制回应视图的话,你可以使用 iOS 14 所导入的 .fullScreenCover 修饰器,替代 .sheet 修饰器来呈现强制回应视图, .fullScreenCover 修饰器的用法如下:

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

本章小结

你已经学习了如何显示强制回应视图、实现浮动按钮以及显示警告提示视窗。iOS 持续鼓励使用者利用手势来与装置互动,并为常见手势提供内建支持。不需要撰写一行代码,即可让使用者在画面上向下滑动,以解除强制回应视图。

强制回应视图与警告提示视窗的 API 设计非常相似,它监控状态变量,以确认是否触发强制回应视图(或警告提示视窗)。一旦你了解这个技术后,实现对你而言应该不困难了。

在本章所准备的示例档中,有完整的项目可供下载: