精通 SwiftUI - iOS 16 版

第 11 章
运用导航 UI 与导航列客制化

在大多数的 App 中(尤其是以内容为基础的 App),你应该体验过导航介面。这类型的 UI 通常有一个包含数据清单的导航列,并且它让使用者点击内容时导航至细节视图。

在 UIKit 中,我们可以使用 UINavigationController 来实现这类型的介面。在 SwiftUI 中,Apple 称其为“NavigationView”。由 iOS 16 开始,这个 “NavigationView” 以 “NavigationStack”取替。在本章中,我详细解说导航 UI 的实现,并教你如何进行一些自定义。和往常一样,我们将进行几个示例项目,以让你获得一些使用 NavigationStack 的实务经验。

图 11.1. 示例项目的导航介面
图 11.1. 示例项目的导航介面

准备初始项目

让我们开始并实现一个我们之前使用导航 UI 建立的示例项目。那么,首先至下列网址下载初始项目:https://www.appcoda.com/resources/swiftui4/SwiftUINavigationListStarter.zip 。下载后开启项目,并看一下预览,你应该对于这个示例 App 非常熟悉,它只显示一个餐厅列表,如图 11.2 所示。

图 11.2. 初始项目应显示一个简单的清单视图
图 11.2. 初始项目应显示一个简单的清单视图

我们所要做的是,将这个清单视图嵌入至导航视图中。

实现导航视图

在旧版的 iOS,SwiftUI 框架提供一个名为 NavigationView 的视图来建立导航 UI。要将清单视图嵌入至NavigationView 中,你所需要做的是使用 NavigationView 包裹 List ,如下所示:

NavigationView {
    List {
        ForEach(restaurants) { restaurant in
            BasicImageRow(restaurant: restaurant)
        }
    }
    .listStyle(.plain)
}

在 iOS 16 中,Apple 将 NavigationView 替换为 NavigationStack。 你仍然可以使用 NavigationView 来创建导航视图, 但建议使用 NavigationStack,因为 NavigationView 最终会从 SDK 中移除。

要使用 NavigationStack 创建导航视图,你可以将以上的代码写成这样:

NavigationStack {
    List {
        ForEach(restaurants) { restaurant in
            BasicImageRow(restaurant: restaurant)
        }
    }
    .listStyle(.plain)
}

进行修改后,你应该会看到一个空的导航栏。 要为栏加入标题,请使用 navigationBarTitle 修饰符,如下所示:

NavigationStack {
    List {
        ForEach(restaurants) { restaurant in
            BasicImageRow(restaurant: restaurant)
        }
    }
    .listStyle(.plain)

    .navigationTitle("Restaurants")
}

现在,该 App 应该有一个具大标题的导航列,如图 11.3 所示。

图 11.3. 基本的导航UI
图 11.3. 基本的导航UI

至目前为止,我们只是在清单视图中加入一个导航列。我们通常使用导航介面来让使用者导航至细节视图,以显示所选项目的细节。对于此示例,我们将建立一个简单的细节视图,以显示餐厅的大图,如图 11.4 所示。

图 11.4. 内容视图与细节视图
图 11.4. 内容视图与细节视图

让我们从细节视图开始。在 ContentView.swift 档的结尾处,插入下列的代码,以建立细节视图:

struct RestaurantDetailView: View {
    var restaurant: Restaurant

    var body: some View {
        VStack {
            Image(restaurant.image)
                .resizable()
                .aspectRatio(contentMode: .fit)

            Text(restaurant.name)
                .font(.system(.title, design: .rounded))
                .fontWeight(.black)

            Spacer()
        }
    }
}

细节视图就像 View类型的其他 SwiftUI 视图一样,它的布局非常简单,只显示餐厅的图片及名称。RestaurantDetailView 结构还带入一个 Restaurant 物件,以检索餐厅的图片及名称。

好的,细节视图已经准备就绪,问题是你如何将内容视图中所选的餐厅传送至此细节视图呢?

SwiftUI 提供一个名为 NavigationLink 的特殊按钮,它能够侦测使用者的触控,并触发导航显示,NavigationLink 的基本用法如下:

NavigationLink(destination: DetailView()) {
    Text("Press me for details")
}

你可在 destination 参数中指定目标视图,并在闭包中实现其外观。对于示例 App, 应该在点击任何一间餐厅时,导航至细节视图。在这个示例中,我们对每一列应用 NavigationLink。修改 List 视图如下:

List {
    ForEach(restaurants) { restaurant in
        NavigationLink(destination: RestaurantDetailView(restaurant: restaurant)) {
            BasicImageRow(restaurant: restaurant)
        }
    }
}
.listStyle(.plain)

在上列的代码中, 我们告诉 NavigationLink 在使用者选择餐厅时, 导航至 RestaurantDetailView。我们也将所选的餐厅传送至细节视图,以进行显示。这就是建立导航介面与运行数据传送所需的全部内容。

图 11.5. 运行 App 来测试导航
图 11.5. 运行 App 来测试导航

在画布中,你应该注意到每列数据皆已加入了一个揭露图示。如图 11.5 所示,你应该能够在选择其中一间餐厅后,导航至细节视图。另外,你可以点击“返回”(Back )按钮来导航回内容视图。整个导航由 NavigationStack 自动渲染。

自订导航列

首先,我们来讨论导航列的显示模式。默认情况下,导航列是设定为显示大标题,但当你向上滚动清单时,导航列会变小,这是 Apple 导入“大标题”(Large Title )导航列后的默认行为。

如果你想要使导航列更小型,并禁用大标题,你可以在 navigationBarTitle 修饰器之下加入navigationBarTitleDisplayMode 修饰器:

.navigationBarTitleDisplayMode(.inline)

这个参数控制导航列的外观,不论它应显示大标题导航列还是小型导航列,而默认是设定为 .automatic,即表示是使用大标题。在上列的代码中,我们将其设定为 .inline,即表示 iOS 使用小型导航列,如图 11.6 所示。

图 11.6. 设定显示模式为“.inline”来使用小型导航列
图 11.6. 设定显示模式为“.inline”来使用小型导航列

现在,我们将显示模式改为 .automatic,看看会得到什么,导航列应该会再次变成大标题导航列。

.navigationBarTitleDisplayMode(.automatic)

设定字型与颜色

接下来,我们来看如何修改标题的字型与颜色。在撰写本章时,SwiftUI 还没有修饰器来让开发人员设定导航列的字型及颜色,而我们需要使用 UIKit 所提供的 UINavigation BarAppearance API。

举例而言,我们要将导航列的标题颜色修改为红色、字型修改为 Arial Rounded MT Bold,则我们可以在 init() 函数中建立一个 UINavigationBarAppearance 物件,并相应地设定属性。在 ContentView 中插入下列的函数:

init() {
    let navBarAppearance = UINavigationBarAppearance()
    navBarAppearance.largeTitleTextAttributes = [.foregroundColor: UIColor.red, .font: UIFont(name: "ArialRoundedMTBold", size: 35)!]
    navBarAppearance.titleTextAttributes = [.foregroundColor: UIColor.red, .font: UIFont(name: "ArialRoundedMTBold", size: 20)!]

    UINavigationBar.appearance().standardAppearance = navBarAppearance
    UINavigationBar.appearance().scrollEdgeAppearance = navBarAppearance
    UINavigationBar.appearance().compactAppearance = navBarAppearance
}

largeTitleTextAttributes 属性用于设定大尺寸标题的文字属性,而 titleTextAttributes 属性则用于设定标准尺寸标题的文字属性。当我们设定 navBarAppearance 后,将其它指定给三个外观属性,包括standardAppearancescrollEdgeAppearancecompactAppearance。如前所述,如果需要的话,你可以为 scrollEdgeAppearancecompactAppearance 建立及指定一个单独的外观物件。

图 11.7. 对大尺寸标题与标准尺寸标题修改字型与颜色
图 11.7. 对大尺寸标题与标准尺寸标题修改字型与颜色

“返回”按钮的图片与颜色

导航视图的“返回”(Back )按钮默认为蓝色,其使用V 形图示(chevron icon )来表示“返回”,如图 11.8 所示。通过使用 UINavigationBarAppearance API,你可以自订颜色、甚至是“返回”按钮的指示器图片。

图 11.8. 标准的“返回”按钮
图 11.8. 标准的“返回”按钮

我们来看这个自定义是否如何工作的。要修改指示器的图片,你可以调用 setBackIndicatorImage 方法,并提供自己的 UIImage。这里,我设定系统图片为 arrow.turn. up.left

navBarAppearance.setBackIndicatorImage(UIImage(systemName: "arrow.turn.up.left"), transitionMaskImage: UIImage(systemName: "arrow.turn.up.left"))

对于“返回”按钮的颜色,你可以通过设置 accentColor 属性来修改它,如下所示:

NavigationStack {
  .
  .
  .
}
.accentColor(.black)

如果你已经进行修改,则运行该 App 来快速测试,“返回”按钮应该如图 11.9 所示。

图 11.9. 自订“返回”按钮的外观
图 11.9. 自订“返回”按钮的外观

自订返回按钮

除了使用 UIKit 的 API 来自订返回按钮以外,另一个方式为隐藏返回按钮,利用 SwiftUI 自己建立一个返回按钮,要隐藏返回按钮,如下所示,你可以使用 .navigationBarBackButtonHidden 修饰器,并将其值设定为 true:

.navigationBarBackButtonHidden(true)

SwiftUI 还提供了一个名为 toolbar 的修饰符,用于创建导航栏项目。 例如,你可以使用所选餐厅的名称创建一个返回按钮,如下所示:

.toolbar {
    ToolbarItem(placement: .navigationBarLeading) {
        Button {
            dismiss()
        } label: {
            Text("\(Image(systemName: "chevron.left")) \(restaurant.name)")
                .foregroundColor(.black)
        }
    }
}

toolbar 的闭包中,我们创建了一个 ToolbarItem 对象,其位置设置为 .navigationBarLeading。 这告诉 iOS 将按钮放在导航栏的前沿。

要让程序产生有效果,修改 RestaurantDetailView 如下:

struct RestaurantDetailView: View {
    @Environment(\.dismiss) var dismiss

    var restaurant: Restaurant

    var body: some View {
        VStack {
            Image(restaurant.image)
                .resizable()
                .aspectRatio(contentMode: .fit)

            Text(restaurant.name)
                .font(.system(.title, design: .rounded))
                .fontWeight(.black)

            Spacer()
        }

        .navigationBarBackButtonHidden(true)
        .toolbar {
            ToolbarItem(placement: .navigationBarLeading) {
                Button {
                    dismiss()
                } label: {
                    Text("\(Image(systemName: "chevron.left")) \(restaurant.name)")
                        .foregroundColor(.black)
                }

            }
        }
    }
}

SwiftUI 内建的环境值很广泛。要解除目前视图,并返回至前一个视图。我们取得 .dismiss 环境值,然后调用 dismiss() 函数。请注意 .dismiss 是 iOS 15(或以上)新加入的环境值,如果你的 App 要支持比较旧的iOS 版本,你可以使用另一个环境值(即 .presentationMode):

@Environment(\.presentationMode) var presentationMode

之后,你可以利用以下代码调用presentationModedismiss() 函数:

presentationMode.wrappedValue.dismiss()

你在预览画布再测试 App,并选取其中一家餐厅,你会见到一个带有餐厅名的返回按钮。点击返回按钮,视图将导航回主画面。

作业

为了确认你理解如何建立导航UI,这里有一个作业。首先,至下列网址下载初始项目:

https://www.appcoda.com/resources/swiftui4/SwiftUINavigationStarter.zip。开启项目后,你将看到一个显示文章清单的示例App。

这个项目与你之前建立的项目非常类似,主要的差异是 Article.swift 的导入。这个文件储存了 articles 数组,而该数组附有一些示例数据。如果你仔细检查 Article 结构,它现在有一个用于储存完整文章的 content 属性。

你的任务是将清单嵌入导航视图,并建立细节视图。当使用者点击内容视图中其中一篇文章时,它将导航至显示完整文章的细节视图,如图 11.10 所示。我将在下一节中与你讨论解决方案,但请你尽力找出自己的解决方案。

图11.10. 为阅读 App 建立导航 UI
图11.10. 为阅读 App 建立导航 UI

建立细节视图

你完成作业了吗?细节视图比我们之前建立的视图更复杂,我们来看看如何建立它。

为了让代码更易编写,我们将为它建立一个单独的文件,而不是在 ContentView.swift 档中建立细节视图。在项目导航器中,右键点击 SwiftUINavigation 数据夹,选择“New File...”,接着选取“SwiftUI View”模板,并将文件命名为“ArticleDetailView.swift”。

由于细节视图将显示文章的详细资讯,我们需要这个属性来让调用者传送文章。因此,在 ArticleDetailView 中声明一个 article 属性:

var article: Article

接着,修改 body 如下,以布局细节视图:

var body: some View {
    ScrollView {
        VStack(alignment: .leading) {
            Image(article.image)
                .resizable()
                .aspectRatio(contentMode: .fit)

            Group {
                Text(article.title)
                    .font(.system(.title, design: .rounded))
                    .fontWeight(.black)
                    .lineLimit(3)

                Text("By \(article.author)".uppercased())
                    .font(.subheadline)
                    .foregroundColor(.secondary)
            }
            .padding(.bottom, 0)
            .padding(.horizontal)

            Text(article.content)
                .font(.body)
                .padding()
                .lineLimit(1000)
                .multilineTextAlignment(.leading)
        }
    }
}

我们使用一个 ScrollView 来包裹所有的视图,以启用可滚动的内容。我不会逐行说明代码,我相信你应该了解 TextImageVStack 的运作方式,不过我想强调的修饰器是 Group,这个修饰器可以让你将多个视图群组在一起,并使用某个设定。在上列的代码中,我们需要对两个 Text 视图使用特定的间距设定。为了避免代码重复,我们将两个视图群组在一起,并使用间距。

现在,我们已经完成了细节视图的布局,但是你应该会在 Xcode 内看到一个错误,指出有ArticleDetailView_Previews 的问题。而预览无法正常运作,是因为我们在 ArticleDetailView 中加入了article 属性,因此你需要在预览中传送一个示例文章。修改 ArticleDetail View_Previews来修正错误,如下所示:

struct ArticleDetailView_Previews: PreviewProvider {
    static var previews: some View {
        ArticleDetailView(article: articles[0])
    }
}

这里,我们只选择 articles 数组中的第一篇文章来预览。如果想要预览其他文章,你可以将其修改为其他值。当你修改后,预览画布应会正确渲染细节视图,如图 11.11 所示。

图 11.11. 用于显示文章的细节视图
图 11.11. 用于显示文章的细节视图

我们再多尝试一件事。由于这个视图将嵌入至 NavigationView中,因此你可以修改预览代码,来预览它在预览介面的外观:

struct ArticleDetailView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationStack {
            ArticleDetailView(article: articles[0])

            .navigationTitle("Article")
        }
    }
}

通过修改程序后,你应该在预览画布中看到一个空白的导航列。

现在我们已经完成了细节视图的布局,是时候该回到 ContentView.swift 来实现导航, 修改 ContentView 结构如下:

struct ContentView: View {

    var body: some View {

        NavigationStack {
            List(articles) { article in
                NavigationLink(destination: ArticleDetailView(article: article)) {
                    ArticleRow(article: article)
                }

                .listRowSeparator(.hidden)
            }
            .listStyle(.plain)

            .navigationTitle("Your Reading")
        }

    }
}

在上列的代码中,我们将 List 视图嵌入至 NavigationStack 中, 并对每一列应用 NavigationLink。导航链接的目的地设定为我们刚才建立的细节视图。在预览中,你应该可通过点击“播放”(Play )按钮来测试 App,并在选择文章后,导航至细节视图。

移除揭示指示器

这个 App 运作得很完美,但是有两个问题你可能想要微调。首先是内容视图中的揭示指示器(disclosure indicator ),这里显示揭示指示器有点奇怪,我们可以禁用它吗?第二个问题是,在细节视图中精选图片的上方出现空白区域。我们来逐一讨论这些问题。

图 11.12. 目前设计中的两个问题
图 11.12. 目前设计中的两个问题

SwiftUI 并没有为开发者提供禁用或隐藏揭示指示器的选项。为了解决这个问题,我们不直接将 NavigationLink 应用于文章列,而是建立一个具有两层的 ZStack。现在修改 ContentViewNavigationView 如下:

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

            NavigationLink(destination: ArticleDetailView(article: article)) {
                EmptyView()
            }
            .opacity(0)

            .listRowSeparator(.hidden)
        }
    }
    .listStyle(.plain)

    .navigationTitle("Your Reading")
}

下层是文章列,上层则是空视图。NavigationLink 现在应用于空视图,以避免 iOS 渲染揭示按钮。当你修改后,揭示指示器就会消失,但你仍然可以导航至细节视图。

现在,我们来看第二个问题的根本原因。

切换到 ArticleDetailView.swift,在设计细节视图时,我没有提到这个问题,但实际上从预览中,你应该会发现这个问题,如图 11.13 所示。

图 11.13 标头中的空白区域
图 11.13 标头中的空白区域

图片上方会出现空白区域的原因是导航列的缘故。这个空白区域实际上是一个带有空白标题的大尺寸导航列,当App 从内容视图导航至细节视图时,导航列会变成标准尺寸列。因此,要修复这个问题,我们需要做的是明确指定使用标准尺寸导航列。

ScrollView 的括号后,插入下列这行代码:

.navigationBarTitleDisplayMode(.inline)

通过将导航列设定为 inline 模式后,空白区域将被最小化,现在你可回到 ContentView.swift 来再次测试App,细节视图现在看起来好多了。

带有客制化“返回”按钮的精致 UI

虽然你可使用内建的属性来自订“返回”按钮指示器图片,有时你可能想要建立一个客制化“返回”按钮来导航回内容视图。问题是如何通过编写代码来完成呢?

在最后一个小节中,我要介绍如何通过隐藏导航列及建立自己的“返回”按钮,来建立一个更精致的细节视图。首先,我们看一下如图 11.14 所示的最终设计,看起来不错吧?

图 11.14. 经修订的细节视图
图 11.14. 经修订的细节视图

要布局这个画面,我们必须要解决两个问题:

  1. 如何将滚动视图延伸到画面顶部?
  2. 如何建立一个客制化“返回”按钮,并编写代码来触发导航?

iOS 有一个“安全区域”(safe area )的概念,用于辅助视图的布局。安全区域可帮你将视图放置于介面的可见部分,例如:安全区域防止视图隐藏了状态列。若是你的 UI 导入了导航列,则会遮挡导航列。

![图 11.15 安全区域](images/navigation/swiftui-navigation-15.jpg)
![图 11.15 安全区域](images/navigation/swiftui-navigation-15.jpg)

要放置超出安全区域的内容,你可以使用名为 ignoresSafeArea 修饰器。对于我们的项目,由于我们想要滚动视图超出安全区域的顶部边缘,则可编写修饰器如下:

.ignoresSafeArea(.all, edges: .top)

这个修饰器接收其他值,如 .bottom.leading。如果你想要忽略整个安全区域,则可以直接使用.ignoresSafeArea()。通过将这个修饰器加到 ScrollView,我们可以隐藏导航列,并实现一个视觉上赏心悦目的细节视图。

图 11.16. 应用这些修饰器至滚动视图
图 11.16. 应用这些修饰器至滚动视图

现在谈到关于建立自己的“返回”按钮的第二个问题,这个问题比第一个问题更棘手。下面是我们要实现的内容:

  1. 隐藏原来的“返回”按钮。
  2. 建立一个一般的按钮,然后将其指定为导航列的左侧按钮。

为了隐藏“返回”按钮,SwiftUI 提供一个名为 navigationBarBackButtonHidden 的修饰器。你只需将其值设定为 true,即可隐藏“返回”按钮:

.navigationBarBackButtonHidden(true)

当隐藏“返回”按钮后,你可以使用自己的按钮来替代它。toolbar 修饰器允许你配置导航栏项目。 在闭包中,我们使用ToolbarItem自订后退按钮,并将该按钮指定为导航栏的左按钮。 以下是相关代码:

.toolbar {
    ToolbarItem(placement: .navigationBarLeading) {
        Button(action: {
            // 导航至前一个画面
        }) {
            Image(systemName: "chevron.left.circle.fill")
                .font(.largeTitle)
                .foregroundColor(.white)
        }
    }
}

你可以将上述的修饰器加到 ScrollView。当修改生效后,你应该会在预览画布中看到我们自己的客制化“返回”按钮,如图 11.17 所示。

图 11.17. 建立我们自己的“返回”按钮
图 11.17. 建立我们自己的“返回”按钮

你可能发现按钮的 action 闭包被留空。“返回”按钮已经布局得不错了,但问题是它不能运作。

原来的“返回”按钮是由 NavigationView 渲染,可以自动导航回前一个画面。问题来了,我们该如何编写代码来触发这个操作呢?感谢 SwiftUI 框架所内建的环境值(environment value ),你可以引用一个名为dismiss 环境绑定(environment binding ),来导航至前一个画面。

现在,在 ArticleDetailView 声明一个 dismiss 变量来取得环境值:

@Environment(\.dismiss) var dismiss

接下来,在我们的客制化“返回”按钮的 action 中,插入下列这行代码:

dismiss()

这里,我们调用 dismiss 方法,以在点击“返回”按钮时解除细节视图。现在,你可以运行 App 并再次测试它,你应该能够在内容视图与细节视图之间进行导航。

本章小结

导航 UI 在行动 App 中非常常见,理解我在本章所介绍的内容非常重要。如果你完全理解了内容,即使数据是静态的,你也可以建立一个基于内容的简单 App。

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

要进一步学习导航视图,你也可以参考下列 Apple 所提供的文件: