自定义你的 NavigationStack 外观

了解如何使用 SwiftUI 提供的一系列导航相关的修饰器,来自定义你的导航栏外观,创建更独特的外观。

自定义你的 NavigationStack 外观

在之前的这篇文章中,我们详细介绍了如何使用 iOS16 上新的 NavigationStack 组件添加导航:

使用 SwiftUI 的 NavigationStack 组件进行页面导航
了解如何在 iOS 16 或以上版本中,使用 SwiftUI 最新的 NavigationStack 组件进行页面导航,并学习通过编程的方式控制导航。

这篇文章,我们主要讲解如何使用 Navigation 相关的修饰器,来自定义导航栏的外观。

在 .sheet 中显示导航栏

使用 .sheet 打开的模态窗口,默认不会显示 NavigationView 的导航栏。可以通过在 .sheet 中使用 NavigationView 包裹子组件,从而实现加载导航栏:

struct ContentView: View {
    @State var showSheet = false

    var body: some View {
        Button("Click"){
            self.showSheet.toggle()
        }
        .sheet(isPresented: $showSheet) {
           NavigationView {    // 在这里添加。
            DetailView()
           }
        }
    }
}


struct DetailView: View {
    var body: some View {
         NavigationLink(destination: ContentView3()){
             Text("Click Here")
         }
         .navigationBarTitle("Bar Title", displayMode: .inline)
    }
}

隐藏顶部导航栏(navigationBarHidden)

默认情况下,当我们向下滑动时,NavigationStack 会激活顶部导航栏显示,在导航栏上会默认显示「返回按钮」,如果我们通过 .navigationBarItems 添加了其他按钮,也会在这里显示:

在某些情况下,我们可能并不需要导航栏。比如设置了 .navigationTransition() 效果之后,可以直接侧滑或下滑来返回,因此并不需要这个返回按钮。

此时,可以使用.navigationBarHidden()修饰器来隐藏导航栏:

NavigationStack {
    MyView()
        .navigationBarHidden(true)
}

在某些情况下(也许是 BUG),需要同时设置.navigationBarTitle才能使.navigationBarHidden正常工作:

NavigationStack {
    MyView()
        .navigationBarTitle("")
        .navigationBarHidden(true)
}

隐藏导航栏背景(toolbarBackground)

默认情况下,导航栏使用带有轻微毛玻璃效果的背景,并有一条明显的分割线。

如果希望修改导航栏背景颜色,或者隐藏背景颜色,可以使用新的 .toolbarBackground 修饰器来实现。

toolbarBackground 可以用于导航栏(navigationBar)或者标签栏(TabBar)。要隐藏导航栏背景,像下面这样使用:

NavigationStack {
    MyView()
        .toolbarBackground(.hidden, for: .navigationBar)
}

修改导航栏背景颜色(toolbarBackground)

.toolbarBackground 不仅可以控制导航栏背景的显示/隐藏,还能修改背景的颜色或材质。

默认情况下,导航栏使用 .regularMaterial 材质,我们可以修改使用更浅的材质:

NavigationStack {
  MyView()
      .toolbarBackground(.ultraThinMaterial, for: .navigationBar)
}

或者为背景添加颜色:

NavigationStack {
  MyView()
    .toolbarBackground(.green.gradient.opacity(0.1), for: .navigationBar)
}

定制/隐藏顶部导航栏返回按钮(navigationBarBackButtonHidden)

NavigationView 默认会提供一个带箭头的返回按钮,但这个按钮是可以定制的。

  • 使用 navigationBarBackButtonHidden(true) 隐藏默认的返回按钮,
  • 使用 navigationBarItems 添加自定义返回按钮。
NavigationStack {
    MyView()
        .navigationBarBackButtonHidden(true)
        .navigationBarItems(
            leading: HStack {
                Button(action: {
                    dismiss()  // 使用 Environment 的 dismiss 返回上一页
                }) {
                    HStack {
                        Image(systemName: "chevron.left")  // 自定义返回图标
                        Text("返回")  // 自定义返回文字
                    }
                }
            }
        )
}

记住,这两个修饰器应该添加到你的目标页面上。如果 MyView 是在一个子组件,而你希望在这个子组件页面上隐藏返回按钮,请将它添加到子页面上。

通过自定义导航栏按钮,你可以实现自己设计的返回按钮。

但这样做,你会失去默认的左滑返回功能。

在隐藏顶部导航栏的情况下保留返回按钮

结论是:

  • 隐藏导航栏,同时保留返回按钮,使用 SwiftUI 无法做到(截止 2024 年 10 月)
  • 可以使用 .navigationTransition() 效果,达到隐藏导航栏之后,使用侧滑或下滑返回功能来返回上一级。
  • 或者使用 Zstack 自定义一个返回 Button,但是会失去屏幕左侧侧滑返回的功能

因为所有 .navigationBarItems 都必须依托在导航栏上存在。如果隐藏了导航栏,则自定义添加的 .navigationBarItems 也会无法显示。

也就是说下面这个代码是无效的:

NavigationStack {
    MyView()
        .navigationBarHidden(true)
        .navigationBarItems(
            leading: HStack {
                Button(action: {
                    dismiss()  // 使用 Environment 的 dismiss 返回上一页
                }) {
                    HStack {
                        Image(systemName: "chevron.left")  // 自定义返回图标
                        Text("返回")  // 自定义返回文字
                    }
                }
            }
        )
}

并且,如果隐藏了导航栏,从屏幕左侧优化返回功能也会失效。

下面这两个帖子探讨了这个问题:

SwiftUI: Hide Nav Bar On One View But Keep Swiping Back Gesture
by u/thebrandontyler in iOSProgramming
Hide the Navigationbar + the space around, but not the Back Button (SwiftUI)
by u/Boothosh in iOSProgramming

设置返回按钮语言(英文/中文)

默认情况下,NavigationView 的返回按钮文字是由系统根据当前设备的 语言设置应用中的导航层级标题 自动决定的。但有时候会出现,设备的系统语言设置为中文,但返回按钮显示为 “Back” 英文的情况。

解决方案是,确保项目支持中文本地化,通过以下步骤实现:

  1. 在 Xcode 中,选择项目文件 -> “Info” -> “Localizations”,并确保选择了 Chinese (Simplified) 或 Chinese (Traditional)。
  1. 为 Localizable.strings 文件添加中文翻译(可选)。

设置页面标题?

可以通过使用 .navigationTitle 修饰符来设置页面的标题。这个修饰符可以在视图中声明导航栏的标题文字。

struct GuideListView: View {
    var body: some View {
        NavigationView {
            VStack {
                // 你的页面内容
            }
            .navigationTitle("设置") // 设置页面标题
        }
    }
}

还可以使用.navigationBarTitleDisplayMode() 修饰符来调整标题的显示模式:

  • .large:显示为大标题,适合用作页面主标题。
  • .inline:显示为小标题,与导航栏在一行中,适合用作嵌套页面的标题。

隐藏顶部安全空间

在视图安全区域边缘添加视图(SafeAreaInset)

常见问题

避免在 Tabview 外部使用 NavigationStack

Apple 不建议将 NavigationStack 放在 TabView 的外部。因为这样做可能会导致导航状态在不同的标签页之间共享,进而引发导航冲突和意外行为。

NavigationStack 管理其内部视图的导航状态。如果将它包裹在 TabView 外部,那么所有标签页将共享同一个导航栈。当在一个标签页中导航到某个视图,然后切换到另一个标签页时,导航栈的状态仍然保留。这可能导致导航路径混乱,甚至在不相关的标签页中显示错误的视图。

NavigationStack 移动到每个标签页的内容视图中

避免将 .navigationDestination() 修饰器放在 List 或 LazyStack 中

根据 SwiftUI 的最佳实践,不应在懒加载容器(如 ListLazyVStack)内使用 navigationDestination 修饰符

Do not put a navigation destination modifier inside a "lazy” container, like `List` or `LazyVStack`. These containers create child views only when needed to render on screen. Add the navigation destination modifier outside these containers so that the navigation stack can always see the destination. There's a misplaced `navigationDestination(for:destination:)` modifier for type `SettingsNavigation`. It will be ignored in a future release.

这个警告指出,在懒加载容器中使用 navigationDestination 会导致导航系统无法正确识别目的地视图。

在 iOS17 上,点击导航导致应用冻结

在 iOS 17 上,点击导航按钮可能会导致应用冻结。

以下代码在 iOS 18 上运行正常,但在 iOS 17 上点击 Button 后会出现应用卡死的问题:

import SwiftUI

struct NavigationStackDemoView: View {
    @State private var showSettingsView = false

    var body: some View {
        NavigationStack {
            VStack {
                Button(action: {
                    showSettingsView = true
                }) {
                    Image(systemName: "gearshape")
                        .resizable()
                        .frame(width: 50, height: 50)
                        .foregroundColor(.blue)
                    Text("Go to Settings")
                        .font(.headline)
                        .foregroundColor(.blue)
                }
            }
            .navigationDestination(isPresented: $showSettingsView) {
                SettingsView()
            }
            .navigationTitle("Main View")
        }
    }
}

目前原因尚不明确,解决方法是在代码中将 .navigationDestination 修饰符尽量靠近触发按钮的位置。例如:

import SwiftUI

struct NavigationStackDemoView: View {
    @State private var showSettingsView = false

    var body: some View {
        NavigationStack {
            VStack {
                Button(action: {
                    showSettingsView = true
                }) {
                    Image(systemName: "gearshape")
                        .resizable()
                        .frame(width: 50, height: 50)
                        .foregroundColor(.blue)
                    Text("Go to Settings")
                        .font(.headline)
                        .foregroundColor(.blue)
                }
                .navigationDestination(isPresented: $showSettingsView) {
                    SettingsView()
                }
            }
            .navigationTitle("Main View")
        }
    }
}