在大多数的 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 导入了导航列,则会遮挡导航列。
要放置超出安全区域的内容,你可以使用名为 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 所提供的文件: