Rust in Everything
#SwiftUI#Swift#iOS

SwiftUI 导航栏详解:从 NavigationView 到 NavigationStack 的自定义与进阶

上一节我们介绍了 SwiftUI 的列表(List)视图,ForEach和Identifiable协议。

这一节我们将详细介绍在 SwiftUI 中如何使用 NavigationView(iOS 16 前)和 NavigationStack(iOS 16 及更高版本) 来构建导航界面,同时探讨如何自定义导航栏的外观、返回按钮,以及使用 SwiftUI 提供的 toolbar 等高级用法。

在 iOS 应用开发中,导航界面是非常常见且重要的部分。无论是显示一系列数据还是管理不同的功能模块, 都少不了使用导航栏和导航视图来为用户提供分层次的浏览体验。 本文将详细介绍在 SwiftUI 中如何使用 NavigationView(iOS 16 前)和 NavigationStack(iOS 16 及更高版本) 来构建导航界面,同时探讨如何自定义导航栏的外观、返回按钮,以及使用 SwiftUI 提供的 toolbar 等高级用法。

目录

在绝大多数 iOS 应用中,用户都会和一个带有导航栏和列表的界面进行交互,通过点击列表项进入详情视图,再通过返回按钮回到上一级页面。在 SwiftUI 中,这种交互可以通过以下组件来完成:

  • NavigationView(在 iOS 16 之前使用)
  • NavigationStack(在 iOS 16 及更高版本中建议使用)
  • NavigationLink(用于在列表或其他视图中触发导航行为)
  • NavigationTitle / navigationBarTitle / toolbar 等修饰器,用于定制导航栏外观和功能

使用 NavigationStack 搭建导航视图

在 iOS 16(或更新版本)中,Apple 引入了新的 NavigationStack 组件来取代原有的 NavigationView。虽然 NavigationView 目前仍然可用,但建议使用 NavigationStack 以获得更好的未来兼容性。使用方式非常类似:

NavigationStack {
    List {
        ForEach(restaurants) { restaurant in
            BasicImageRow(restaurant: restaurant)
        }
    }
    .listStyle(.plain)
}

如果你在较早版本的 iOS 中使用 SwiftUI,可以使用:

NavigationView {
    List {
        ForEach(restaurants) { restaurant in
            BasicImageRow(restaurant: restaurant)
        }
    }
    .listStyle(.plain)
}

为导航视图添加标题

在 SwiftUI 中,可以使用修饰器 navigationTitle(或 navigationBarTitle)为导航栏添加标题,比如:

NavigationStack {
    List {
        ForEach(restaurants) { restaurant in
            BasicImageRow(restaurant: restaurant)
        }
    }
    .listStyle(.plain)
    .navigationTitle("Restaurants")
}

这样就可以为导航界面设置一个大标题(Large Title)。如果你希望在界面中展示小标题,可以使用 设置大标题还是小标题 章节所述的方法来自定义显示模式。

在实际项目中,我们通常需要在点击列表项后跳转到详情视图,展示更详细的信息。SwiftUI 提供了 NavigationLink 来实现这一功能。

举例来说,如果我们有一个 RestaurantDetailView 视图需要接收 restaurant 参数,可以这样做:

List {
    ForEach(restaurants) { restaurant in
        NavigationLink(destination: RestaurantDetailView(restaurant: restaurant)) {
            BasicImageRow(restaurant: restaurant)
        }
    }
}
.listStyle(.plain)

当用户点击列表中的某一行时,应用就会通过 NavigationLink 把对应的 restaurant 对象传递给详情视图进行渲染。

自定义导航栏外观

设置大标题还是小标题

在 SwiftUI 中,如果你想让导航栏以大标题的形式显示,可以使用 .navigationBarTitleDisplayMode(.automatic) 或者在 iOS 较早版本使用 .largeNavigationBarTitle。如果你想要小标题,则可以使用:

.navigationBarTitleDisplayMode(.inline)
  • .inline:小标题
  • .large:大标题
  • .automatic:系统自动决定

示例:

.navigationTitle("Restaurants")
.navigationBarTitleDisplayMode(.inline)

使用 UINavigationBarAppearance 修改字体和颜色

虽然 SwiftUI 的修饰器非常便捷,但它目前还不直接提供修改导航栏文字颜色和字体的原生 API。我们可以借助 UIKit 的 UINavigationBarAppearance 做到这一点。

在 SwiftUI 的 init() 方法中配置:

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:普通标题的样式
    通过为这三个属性(standardAppearance, scrollEdgeAppearance, compactAppearance)都赋值,可以保证导航栏在不同滚动状态下的样式一致。

修改返回按钮的图片及颜色

默认的返回按钮是一个蓝色的 chevron 图标。如果需要自定义返回按钮的图标,可以在 UINavigationBarAppearance 上调用 setBackIndicatorImage 方法。例如:

navBarAppearance.setBackIndicatorImage(
    UIImage(systemName: "arrow.turn.up.left"),
    transitionMaskImage: UIImage(systemName: "arrow.turn.up.left")
)

要修改返回按钮的颜色,可以在对应的 NavigationStack 或 NavigationView 中使用:

.tint(.black)

这样就可以将返回按钮的颜色改为黑色等你需要的颜色。

隐藏系统返回按钮并自定义返回按钮

除了通过 UIKit 接口修改返回按钮的图标,你还可以在 SwiftUI 层面隐藏系统返回按钮,然后使用一个自定义按钮来替代它,实现更多灵活的交互或视觉效果。

可以使用以下修饰器隐藏默认返回按钮:

.navigationBarBackButtonHidden(true)

然后在 .toolbar 中加入自己的按钮:

.toolbar {
    ToolbarItem(placement: .navigationBarLeading) {
        Button {
            dismiss()
        } label: {
            Text("\(Image(systemName: "chevron.left")) 自定义返回")
                .foregroundColor(.black)
        }
    }
}

这里使用了 SwiftUI 提供的 @Environment(\.dismiss) 来获取一个可以关闭当前视图的函数 dismiss(),只要在 iOS 15 或更高版本中就能使用。如果需要支持更早的版本,可以改用 @Environment(\.presentationMode) 并调用 presentationMode.wrappedValue.dismiss()。

实战案例:餐馆列表与详情视图

除了文章列表,我们也可以用同样的方式构建餐馆列表和详情视图。以下是一个餐馆列表的示例,它在 init() 中使用了 UINavigationBarAppearance 来自定义导航栏标题字体及颜色:

餐馆列表及自定义导航栏外观

struct ContentView: View {
  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
  }
  
  var body: some View {
    NavigationStack {
      List {
        ForEach(restaurants) { restaurant in
          NavigationLink(destination: RestaurantDetailView(restaurant: restaurant)) {
            BasicImageRow(restaurant: restaurant)
          }
        }
      }
      .listStyle(.plain)
      .navigationBarTitle("Restaurants")
      .navigationBarTitleDisplayMode(.automatic)
    }
  }
}

如上所示,我们把大标题的字体、颜色、大小都改为红色、ArialRoundedMTBold,并应用到不同的 appearance 属性上,实现大标题和小标题同样的风格。

自定义返回按钮及更多设置

在详情页面,我们可以隐藏系统默认的返回按钮并自定义一个带图标和文字的返回按钮,借助 SwiftUI 的 toolbar 修饰器:

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))

      Spacer()
    }
    .navigationBarBackButtonHidden(true)
    .toolbar {
      ToolbarItem(placement: .navigationBarLeading) {
        Button(action: {
          dismiss()
        }) {
          Text("\(Image(systemName: "chevron.left")) \(restaurant.name)")
            .foregroundColor(.black)
        }
      }
    }
  }
}

这样就可以让返回按钮既包含自定义的图标,也能携带文字说明,用户体验更友好。

实战案例:文章列表与详情视图

接下来让我们看一个完整的示例。假设我们有一个应用用来展示一系列文章,主界面是文章列表,详情界面则显示文章的全文内容。下面是一个示例项目的思路(下载示例工程可参考文末链接):

创建详情视图

我们先在 ArticleDetailView.swift 文件中创建详情视图:

struct ArticleDetailView: View {
    var article: Article

    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)")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                        .uppercased()
                }
                .padding(.bottom, 0)
                .padding(.horizontal)

                Text(article.content)
                    .font(.body)
                    .padding()
                    .lineLimit(1000)
                    .multilineTextAlignment(.leading)
            }
        }
    }
}

注意:为了在预览时不报错,可以在 #Preview 中传入一个示例的 Article 对象。

为列表添加导航功能

然后我们在 ContentView.swift 中,把列表嵌入到 NavigationStack 中, 并使用 NavigationLink 导向详情视图:

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")
        }
    }
}

这样就实现了从文章列表到文章详情的导航,并把相应的 article 传递给详情页。

去除 Disclosure Indicator

如果不想要默认列表右侧的>小箭头(disclosure indicator),可以使用一个小技巧: 在 ZStack 中叠放一个透明的 NavigationLink:

List(articles) { article in
    ZStack {
        ArticleRow(article: article)
        
        NavigationLink(destination: ArticleDetailView(article: article)) {
            EmptyView()
        }
        .opacity(0)
        .listRowSeparator(.hidden)
    }
}

这样就不会出现默认的指示箭头,同时也不影响点击跳转功能。

修复顶端空白区域并调节导航栏展示样式

如果你注意到详情视图顶部出现了空白区域,往往是因为导航栏默认采用大标题模式, 而你的详情视图一开始没有标题导致的。 我们可以通过设置:

.navigationBarTitleDisplayMode(.inline)

来使详情视图以小标题的方式出现,减少空白区域。

打造更优雅的自定义返回按钮

有时候我们想要在详情页中隐藏系统导航栏,自己做一个漂亮的自定义头部。 例如,我们可以将导航栏隐藏并使用 .toolbar 来放置一个返回按钮:

.navigationBarBackButtonHidden(true)
.toolbar {
    ToolbarItem(placement: .navigationBarLeading) {
        Button(action: {
            dismiss()
        }) {
            Image(systemName: "chevron.left.circle.fill")
                .font(.largeTitle)
        }
        .tint(.white)
    }
}
.ignoresSafeArea(.all, edges: .top)

同时,通过 ignoresSafeArea(.all, edges: .top) 可以让图片等内容扩展到安全区域之外, 为页面带来更沉浸式的观感。

总结

掌握 SwiftUI 中的导航界面(无论是 NavigationView 还是 NavigationStack)是构建多层级应用的核心。 通过配合 NavigationLink、toolbar、UINavigationBarAppearance,我们可以非常灵活地定制导航栏的 外观、隐藏和替换系统返回按钮,构建更富有设计感和交互性的界面。

虽然文中的示例数据大多是静态的,但这些技术同样适用于动态数据的场景。 理解了这一套导航方案后,你就能够轻松开发出带有导航功能的新闻、博客、社交、餐馆点评等各类 SwiftUI 应用。

完整示例代码

以下分别为文章案例和餐馆案例的完整示例代码,你可以直接复制到自己的项目中测试或进行二次开发。

餐馆案例完整代码

import SwiftUI

struct Restaurant: Identifiable {
  var id = UUID()
  var name: String
  var image: String
}

var restaurants = [
  Restaurant(name: "Cafe Deadend", image: "cafedeadend"),
  Restaurant(name: "Homei", image: "homei"),
  Restaurant(name: "Teakha", image: "teakha"),
  Restaurant(name: "Cafe Loisl", image: "cafeloisl"),
  Restaurant(name: "Petite Oyster", image: "petiteoyster"),
  Restaurant(name: "For Kee Restaurant", image: "forkeerestaurant"),
  Restaurant(name: "Po's Atelier", image: "posatelier"),
  Restaurant(name: "Bourke Street Bakery", image: "bourkestreetbakery"),
  Restaurant(name: "Haigh's Chocolate", image: "haighschocolate"),
  Restaurant(name: "Palomino Espresso", image: "palominoespresso"),
  Restaurant(name: "Upstate", image: "upstate"),
  Restaurant(name: "Traif", image: "traif"),
  Restaurant(name: "Graham Avenue Meats And Deli", image: "grahamavenuemeats"),
  Restaurant(name: "Waffle & Wolf", image: "wafflewolf"),
  Restaurant(name: "Five Leaves", image: "fiveleaves"),
  Restaurant(name: "Cafe Lore", image: "cafelore"),
  Restaurant(name: "Confessional", image: "confessional"),
  Restaurant(name: "Barrafina", image: "barrafina"),
  Restaurant(name: "Donostia", image: "donostia"),
  Restaurant(name: "Royal Oak", image: "royaloak"),
  Restaurant(name: "CASK Pub and Kitchen", image: "caskpubkitchen")
]


struct ContentView: View {
  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
  }
  
  var body: some View {
    
    NavigationStack {
      List {
        ForEach(restaurants) { restaurant in
          NavigationLink(destination: RestaurantDetailView(restaurant: restaurant)) {
            BasicImageRow(restaurant: restaurant)
          }
        }
      }
      .listStyle(.plain)
      .navigationBarTitle("Restaurants")
      .navigationBarTitleDisplayMode(.automatic)
    }
  }
}

struct BasicImageRow: View {
  var restaurant: Restaurant
  var body: some View {
    HStack {
      Image(restaurant.image)
        .resizable()
        .frame(width: 40, height: 40)
        .cornerRadius(5)
      Text(restaurant.name)
    }
  }
}

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))
      
      Spacer()
    }
    .navigationBarBackButtonHidden(true)
    .toolbar {
      ToolbarItem(placement: .navigationBarLeading) {
        Button(
          action: {
            dismiss()
          },
          label: {
            Text("\(Image(systemName: "chevron.left")) \(restaurant.name)")
              .foregroundColor(.black)
          }
        )
      }
    }
  }
}


#Preview {
  ContentView()
}

文章案例完整代码

import SwiftUI

struct Article: Identifiable {
    var id = UUID()
    var title: String
    var author: String
    var rating: Int
    var excerpt: String
    var image: String
    var content: String
}

struct ContentView: View {
  var body: some View {
    NavigationStack{
      List(articles) { article in
        ZStack {
          ArticleRow(article: article)
          
          NavigationLink(destination: ArticleDetailView(article: article)) {
            EmptyView()
          }
          .opacity(0)
          .listRowSeparator(.hidden)
        }
      }
      .listStyle(.plain)
      .navigationTitle("Your Reading")
    }
  }
}


struct ArticleDetailView: View {
  @Environment(\.dismiss) var dismiss
  var article: Article
  
  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(article.author)
            .font(.subheadline)
            .foregroundColor(.secondary)
        }
        .padding(.bottom, 0)
        .padding(.horizontal)
        
        Text(article.content)
          .font(.body)
          .padding()
          .lineLimit(1000)
          .multilineTextAlignment(.leading)
      }
    }
    .navigationBarTitleDisplayMode(.inline)
    .ignoresSafeArea(.all, edges: .top)
    .navigationBarBackButtonHidden(true)
    .toolbar {
      ToolbarItem(placement: .navigationBarLeading) {
        Button(action: {
          dismiss()
        }) {
          Image(systemName: "chevron.left.circle.fill")
            .font(.largeTitle)
        }
        .tint(.white)
      }
    }
  }
}

struct ArticleRow: View {
  var article: Article
  
  var body: some View {
    VStack(alignment: .leading, spacing: 6) {
      Image(article.image)
        .resizable()
        .aspectRatio(contentMode: .fit)
        .cornerRadius(5)
      
      Text(article.title)
        .font(.system(.title, design: .rounded))
        .fontWeight(.black)
        .lineLimit(3)
        .padding(.bottom, 0)
      
      Text("By \(article.author)".uppercased())
        .font(.subheadline)
        .foregroundColor(.secondary)
        .padding(.bottom, 0)
      
      HStack(spacing: 3) {
        ForEach(1...(article.rating), id: \.self) { _ in
          Image(systemName: "star.fill")
            .font(.caption)
            .foregroundColor(.yellow)
        }
      }
      
      Text(article.excerpt)
        .font(.subheadline)
        .foregroundColor(.secondary)
    }
  }
}

#Preview {
  ContentView()
}

希望通过以上示例,你能够更好地理解 SwiftUI 中导航栏的使用和定制方式。 在实际项目中,你可以自由组合这些技巧,让应用拥有更加灵活且美观的导航体验。

如果你对本文中的示例或 SwiftUI 其他特性有任何问题或想法,欢迎在评论区留言、或与社区小伙伴们交流。 祝你在 SwiftUI 开发之路上一路顺利!

评论区

登录后即可发表评论

加载评论中…

💬 关联讨论

发起讨论
加载中...