iOS 17 App 程式設計實戰心法(SwiftUI)

第 11 章
運用導覽視圖

Just build something that you'd want to use today, not something you think people would use somehow.

– Paul Graham

首先,什麼是「導覽視圖」(Navigation View)?和清單視圖類似,導覽視圖是 iOS App 中常用的 UI 元件,它們提供用來顯示分層內容的階層介面。查看預先安裝的相片 App、YouTube 以及聯絡人,它們都利用導覽視圖以分層方式顯示內容。通常,你會將導覽視圖與一堆清單視圖結合,來為你的 App 建立複雜的介面,但請務必注意,這並不表示你必須同時使用兩者,導覽視圖可以和任何類型的視圖一起運用。

建立導覽視圖

我們回到 FoodPin 專案 (http://www.appcoda.com/resources/swift59/swiftui-foodpin-list-deletion.zip) 並開啟 RestaurantListView. swift 檔。

按住 control 鍵並點選 RestaurantListView 中的 List,在內容選單中選擇「Embed...」,然後變更預設容器(Container )為「NavigationStack」,如圖 11.1 所示。在 iOS 中,你使用 NavigationStack 來建立導覽視圖。

圖 11.1. 將清單視圖嵌入導覽視圖
圖 11.1. 將清單視圖嵌入導覽視圖

要設定導覽列的標題,則在導覽堆疊(Navigation Stack )中加入 .navigationTitle 修飾器。更改後的程式碼如下所示:

NavigationStack {
    List {
        .
        .
        .
    }

    .listStyle(.plain)

    .navigationTitle("FoodPin")
    .navigationBarTitleDisplayMode(.automatic)
}

或者,你可以使用 .navigationBarTitleDisplayMode 修飾器來設定導覽列(Navigation Bar)的顯示模式。透過將其設定為「.automatic」,你可以讓 iOS 確定導覽列的適當大小; 如果你想要固定導覽列的大小,則可以將其設定為「.large」或「.inline」。

完成變更後,預覽應該會渲染導覽視圖,App 使用者介面的整體外觀基本上保持不變, 除了你現在應該會看到一個帶有大標題的導覽列,如圖 11.2 所示。如果你執行 App 並滾動清單,導覽列會自動最小化,這行為說明了 .automatic 模式的工作原理。

圖 11.2. 帶有大標題的導覽視圖
圖 11.2. 帶有大標題的導覽視圖

新增餐廳細節視圖

接下來,我們結合餐廳的細節視圖(Detail View ),當使用者點擊任何項目時,App 會導覽到另一個顯示餐廳細節的視圖,圖 11.3 顯示了預期的 UI。

圖 11.3. 細節視圖的 UI
圖 11.3. 細節視圖的 UI

我們會建立一個新檔案,並在其中實作細節視圖。在專案導覽器中的「View」資料夾上按右鍵,並選擇「New File...」,然後選取「SwiftUI View」模板,將檔案命名為「RestaurantDetailView.swift」。

細節視圖是設計用來顯示餐廳資訊,因此我們先宣告一個變數來存放餐廳物件:

var restaurant: Restaurant

當你新增變數後,Xcode 會在 #Previews 巨集中顯示錯誤。我們需要更新 RestaurantDetail View 的初始化,並傳送一個餐廳範例給它,如下所示:

RestaurantDetailView(restaurant: Restaurant(name: "Cafe Deadend", type: "Cafe", location: "Hong Kong", image: "cafedeadend", isFavorite: true))

現在我們來實作細節視圖的佈局。我們從背景圖片開始如何呢?你應該已經知道如何使用 Image 視圖來載入圖片,因此替換 body 變數如下:

var body: some View {
    Image(restaurant.image)
        .resizable()
}

透過套用 resizable 修飾器,圖片會被拉伸來填滿視圖,但是它不會保持長寬比。除此之外,還有一個問題,即圖片沒有完全填滿整個螢幕,如圖 11.4 所示。

Figure 11-4. The UI of the detail view
Figure 11-4. The UI of the detail view

透過套用 resizable 修飾器,圖片會被拉伸來填滿視圖,但是它不會保持長寬比。除此之外,還有一個問題,即圖片沒有完全填滿整個螢幕,如圖 11.4 所示。

Image(restaurant.image)
    .resizable()
    .scaledToFill()
    .frame(minWidth: 0, maxWidth: .infinity)
    .ignoresSafeArea()

frame 修飾器用來確定圖片框的大小;將值設定為 .infinity,表示圖片應占滿螢幕的整個寬度,如圖 11.5 所示。

圖 11.5. 使用scaleToFill 縮放圖片
圖 11.5. 使用scaleToFill 縮放圖片

好的,我們已經實作了背景圖片,那我們如何將餐廳資訊框覆蓋在圖片之上呢?答案是使用堆疊視圖。

到目前為止,我已經向你介紹了 HStack 與 VStack,不過 SwiftUI 中還有另一種名為「ZStack」的堆疊視圖,透過使用 ZStack,你可以輕鬆將視圖覆蓋在另一個視圖之上。

現在更新 body 中的程式碼如下:

ZStack {
    Image(restaurant.image)
        .resizable()
        .scaledToFill()
        .frame(minWidth: 0, maxWidth: .infinity)
        .ignoresSafeArea()

    Color.black
        .frame(height: 100)
        .opacity(0.8)
        .clipShape(RoundedRectangle(cornerRadius: 20))
        .padding()
}

要實現這種覆蓋效果,我們將 Image 視圖封裝在 ZStack 中,並引入一個新的 Color 視圖。在 ZStack 中的視圖順序決定了它們的分層,在這種情況下,Color 視圖會放置在 Image 視圖之上以建立所需的覆蓋效果。

對於 Color 視圖,我們指定黑色並將其框的高度設定為「100 點」。為了實現半透明的外觀,我們應用 opacity 修飾器,並將其值設定為「0.8」,變更完成後,預覽應該會在螢幕中央顯示一個圓角矩形,如圖 11.6 所示。

圖 11.6. 在背景圖片上疊加一個圓角矩形
圖 11.6. 在背景圖片上疊加一個圓角矩形

接下來,我們如何將矩形框移到螢幕頂部呢?與 HStack(或VStack )類似,ZStack 帶有 alignment 參數,你可以更改 ZStack 的初始化如下:

ZStack(alignment: .top) {
  .
  .
  .
}

透過設定 alignment 的值為「.top」,矩形框會移動到螢幕頂部,如圖 11.7 所示。

圖 11.7. 更改 ZStack 的對齊
圖 11.7. 更改 ZStack 的對齊

要顯示餐廳資訊(包括名稱、類型與位置),我們可以將 overlay 修飾器加到 Color 視圖:

Color.black
    .frame(height: 100)
    .opacity(0.8)
    .clipShape(RoundedRectangle(cornerRadius: 20))
    .padding()
    .overlay {
        VStack(spacing: 5) {
            Text(restaurant.name)
            Text(restaurant.type)
            Text(restaurant.location)
        }
        .font(.system(.headline, design: .rounded))
        .foregroundStyle(.white)
    }

我們使用 overlay 修飾器來覆蓋 Color 視圖中的內容。為了顯示餐廳資訊,我們使用 VStack 來顯示餐廳名稱、類型與位置,如圖 11.8 所示。

圖 11.8.  使用 overlay 修飾器來覆蓋內容
圖 11.8.  使用 overlay 修飾器來覆蓋內容

從一個視圖導覽到另一個視圖

現在我們已經建立了細節視圖,下一步是啟用從清單視圖到細節視圖的導覽。要實現此目的,則開啟RestaurantListView.swift 檔,並修改 RestaurantListView 結構。使用 NavigationLink 包裹 BasicTextImageRow 視圖,如下所示:

NavigationLink(destination: RestaurantDetailView(restaurant: restaurants[index])) {
    BasicTextImageRow(restaurant: $restaurants[index])
}

為了啟用清單視圖中所有項目的導覽,我們利用 NavigationLink 元件。NavigationLink 的 destination 參數指定導覽的目標視圖, 在本例中, 我們將其設定為最近建立的 RestaurantDetailView,如圖 11.9 所示。

執行 App 來進行測試,點擊任何一間餐廳,將會導覽到細節視圖。

圖 11.9. 使用 NavigationLink 來啟用導覽
圖 11.9. 使用 NavigationLink 來啟用導覽

使用色調

預設上,「返回」按鈕顯示為藍色,如果你想要將其變更為其他顏色,可以將 .tint 修飾器加到 NavigationStack,並指定你喜愛的顏色。以下是一個例子:

NavigationStack {
  .
  .
  .
}
.tint(.white)

自訂返回按鈕

系統會自動產生「返回」按鈕,該按鈕由由朝左的V 形圖示和原始視圖的標題(本例中是 FoodPin )組成在 SwiftUI 中,自訂「返回」按鈕的一種方式是建立我們自己的按鈕, 例如:如果我們想在「返回」按鈕中顯示餐廳名稱,該怎麼做呢?

Figure 11-10. Customizing the back button
Figure 11-10. Customizing the back button

你可以將下列的修飾器加到 RestaurantDetailView 的 ZStack 視圖中:

ZStack(alignment: .top) {

  .
  .
  .

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

為了隱藏原始的「返回」按鈕,我們使用 navigationBarBackButtonHidden 修飾器,然後我們使用toolbar 修飾器來建立「返回」按鈕的自訂版本。在 toolbar 修飾器中,我們定義一個 ToolbarItem 充當按鈕,顯示餐廳名稱,且我們將 ToolbarItem 的位置設定為導覽列的前緣。

由於「返回」按鈕是手動建立的,因此我們需要自行實作按鈕的動作,這就是為什麼我們在 action 閉包中編寫以下這行程式碼:

dismiss()

不過,這個 dismiss() 函數是什麼呢?我們還沒有實作它,因此 Xcode 應該會顯示一些錯誤。為了讓程式正常運作,在 RestaurantDetailView 中宣告 dismiss 變數:

@Environment(\.dismiss) var dismiss

SwiftUI 框架提供一個名為「@Environment」的屬性包裹器,以供開發者從視圖的環境中讀取值,例如:你可以讀取視圖中使用的配色方案(深色 / 淺色模式)的值。.dismiss 是用來關閉目前視圖的環境值,如圖 11.11 所示。

圖 11.11 加入一行程式碼來取得環境值
圖 11.11 加入一行程式碼來取得環境值

在 iOS 15 中首次導入了dismiss 環境值,如果你的 App 支援較低版本的 iOS,你可以將其替換為presentationMode:

@Environment(\.presentationMode) var presentationMode

並且,你可以像這樣呼叫 dismiss():

presentationMode.wrappedValue.dismiss()

移除揭示指示器

加入導覽連結後,系統會自動為清單視圖中的每個項目包含一個揭示指示器(Disclosure Indicator ),如果你希望移除揭示指示器,SwiftUI 並沒有提供特定的修飾器來控制其可見性,但是你可以使用 ZStack 與 EmptyView 來停用揭示指示器。

圖 11.12.  iOS 為所有列加入了揭示指示器
圖 11.12.  iOS 為所有列加入了揭示指示器

回到 RestaurantListView.swift,在 RestaurantListView 結構中,將下列程式碼:

NavigationLink(destination: RestaurantDetailView(restaurant: restaurants[index])) {
    BasicTextImageRow(restaurant: restaurant)
}

改為:

ZStack(alignment: .leading) {
    NavigationLink(destination: RestaurantDetailView(restaurant: restaurants[index])) {
        EmptyView()
    }
    .opacity(0)

    BasicTextImageRow(restaurant: $restaurants[index])
}

更改後,再次執行 App 來進行測試,揭示指示器應該消失了。

本章小結

在本章中,我引導你了解導覽視圖與導覽連結的基礎知識。使用 SwiftUI,則你只需幾行程式碼就能輕鬆建立基於導覽的使用者介面,我還解釋了一些自訂導覽列的技術。此外,透過實作細節視圖,你現在應該熟悉如何使用 ZStack 來將一個視圖覆蓋在另一個視圖之上。

在本章所準備的範例檔中,有最後完整的 Xcode 專案可供你下載參考:

http://www.appcoda.com/resources/swift59/swiftui-foodpin-navigation.zip