SwiftUI导航栏完全指南

SwiftUI-NavigationView - 韦弦zhy

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参数来自定义标题的显示方式。共有三个选项:

    1. .large选项显示大标题,这对于导航堆栈中的顶级视图很有用。
    1. .inline选项显示小标题,这些小标题对于导航堆栈中的辅助视图,第三视图或后续视图很有用。
    1. .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所以界面可能会有一点异常:

示例1

只有将界面上滑后才会和设置了displayMode: .inline一样出现我们预期的界面:

示例2

呈现新视图

导航视图使用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
                        }
                )
        }
    }
}

如果您想要左边和右边按钮,只需传递leadingtrailing参数,如下所示:

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课程,该课程完全免费。

译自 The Complete Guide to NavigationView in SwiftUI

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容