精通 SwiftUI - iOS 17 版

第 11 章
運用導覽 UI 與導覽列客製化

在大多數的 App 中(尤其是以內容為基礎的 App),你應該體驗過導覽介面。這類型的 UI 通常有一個包含資料清單的導覽列,並且它讓使用者點擊內容時導覽至細節視圖。

在 UIKit 中,我們可以使用 UINavigationController 來實作這類型的介面。在 SwiftUI 中,Apple 稱其為「NavigationView」。由 iOS 16 開始,這個 「NavigationView」 以 「NavigationStack」取替。在本章中,我詳細解說導覽 UI 的實作,並教你如何進行一些自定義。和往常一樣,我們將進行幾個範例專案,以讓你獲得一些使用 NavigationStack 的實務經驗。

圖 11.1. 範例專案的導覽介面
圖 11.1. 範例專案的導覽介面

準備起始專案

讓我們開始並實作一個我們之前使用導覽 UI 建立的範例專案。那麼,首先至下列網址下載起始專案:https://www.appcoda.com/resources/swiftui5/SwiftUINavigationListStarter.zip 。下載後開啟專案,並看一下預覽,你應該對於這個範例 App 非常熟悉,它只顯示一個餐廳列表,如圖 11.2 所示。

圖 11.2. 起始專案應顯示一個簡單的清單視圖
圖 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.3. 基本的導覽UI
圖 11.3. 基本的導覽UI

至目前為止,我們只是在清單視圖中加入一個導覽列。我們通常使用導覽介面來讓使用者導覽至細節視圖,以顯示所選項目的細節。對於此範例,我們將建立一個簡單的細節視圖,以顯示餐廳的大圖,如圖 11.4 所示。

圖 11.4. 內容視圖與細節視圖
圖 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. 執行 App 來測試導覽
圖 11.5. 執行 App 來測試導覽

在畫布中,你應該注意到每列資料皆已加入了一個揭露圖示。如圖 11.5 所示,你應該能夠在選擇其中一間餐廳後,導覽至細節視圖。另外,你可以點擊「返回」(Back )按鈕來導覽回內容視圖。整個導覽由 NavigationStack 自動渲染。

自訂導覽列

首先,我們來討論導覽列的顯示模式。預設情況下,導覽列是設定為顯示大標題,但當你向上滾動清單時,導覽列會變小,這是 Apple 導入「大標題」(Large Title )導覽列後的預設行為。

如果你想要使導覽列更小型,並禁用大標題,你可以在 navigationBarTitle 修飾器之下加入navigationBarTitleDisplayMode 修飾器:

.navigationBarTitleDisplayMode(.inline)

這個參數控制導覽列的外觀,不論它應顯示大標題導覽列還是小型導覽列,而預設是設定為 .automatic,即表示是使用大標題。在上列的程式碼中,我們將其設定為 .inline,即表示 iOS 使用小型導覽列,如圖 11.6 所示。

圖 11.6. 設定顯示模式為「.inline」來使用小型導覽列
圖 11.6. 設定顯示模式為「.inline」來使用小型導覽列

現在,我們將顯示模式改為 .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 後,將其它指定給三個外觀屬性,包括standardAppearancescrollEdgeAppearancecompactAppearance。如前所述,如果需要的話,你可以為 scrollEdgeAppearancecompactAppearance 建立及指定一個單獨的外觀物件。

圖 11.7. 對大尺寸標題與標準尺寸標題變更字型與顏色
圖 11.7. 對大尺寸標題與標準尺寸標題變更字型與顏色

「返回」按鈕的圖片與顏色

導覽視圖的「返回」(Back )按鈕預設為藍色,其使用V 形圖示(chevron icon )來表示「返回」,如圖 11.8 所示。透過使用 UINavigationBarAppearance API,你可以自訂顏色、甚至是「返回」按鈕的指示器圖片。

圖 11.8. 標準的「返回」按鈕
圖 11.8. 標準的「返回」按鈕

我們來看這個自定義是否如何工作的。要變更指示器的圖片,你可以呼叫 setBackIndicatorImage 方法,並提供自己的 UIImage。這裡,我設定系統圖片為 arrow.turn.up.left

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

對於「返回」按鈕的顏色,你可以透過設置 tint 屬性來更改它,如下所示:

NavigationStack {
  .
  .
  .
}
.tint(.black)

如果你已經進行變更,則執行該 App 來快速測試,「返回」按鈕應該如圖 11.9 所示。

圖 11.9. 自訂「返回」按鈕的外觀
圖 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

之後,你可以利用以下程式碼呼叫presentationModedismiss() 函數:

presentationMode.wrappedValue.dismiss()

你在預覽畫布再測試 App,並選取其中一家餐廳,你會見到一個帶有餐廳名的返回按鈕。點擊返回按鈕,視圖將導覽回主畫面。

作業

為了確認你理解如何建立導覽UI,這裡有一個作業。首先,至下列網址下載起始專案:

https://www.appcoda.com/resources/swiftui5/SwiftUINavigationStarter.zip。開啟專案後,你將看到一個顯示文章清單的範例App。

這個專案與你之前建立的專案非常類似,主要的差異是 Article.swift 的導入。這個檔案儲存了 articles 陣列,而該陣列附有一些範例資料。如果你仔細檢視 Article 結構,它現在有一個用於儲存完整文章的 content 屬性。

你的任務是將清單嵌入導覽視圖,並建立細節視圖。當使用者點擊內容視圖中其中一篇文章時,它將導覽至顯示完整文章的細節視圖,如圖 11.10 所示。我將在下一節中與你討論解決方案,但請你盡力找出自己的解決方案。

圖11.10. 為閱讀 App 建立導覽 UI
圖11.10. 為閱讀 App 建立導覽 UI

建立細節視圖

你完成作業了嗎?細節視圖比我們之前建立的視圖更複雜,我們來看看如何建立它。

為了讓程式碼更易編寫,我們將為它建立一個單獨的檔案,而不是在 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 來包裹所有的視圖,以啟用可滾動的內容。我不會逐行說明程式碼,我相信你應該了解 TextImageVStack 的運作方式,不過我想強調的修飾器是 Group,這個修飾器可以讓你將多個視圖群組在一起,並使用某個設定。在上列的程式碼中,我們需要對兩個 Text 視圖使用特定的間距設定。為了避免程式碼重複,我們將兩個視圖群組在一起,並使用間距。

現在,我們已經完成了細節視圖的佈局,但是你應該會在 Xcode 內看到一個錯誤,指出 #Preview 的問題。而預覽無法正常運作,是因為我們在 ArticleDetailView 中加入了article 屬性,因此你需要在預覽中傳送一個範例文章。更新 #Preview 來修正錯誤,如下所示:

#Preview {
    ArticleDetailView(article: articles[0])
}

這裡,我們只選擇 articles 陣列中的第一篇文章來預覽。如果想要預覽其他文章,你可以將其更改為其他值。當你變更後,預覽畫布應會正確渲染細節視圖,如圖 11.11 所示。

圖 11.11. 用於顯示文章的細節視圖
圖 11.11. 用於顯示文章的細節視圖

我們再多嘗試一件事。由於這個視圖將嵌入至 NavigationView中,因此你可以修改預覽程式碼,來預覽它在預覽介面的外觀:

#Preview {
    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 ),這裡顯示揭示指示器有點奇怪,我們可以禁用它嗎?第二個問題是,在細節視圖中精選圖片的上方出現空白區域。我們來逐一討論這些問題。

圖 11.12. 目前設計中的兩個問題
圖 11.12. 目前設計中的兩個問題

SwiftUI 並沒有為開發者提供禁用或隱藏揭示指示器的選項。為了解決這個問題,我們不直接將 NavigationLink 應用於文章列,而是建立一個具有兩層的 ZStack。現在更新 ContentViewNavigationView 如下:

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 所示。

圖 11.13 標頭中的空白區域
圖 11.13 標頭中的空白區域

圖片上方會出現空白區域的原因是導覽列的緣故。這個空白區域實際上是一個帶有空白標題的大尺寸導覽列,當App 從內容視圖導覽至細節視圖時,導覽列會變成標準尺寸列。因此,要修復這個問題,我們需要做的是明確指定使用標準尺寸導覽列。

ScrollView 的括號後,插入下列這行程式碼:

.navigationBarTitleDisplayMode(.inline)

透過將導覽列設定為 inline 模式後,空白區域將被最小化,現在你可回到 ContentView.swift 來再次測試App,細節視圖現在看起來好多了。

帶有客製化「返回」按鈕的精緻 UI

雖然你可使用內建的屬性來自訂「返回」按鈕指示器圖片,有時你可能想要建立一個客製化「返回」按鈕來導覽回內容視圖。問題是如何透過編寫程式碼來完成呢?

在最後一個小節中,我要介紹如何透過隱藏導覽列及建立自己的「返回」按鈕,來建立一個更精緻的細節視圖。首先,我們看一下如圖 11.14 所示的最終設計,看起來不錯吧?

圖 11.14. 經修訂的細節視圖
圖 11.14. 經修訂的細節視圖

要佈局這個畫面,我們必須要解決兩個問題:

  1. 如何將滾動視圖延伸到畫面頂部?
  2. 如何建立一個客製化「返回」按鈕,並編寫程式碼來觸發導覽?

iOS 有一個「安全區域」(safe area )的觀念,用於輔助視圖的佈局。安全區域可幫你將視圖放置於介面的可見部分,例如:安全區域防止視圖隱藏了狀態列。若是你的 UI 導入了導覽列,則會遮擋導覽列。

![圖 11.15 安全區域](images/navigation/swiftui-navigation-15.jpg)
![圖 11.15 安全區域](images/navigation/swiftui-navigation-15.jpg)

要放置超出安全區域的內容,你可以使用名為 ignoresSafeArea 修飾器。對於我們的專案,由於我們想要滾動視圖超出安全區域的頂部邊緣,則可編寫修飾器如下:

.ignoresSafeArea(.all, edges: .top)

這個修飾器接收其他值,如 .bottom.leading。如果你想要忽略整個安全區域,則可以直接使用.ignoresSafeArea()。透過將這個修飾器加到 ScrollView,我們可以隱藏導覽列,並實現一個視覺上賞心悅目的細節視圖。

圖 11.16. 應用這些修飾器至滾動視圖
圖 11.16. 應用這些修飾器至滾動視圖

現在談到關於建立自己的「返回」按鈕的第二個問題,這個問題比第一個問題更棘手。下面是我們要實作的內容:

  1. 隱藏原來的「返回」按鈕。
  2. 建立一個一般的按鈕,然後將其指定為導覽列的左側按鈕。

為了隱藏「返回」按鈕,SwiftUI 提供一個名為 navigationBarBackButtonHidden 的修飾器。你只需將其值設定為 true,即可隱藏「返回」按鈕:

.navigationBarBackButtonHidden(true)

當隱藏「返回」按鈕後,你可以使用自己的按鈕來替代它。toolbar 修飾器允許你配置導航欄項目。 在閉包中,我們使用ToolbarItem自訂後退按鈕,並將該按鈕指定為導航欄的左按鈕。 以下是相關程式碼:

.toolbar {
    ToolbarItem(placement: .navigationBarLeading) {
        Button(action: {
            // 導覽至前一個畫面
        }) {
            Image(systemName: "chevron.left.circle.fill")
                .font(.largeTitle)
        }
        .tint(.white)
    }
}

你可以將上述的修飾器加到 ScrollView。當修改生效後,你應該會在預覽畫布中看到我們自己的客製化「返回」按鈕,如圖 11.17 所示。

圖 11.17. 建立我們自己的「返回」按鈕
圖 11.17. 建立我們自己的「返回」按鈕

你可能發現按鈕的 action 閉包被留空。「返回」按鈕已經佈局得不錯了,但問題是它不能運作。

原來的「返回」按鈕是由 NavigationView 渲染,可以自動導覽回前一個畫面。問題來了,我們該如何編寫程式碼來觸發這個操作呢?感謝 SwiftUI 框架所內建的環境值(environment value ),你可以引用一個名為dismiss 環境綁定(environment binding ),來導覽至前一個畫面。

現在,在 ArticleDetailView 宣告一個 dismiss 變數來取得環境值:

@Environment(\.dismiss) var dismiss

接下來,在我們的客製化「返回」按鈕的 action 中,插入下列這行程式碼:

dismiss()

這裡,我們呼叫 dismiss 方法,以在點擊「返回」按鈕時解除細節視圖。現在,你可以執行 App 並再次測試它,你應該能夠在內容視圖與細節視圖之間進行導覽。

本章小結

導覽 UI 在行動 App 中非常常見,理解我在本章所介紹的內容非常重要。如果你完全理解了內容,即使資料是靜態的,你也可以建立一個基於內容的簡單 App。

在本章所準備的範例檔中,有完整的專案可供下載:

要進一步學習導覽視圖,你也可以參考下列 Apple 所提供的文件: