NavigationView
是SwiftUI应用程序最重要的组件之一,它使我们能够轻松推入和弹出屏幕,以清晰,分层的方式为用户呈现信息。在本文中,我想演示在应用程序中使用NavigationView
的所有方式,包括诸如设置标题和添加按钮之类的简单操作,还包括程序化导航,创建拆分视图,甚至处理其他Apple平台,例如macOS和watchOS。
获取带有标题的基本 NavigationView
要开始使用NavigationView
,您应该在要显示的内容周围加上一个,例如:
struct ContentView: View {
var body: some View {
NavigationView {
Text("Hello, World!")
}
}
}
对于更简单的布局,导航视图应该是视图中的顶级内容,但是如果您在TabView
中使用它们,则导航视图应该在选项卡视图中。
在学习SwiftUI时,人们会感到困惑的一件事是我们如何将标题附加到导航视图:
NavigationView {
Text("Hello, World!")
.navigationBarTitle("Navigation")
}
请注意为什么navigationBarTitle()
修饰符属于Text
视图,而不属于导航视图?这是有意的,也是在此处添加标题的正确方法。
您会看到,导航视图使我们可以通过从右边缘向内滑动来显示新的内容屏幕。每个屏幕都可以有自己的标题,SwiftUI的工作就是确保标题始终显示在导航视图中——您会看到旧标题动画消失,而新标题动画显示。
现在考虑一下:如果我们将标题直接附加到导航视图,那么我们所说的是“这是给所有时间的固定标题”。通过将标题附加到导航视图内的任何内容,SwiftUI可以随着内容的更改来更改标题。
提示:您可以在导航视图内的任何视图上使用navigationBarTitle()
;它不必是最外层的。
您可以通过添加displayMode
参数来自定义标题的显示方式。共有三个选项:
-
.large
选项显示大标题,这对于导航堆栈中的顶级视图很有用。
-
-
.inline
选项显示小标题,这些小标题对于导航堆栈中的辅助视图,第三视图或后续视图很有用。
-
-
.automatic
选项是默认选项(初始默认.large
),它使用前面视图使用的选项。
-
对于大多数应用程序,您应该在初始视图中使用.automatic
选项,只需完全跳过displayMode
参数即可获取,此时为大标题:
.navigationBarTitle("Navigation")
对于所有推送到导航堆栈的视图,通常将使用.inline
选项,如下所示:
.navigationBarTitle("Navigation", displayMode: .inline)
可以尝试一下代码:
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination: NewTestView()) {
Text("Hello, World!")
}
.navigationBarTitle("Navigation")
}
}
}
struct NewTestView: View {
var body: some View {
List(0..<100) { item in
Text("\(item)")
}
.navigationBarTitle("NavigationNew")
}
}
因为没有在NewTestView
中设置displayMode: .inline
所以界面可能会有一点异常:
只有将界面上滑后才会和设置了displayMode: .inline
一样出现我们预期的界面:
呈现新视图
导航视图使用NavigationLink
呈现新屏幕,可以通过用户点击其内容或以编程方式启用它们来触发。
NavigationLink
我最喜欢的功能之一是您可以推送到任何视图——它可以是您选择的自定义视图,但是如果您只是进行原型制作,它也可以是SwiftUI的原始视图之一。
例如,这直接推送到文本视图:
NavigationView {
NavigationLink(destination: Text("Second View")) {
Text("Hello, World!")
}
.navigationBarTitle("Navigation")
}
由于我在导航链接中使用了文本视图,因此SwiftUI会自动将文本显示为蓝色,以向用户表示它是交互式的。这是一个非常有用的功能,但可能会带来不利的副作用:如果在导航链接中使用图像,您可能会发现图像变成蓝色!
要尝试此操作,请尝试将两张图像添加到项目的资产目录中——一张是照片,一张是具有一定透明度的形状。我添加了头像和Hacking with Swift 的logo,并像这样使用它们:
NavigationLink(destination: Text("Second View")) {
Image("hws")
}
.navigationBarTitle("Navigation")
我添加的图片是红色的,但是当我运行该应用程序时,SwiftUI会将其渲染成蓝色——试图提供帮助,向用户显示该图片是交互式的。但是,图像具有透明度,SwiftUI保持透明部分不变,因此您仍然可以清晰地看到一个蓝色的Logo。
如果我改用我的头像,结果会更糟:
NavigationLink(destination: Text("Second View")) {
Image("zhy")
}
.navigationBarTitle("Navigation")
由于这是一张照片,它没有任何透明度,因此SwiftUI将整个事物都染成蓝色——现在看起来就像一个蓝色正方形。
如果您希望SwiftUI使用图片的原始颜色,则应为其附加一个renderingMode()
修饰符,如下所示:
NavigationLink(destination: Text("Second View")) {
Image("zhy")
.renderingMode(.original)
}
.navigationBarTitle("Navigation")
请记住,这只是禁用蓝色,这意味着图像将看起来不具有交互性,但实际上它仍然可以点击。
在视图之间传递数据
使用NavigationLink
将新视图推送到导航堆栈时,可以传递新视图需要工作的所有参数。
例如,如果我们掷硬币并希望用户选择正面或反面,则可能会有类似以下结果视图:
struct ResultView: View {
var choice: String
var body: some View {
Text("你选择的是: \(choice)")
}
}
然后,在内容视图中,我们可以显示两个不同的导航链接:一个以“正面”作为选择来创建ResultView
,另一个是“反面”。在创建结果视图时,必须传递这些值,如下所示:
struct ContentView: View {
var body: some View {
NavigationView {
VStack(spacing: 30) {
Text("您将掷硬币–您要选择正面还是反面?")
NavigationLink(destination: ResultView(choice: "正面")) {
Text("选择 正面")
}
NavigationLink(destination: ResultView(choice: "反面")) {
Text("选择 反面")
}
}
.navigationBarTitle("Navigation")
}
}
}
SwiftUI将始终确保您提供正确的值来初始化详细视图。
以编程方式导航
SwiftUI 的 NavigationLink
具有第二个初始化器,该初始化器具有isActive
参数,可让我们读取或写入导航链接当前是否处于活动状态。实际上,这意味着我们可以通过将状态设置为true
来以编程方式触发导航链接的激活。
例如,这将创建一个空的导航链接并将其绑定到isShowingDetailView
属性:
struct ContentView: View {
@State private var isShowingDetailView = false
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: Text("Second View"), isActive: $isShowingDetailView) { EmptyView() }
Button("Tap to show detail") {
self.isShowingDetailView = true
}
}
.navigationBarTitle("Navigation")
}
}
}
请注意,触发后,导航链接下方的按钮如何将isShowingDetailView
设置为true
——这是使导航动作发生的原因,而不是用户与导航链接本身内部的任何内容进行交互的原因。
显然,使用多个布尔值来跟踪不同的可能的导航目标将很困难,因此SwiftUI为我们提供了另一种选择:我们可以向每个导航链接添加一个标记,然后使用单个属性控制触发哪个标记。例如,这将显示两个详细视图之一,具体取决于所按下的按钮:
struct ContentView: View {
@State private var selection: String? = nil
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: Text("Second View"), tag: "Second", selection: $selection) { EmptyView() }
NavigationLink(destination: Text("Third View"), tag: "Third", selection: $selection) { EmptyView() }
Button("Tap to show second") {
self.selection = "Second"
}
Button("Tap to show third") {
self.selection = "Third"
}
}
.navigationBarTitle("Navigation")
}
}
}
值得补充的是,您可以使用状态属性隐藏视图就像显示视图一样。例如,我们可以编写代码来创建显示详细信息屏幕的可点击导航链接,还可以在两秒钟后将isShowingDetailView
设置为false
。实际上,这意味着您可以启动该应用程序,用手点击该链接以显示第二个视图,然后短暂停留后,您将自动回到上一个屏幕。
例如:
struct ContentView: View {
@State private var isShowingDetailView = false
var body: some View {
NavigationView {
NavigationLink(destination: Text("Second View"), isActive: $isShowingDetailView) {
Text("Show Detail")
}
.navigationBarTitle("Navigation")
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.isShowingDetailView = false
}
}
}
}
使用环境传递值
NavigationView
会自动与其显示的任何子视图共享其环境,即使在非常深的导航堆栈中也可以轻松共享数据。关键是要确保您使用附加到导航视图本身的environmentObject()
修饰符,而不是其中的某些东西。
为了证明这一点,我们可以先定义一个简单的观察对象,该对象将承载我们的数据:
class User: ObservableObject {
@Published var score = 0
}
然后,我们可以创建一个详细视图以使用环境对象显示该数据,同时还提供一种方法来增加那里的得分:
struct ChangeView: View {
@EnvironmentObject var user: User
var body: some View {
VStack {
Text("Score: \(user.score)")
Button("Increase") {
self.user.score += 1
}
}
}
}
最后,我们可以让ContentView
创建一个新的User实例,该实例将被注入到导航视图环境中,以便在任何地方共享:
struct ContentView: View {
@ObservedObject var user = User()
var body: some View {
NavigationView {
VStack(spacing: 30) {
Text("Score: \(user.score)")
NavigationLink(destination: ChangeView()) {
Text("Show Detail View")
}
}
.navigationBarTitle("Navigation")
}
.environmentObject(user)
}
}
请记住,该环境对象将由导航视图提供的所有视图共享,这意味着如果ChangeView
显示自己的详细信息屏幕,该屏幕也将继承该环境。
提示:在生产应用程序中,您应该谨慎地在视图本地创建引用类型,并且应该为它们创建一个单独的模型层。
添加导航栏按钮
我们可以在导航视图中同时添加Leading导航按钮和Trailing导航按钮,可以在一侧或两侧使用一个或多个按钮。如果需要,这些按钮可以是标准按钮视图,但也可以使用导航链接。
注:Leading 和 Trailing 是按书写顺序区分的,即从左往右书写习惯的地方,Leading在为左侧,从右往左则相反比如阿拉伯语。
例如,这将创建一个右侧的导航栏按钮,在点击该按钮时会修改得分值:
struct ContentView: View {
@State private var score = 0
var body: some View {
NavigationView {
Text("Score: \(score)")
.navigationBarTitle("Navigation")
.navigationBarItems(
trailing:
Button("Add 1") {
self.score += 1
}
)
}
}
}
如果您想要左边和右边按钮,只需传递leading
和trailing
参数,如下所示:
Text("Score: \(score)")
.navigationBarTitle("Navigation")
.navigationBarItems(
leading:
Button("Subtract 1") {
self.score -= 1
},
trailing:
Button("Add 1") {
self.score += 1
}
)
如果要将两个按钮都放在导航栏的同一侧,则应将它们放在HStack
中,如下所示:
Text("Score: \(score)")
.navigationBarTitle("Navigation")
.navigationBarItems(
trailing:
HStack {
Button("Subtract 1") {
self.score -= 1
}
Button("Add 1") {
self.score += 1
}
}
)
提示:添加到导航栏中的按钮的可点击区域很小,因此最好在其周围添加一些填充(padding()
)以使其更易于点击。
自定义导航栏
我们可以通过多种方式自定义导航栏,例如控制其字体,颜色或可见性。但是,目前在SwiftUI中对此功能的支持还有些不足,实际上,只有两个修饰符可以使用,而无需使用UIKit:
-
navigationBarHidden()
修饰符使我们可以控制整个导航栏是可见还是隐藏。 -
navigationBarBackButtonHidden()
修饰符使我们可以控制后退按钮是隐藏还是可见,这对于希望用户在向后移动之前主动做出选择的时候很有用。
像navigationBarTitle()
一样,这两个修饰符都附加到导航视图内部的视图上,而不是导航视图本身。令人困惑的是,这与statusBar(hidden:)
修饰符不同,后者需要放置在导航视图中。
为了说明这一点,下面的一些代码可以在点击按钮时显示和隐藏导航栏和状态栏:
struct ContentView: View {
@State private var fullScreen = false
var body: some View {
NavigationView {
Button("Toggle Full Screen") {
self.fullScreen.toggle()
}
.navigationBarTitle("Full Screen")
.navigationBarHidden(fullScreen)
}
.statusBar(hidden: fullScreen)
}
}
现在到了自定义导航栏本身——颜色,字体等——我们需要回到 UIKit。这并不难,尤其是如果您以前使用过 UIKit,但是在SwiftUI之后,这会给系统带来一些改动。
自定义栏本身意味着向 AppDelegate.swift 中的 didFinishLaunchingWithOptions
方法添加一些代码。例如,这将创建一个UINavigationBarAppearance
的新实例,使用自定义背景色,前景色和字体对其进行配置,然后将其分配给导航栏代理:
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .red
let attrs: [NSAttributedString.Key: Any] = [
.foregroundColor: UIColor.white,
.font: UIFont.monospacedSystemFont(ofSize: 36, weight: .black)
]
appearance.largeTitleTextAttributes = attrs
UINavigationBar.appearance().standardAppearance = appearance
我不会在SwiftUI世界中声称这很好,但这就是事实。
使用 NavigationViewStyle 创建拆分视图
NavigationView
最有趣的行为之一是它还可以在较大的设备(通常是大尺寸的iPhone和iPad)上充当拆分视图的方式。
默认情况下,此行为有些混乱,因为它可能导致看似空白的屏幕。例如,这在导航视图中显示一个单词标签:
struct ContentView: View {
var body: some View {
NavigationView {
Text("Primary")
}
}
}
纵向显示效果不错,但是如果您使用iPhone 11 Pro Max将其旋转至横向,您会看到文本视图消失。
发生的情况是,SwiftUI自动考虑横向导航视图以形成主要细节拆分视图,在该视图中可以并排显示两个屏幕。同样,只有在有足够空间的情况下,这才在大型iPhone和iPad上发生,但仍然经常令人困惑。
首先,您可以通过在NavigationView
中提供两个视图来解决SwiftUI期望的问题,如下所示:
struct ContentView: View {
var body: some View {
NavigationView {
Text("Primary")
Text("Secondary")
}
}
}
当它在横向的大型iPhone上运行时,您会看到“Secondary屏幕占据了整个屏幕,并带有导航栏按钮以将主视图作为幻灯片显示出来。在iPad上,大多数时候您会同时看到两种视图,但是如果空间有限,您将获得与竖屏iPhone相同的推/弹出行为。
当使用两个这样的视图时,主视图中的任何NavigationLink
都会自动显示其目的地,而不是辅助视图。
另一种解决方案是要求SwiftUI一次只显示一个视图,而不管使用哪种设备或方向。这是通过将新的StackNavigationViewStyle()
实例传递给navigationViewStyle()
修饰符来完成的,如下所示:
NavigationView {
Text("Primary")
Text("Secondary")
}
.navigationViewStyle(StackNavigationViewStyle())
该解决方案在iPhone上运行得很好,但会在iPad上触发全屏导航,这在您看来并不令人愉快。
在 macOS 和 watchOS 上工作
尽管SwiftUI是跨平台框架,但它的目的是让您将技能应用到所有地方,而不是让您在所有平台上复制和粘贴相同的代码。区别是细微的,但对于NavigationView
来说很重要:
- 在macOS上,
navigationBarTitle()
修饰符不存在。 - 在watchOS上,
NavigationView
本身不存在。
其中任何一种都会阻止您共享代码,因为您的代码无法编译。但是,我们可以通过一些小的修改轻松地解决它们。
例如,在watchOS上,我们可以添加自己的空NavigationView
,将其内容简单地包装在琐碎的VStack
中:
#if os(watchOS)
struct NavigationView<Content: View>: View {
let content: () -> Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
var body: some View {
VStack(spacing: 0) {
content()
}
}
}
#endif
使用#if os(watchOS)
会限制其可见性,以便其他平台能够按预期运行,仅添加简单的VStack
不会使您的UI复杂化,因此很容易。
对于macOS,我们可以创建自己的navigationBarTitle()
修饰符,该修饰符根本不执行任何操作,如下所示:
#if os(macOS)
extension View {
func navigationBarTitle(_ title: String) -> some View {
self
}
}
#endif
同样,这几乎没有增加UI工作量,Swift编译器甚至可以完全对其进行优化,从而完全不增加负担。
这些更改可能看起来很小,但是它们在帮助我们避免使用SwiftUI创建跨平台应用程序时避免不必要的麻烦大有帮助。
总结
在本文中,我们研究了可以在SwiftUI中使用导航视图的多种方法,但是还有更多尝试的方法!
如果您想学习所有SwiftUI,请查看我的100 Days of SwiftUI课程,该课程完全免费。