在大多数的 App 中(尤其是以内容为基础的 App),你应该体验过导航介面。这类型的 UI 通常有一个包含数据清单的导航列,并且它让使用者点击内容时导航至细节视图。
在 UIKit 中,我们可以使用 UINavigationController 来实现这类型的介面。在 SwiftUI 中,Apple 称其为“NavigationView”。由 iOS 16 开始,这个 “NavigationView” 以 “NavigationStack”取替。在本章中,我详细解说导航 UI 的实现,并教你如何进行一些自定义。和往常一样,我们将进行几个示例项目,以让你获得一些使用 NavigationStack 的实务经验。

让我们开始并实现一个我们之前使用导航 UI 建立的示例项目。那么,首先至下列网址下载初始项目:https://www.appcoda.com/resources/swiftui4/SwiftUINavigationListStarter.zip 。下载后开启项目,并看一下预览,你应该对于这个示例 App 非常熟悉,它只显示一个餐厅列表,如图 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.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 所示,你应该能够在选择其中一间餐厅后,导航至细节视图。另外,你可以点击“返回”(Back )按钮来导航回内容视图。整个导航由 NavigationStack 自动渲染。
首先,我们来讨论导航列的显示模式。默认情况下,导航列是设定为显示大标题,但当你向上滚动清单时,导航列会变小,这是 Apple 导入“大标题”(Large Title )导航列后的默认行为。
如果你想要使导航列更小型,并禁用大标题,你可以在 navigationBarTitle 修饰器之下加入navigationBarTitleDisplayMode 修饰器:
.navigationBarTitleDisplayMode(.inline)
这个参数控制导航列的外观,不论它应显示大标题导航列还是小型导航列,而默认是设定为 .automatic,即表示是使用大标题。在上列的代码中,我们将其设定为 .inline,即表示 iOS 使用小型导航列,如图 11.6 所示。

现在,我们将显示模式改为 .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 后,将其它指定给三个外观属性,包括standardAppearance、scrollEdgeAppearance 与 compactAppearance。如前所述,如果需要的话,你可以为 scrollEdgeAppearance 与 compactAppearance 建立及指定一个单独的外观物件。

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

我们来看这个自定义是否如何工作的。要修改指示器的图片,你可以调用 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 所示。

除了使用 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
之后,你可以利用以下代码调用presentationMode 的 dismiss() 函数:
presentationMode.wrappedValue.dismiss()
你在预览画布再测试 App,并选取其中一家餐厅,你会见到一个带有餐厅名的返回按钮。点击返回按钮,视图将导航回主画面。
为了确认你理解如何建立导航UI,这里有一个作业。首先,至下列网址下载初始项目:
https://www.appcoda.com/resources/swiftui4/SwiftUINavigationStarter.zip。开启项目后,你将看到一个显示文章清单的示例App。
这个项目与你之前建立的项目非常类似,主要的差异是 Article.swift 的导入。这个文件储存了 articles 数组,而该数组附有一些示例数据。如果你仔细检查 Article 结构,它现在有一个用于储存完整文章的 content 属性。
你的任务是将清单嵌入导航视图,并建立细节视图。当使用者点击内容视图中其中一篇文章时,它将导航至显示完整文章的细节视图,如图 11.10 所示。我将在下一节中与你讨论解决方案,但请你尽力找出自己的解决方案。

你完成作业了吗?细节视图比我们之前建立的视图更复杂,我们来看看如何建立它。
为了让代码更易编写,我们将为它建立一个单独的文件,而不是在 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 来包裹所有的视图,以启用可滚动的内容。我不会逐行说明代码,我相信你应该了解 Text、Image 与 VStack 的运作方式,不过我想强调的修饰器是 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 所示。

我们再多尝试一件事。由于这个视图将嵌入至 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 ),这里显示揭示指示器有点奇怪,我们可以禁用它吗?第二个问题是,在细节视图中精选图片的上方出现空白区域。我们来逐一讨论这些问题。

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

图片上方会出现空白区域的原因是导航列的缘故。这个空白区域实际上是一个带有空白标题的大尺寸导航列,当App 从内容视图导航至细节视图时,导航列会变成标准尺寸列。因此,要修复这个问题,我们需要做的是明确指定使用标准尺寸导航列。
在 ScrollView 的括号后,插入下列这行代码:
.navigationBarTitleDisplayMode(.inline)
通过将导航列设定为 inline 模式后,空白区域将被最小化,现在你可回到 ContentView.swift 来再次测试App,细节视图现在看起来好多了。
虽然你可使用内建的属性来自订“返回”按钮指示器图片,有时你可能想要建立一个客制化“返回”按钮来导航回内容视图。问题是如何通过编写代码来完成呢?
在最后一个小节中,我要介绍如何通过隐藏导航列及建立自己的“返回”按钮,来建立一个更精致的细节视图。首先,我们看一下如图 11.14 所示的最终设计,看起来不错吧?

要布局这个画面,我们必须要解决两个问题:
iOS 有一个“安全区域”(safe area )的概念,用于辅助视图的布局。安全区域可帮你将视图放置于介面的可见部分,例如:安全区域防止视图隐藏了状态列。若是你的 UI 导入了导航列,则会遮挡导航列。
](images/navigation/swiftui-navigation-15.jpg)
要放置超出安全区域的内容,你可以使用名为 ignoresSafeArea 修饰器。对于我们的项目,由于我们想要滚动视图超出安全区域的顶部边缘,则可编写修饰器如下:
.ignoresSafeArea(.all, edges: .top)
这个修饰器接收其他值,如 .bottom 与 .leading。如果你想要忽略整个安全区域,则可以直接使用.ignoresSafeArea()。通过将这个修饰器加到 ScrollView,我们可以隐藏导航列,并实现一个视觉上赏心悦目的细节视图。

现在谈到关于建立自己的“返回”按钮的第二个问题,这个问题比第一个问题更棘手。下面是我们要实现的内容:
为了隐藏“返回”按钮,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 所示。

你可能发现按钮的 action 闭包被留空。“返回”按钮已经布局得不错了,但问题是它不能运作。
原来的“返回”按钮是由 NavigationView 渲染,可以自动导航回前一个画面。问题来了,我们该如何编写代码来触发这个操作呢?感谢 SwiftUI 框架所内建的环境值(environment value ),你可以引用一个名为dismiss 环境绑定(environment binding ),来导航至前一个画面。
现在,在 ArticleDetailView 声明一个 dismiss 变量来取得环境值:
@Environment(\.dismiss) var dismiss
接下来,在我们的客制化“返回”按钮的 action 中,插入下列这行代码:
dismiss()
这里,我们调用 dismiss 方法,以在点击“返回”按钮时解除细节视图。现在,你可以运行 App 并再次测试它,你应该能够在内容视图与细节视图之间进行导航。
导航 UI 在行动 App 中非常常见,理解我在本章所介绍的内容非常重要。如果你完全理解了内容,即使数据是静态的,你也可以建立一个基于内容的简单 App。
在本章所准备的示例档中,有完整的项目可供下载:
要进一步学习导航视图,你也可以参考下列 Apple 所提供的文件: