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

第 20 章
使用 TabView 建立導覽畫面

If you're interested in the living heart of what you do, focus on building things rather than talking about them.

- Ryan Freitas, About.me

首次啟動 App 時,通常會包含一系列導覽畫面或教學,這些畫面引導使用者了解 App 的特色及功能。有些人認為對於導覽畫面的需求,就意味著 App 設計的失敗,但是就我個人而言,我發現大多數的導覽畫面都很有用,且不討厭它們,關鍵是要保持簡潔,避免冗長且無聊的教學,我不會爭論你是否應該在 App 中包含導覽畫面,我只是想向你展示如何做出這個功能。

App 開發者利用導覽畫面,不僅可以展示 App 的功能,還可以引導使用者完成初始設定過程,例如:啟動通知與選擇顏色主題,導覽畫面的範例如圖 20.1 所示。

圖 20.1.  Sorted 的導覽畫面範例
圖 20.1.  Sorted 的導覽畫面範例

在本章中,我們將討論如何使用 TabView 來建立導覽畫面。當我提到標籤視圖,你可能會立即想到一個帶有標籤列的 App,不過使用 SwiftUI,TabView 可用來顯示一個具有多個標籤的介面,並且提供的不僅是一個標準的標籤介面,透過更改其樣式,你可以輕鬆將標籤視圖轉換為頁面滾動視圖(Paged Scrolling View )。

我們開始吧 !

快速瀏覽導覽畫面

我們瀏覽一下導覽畫面,App 一共顯示三個導覽畫面,使用者可透過在螢幕上滑動或點擊「Next」按鈕來在頁面之間導覽。

在最後的導覽畫面中,它顯示一個「Get Started」按鈕,當使用者點擊該按鈕,導覽畫面將會關閉,並且不再顯示。此外,使用者隨時可點擊「Skip」按鈕來略過導覽畫面,圖 20.2 是導覽畫面的螢幕截圖。

圖 20.2. FoodPin App 的導覽畫面
圖 20.2. FoodPin App 的導覽畫面

要建立導覽畫面,你需要先準備好圖片。首先下載本章所準備的圖片集( http://www.appcoda.com/resources/swift53/onboarding.zip ),然後匯入所有圖片( .svg )至素材目錄,請確保你有為每張圖片啟用「Preserve Vector Data」選項,如圖 20.3 所示。

圖 20.3. 匯入圖片到素材目錄
圖 20.3. 匯入圖片到素材目錄

建立導引視圖

和往常一樣,我們將為導覽畫面建立一個單獨視圖。在專案導覽器中的「View」資料夾上按右鍵,並選擇「New File...」,然後選取「SwiftUI View」模板,將檔案命名為「TutorialView.swift」。

對於導引視圖(Tutorial View )的每個頁面,佈局都非常相似,因此我們建立一個名為「TutorialPage」的子視圖,其顯示特色圖片、標題與子標題。在 TutorialView.swift 檔案中,插入下列的程式碼片段:

struct TutorialPage: View {

    let image: String
    let heading: String
    let subHeading: String

    var body: some View {
        VStack(spacing: 70) {
            Image(image)
                .resizable()
                .scaledToFit()

            VStack(spacing: 10) {
                Text(heading)
                    .font(.headline)

                Text(subHeading)
                    .font(.body)
                    .foregroundStyle(.gray)
                    .multilineTextAlignment(.center)
            }
            .padding(.horizontal, 40)

            Spacer()
        }
        .padding(.top)
    }
}

這段程式碼非常簡單,我們使用 VStack 視圖來排列圖片、標題與子標題。我們使用 Spacer() 將元件與螢幕頂部對齊。

要預覽 TutorialPage 視圖,則插入下列的預覽程式碼:

#Preview("TutorialPage", traits: .sizeThatFitsLayout) {
    TutorialPage(image: "onboarding-1", heading: "CREATE YOUR OWN FOOD GUIDE", subHeading: "Pin your favorite restaurants and create your own food guide")
}

透過將一些測試資料傳送給 TutorialPage 視圖,Xcode 應該能渲染該預覽,如圖 20.4 所示。

Figure 20-4. Previewing the TutorialPage view
Figure 20-4. Previewing the TutorialPage view

現在已經建立了 TutorialPage 視圖,我們可以開始建立頁面導覽視圖。在 TutorialView 結構中,宣告以下的變數來存放標題、子標題與圖片:

let pageHeadings = [ "CREATE YOUR OWN FOOD GUIDE", "SHOW YOU THE LOCATION", "DISCOVER GREAT RESTAURANTS" ]
let pageSubHeadings = [ "Pin your favorite restaurants and create your own food guide",
                        "Search and locate your favorite restaurant on Maps",
                        "Find restaurants shared by your friends and other foodies"
                        ]
let pageImages = [ "onboarding-1", "onboarding-2", "onboarding-3" ]

要使用 TabView 來建立頁面滾動視圖,則可使用下列的程式碼片段更新 body 變數:

TabView {
    ForEach(pageHeadings.indices, id: \.self) { index in
        TutorialPage(image: pageImages[index], heading: pageHeadings[index], subHeading: pageSubHeadings[index])
            .tag(index)
    }
}
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))

在 TabView 中,我們使用 TutorialPage 來顯示導覽畫面的每個頁面。.tag 修飾器為每個頁面提供唯一的索引。要將標準標籤視圖轉換為頁面滾動視圖,你只需要將標籤視圖的樣式設定為「.page」。

indexViewStyle 修飾器用來指定頁面指示器的樣式。在本例中,我們設定其值為 .page(backgroundDisplayMode: .always),以確保標籤視圖始終顯示頁面圓點。

現在你可以在預覽窗格中測試這個 App,你可以向左滑動或向右滑動,以在不同頁面之間瀏覽,如圖 20.5 所示。

圖 20.5. 沒有頁面指示器的導覽視圖
圖 20.5. 沒有頁面指示器的導覽視圖

SwiftUI 並沒有提供任何的修飾器來設定圓點的顏色,我們必須依賴 UIKit API。在 TutorialView 中,如果你想要變更頁面指示器的顏色,則新增 init() 方法:

init() {
    UIPageControl.appearance().currentPageIndicatorTintColor = .systemIndigo
}

我們設定現行圓點的顏色為「.systemIndigo」,如果你再次測試 App,你應該會看到頁面指示器,如圖 20.6 所示。

圖 20.6. 變更頁面圓點的顏色
圖 20.6. 變更頁面圓點的顏色

加入 Next 及 Skip 按鈕

導引視圖中還缺少了一些元素,即「Next」、「Skip」與「Get Started」按鈕。當點擊「Next」按鈕時,App 應該會導覽至導覽畫面的下一頁,但是我們如何編寫程式碼來使用TabView 進行頁面間的切換呢?

訣竅是透過綁定到目前的頁面索引來初始化 TabView,如此標籤視圖將會監看目前頁面索引的任何變化,並自動滾動至指定的頁面索引。

我們來看如何實作。首先,宣告一個狀態變數來追蹤目前的頁面索引:

@State private var currentPage = 0

我們設定目前的頁面索引為「0」(即導覽畫面的第一頁)。接下來,宣告下列的變數來從環境中取得 .dismiss:

@Environment(\.dismiss) var dismiss

稍後,我們將使用它來關閉導引視圖。要在頁面指示器的下方新增按鈕,我們使用 VStack 包裹 TabView,更新 body 變數如下:

VStack {
    TabView(selection: $currentPage) {
        ForEach(pageHeadings.indices, id: \.self) { index in
            TutorialPage(image: pageImages[index], heading: pageHeadings[index], subHeading: pageSubHeadings[index])
                .tag(index)
        }
    }
    .tabViewStyle(.page(indexDisplayMode: .always))
    .indexViewStyle(.page(backgroundDisplayMode: .always))
    .animation(.default, value: currentPage)

    VStack(spacing: 20) {
        Button(action: {
            if currentPage < pageHeadings.count - 1 {
                currentPage += 1
            } else {
                dismiss()
            }
        }) {
            Text(currentPage == pageHeadings.count - 1 ? "GET STARTED" : "NEXT")
                .font(.headline)
                .foregroundStyle(.white)
                .padding()
                .padding(.horizontal, 50)
                .background(Color(.systemIndigo))
                .cornerRadius(25)
        }

        if currentPage < pageHeadings.count - 1 {

            Button(action: {
                dismiss()
            }) {

                Text("Skip")
                    .font(.headline)
                    .foregroundStyle(Color(.darkGray))

            }
        }
    }
    .padding(.bottom)

}

我們對上列的程式碼做了一些更改:

  1. 我們使用 selection參數來實例化 TabView。該參數接受與目前的頁面索引的綁定,這 讓 TabView 自動監看頁面索引的變化,並相應導覽到更新的頁面。
  2. 我們加入 .animation修飾器到 TabView,以在頁面滾動時實現平滑的動畫。
  3. 我們引入另一個 VStack視圖來排列這些按鈕。首先是「Next」按鈕,當點擊「Next」 按鈕時,currentPage 索引會加1,直到導覽頁面的末尾。當使用者到達導覽頁面的最後一頁時,此按鈕的標籤也會變更為「Get Started」。
  4. 除了最後一頁之外,其他頁面均會顯示「Skip」按鈕,這就是我們使用條件檢查來包裹 Button 視圖的原因。

在預覽中執行 App 來快速測試一下,你現在可以使用滑動手勢與「Next」按鈕來在導引視圖之間導覽。

圖 20.7.  加入 Next 和 Skip 按鈕
圖 20.7.  加入 Next 和 Skip 按鈕

顯示導引視圖

如前所述,導引視圖應該要在使用者首次啟動 App 時出現,因此我們需要對 Restaurant ListView 進行一些修改,我們將使用 sheet 修飾器來顯示導引視圖。

切換到 RestaurantListView.swift,並宣告一個狀態變數:

@State private var showWalkthrough = true

這個狀態變數指示是否應出現導引視圖。接下來,我們加入另一個 sheet 修飾器到導引堆疊:

.sheet(isPresented: $showWalkthrough) {
    TutorialView()
}

現在我們在模擬器上執行 App 來快速測試。當 App 啟動時,你應該會看到導引視圖, 如圖 20.8 所示。很酷,對吧?

圖 20.8. 顯示導引視圖
圖 20.8. 顯示導引視圖

使用 UserDefaults

現在導覽畫面已經開始運作了,但是每當啟動 App 時,它都會出現。理想的情況下, 導覽畫面或教學只應在使用者第一次啟動 App 時顯示,為此我們需要找到一個儲存狀態的方式,以指示使用者是否看過導覽畫面。

我們應在哪裡保存這個狀態呢?

你已經學過 SwiftData 了,因此你可能希望將這個狀態儲存在本地資料庫中,雖然這是一個選項,但是還有一種更簡單的方式可儲存應用程式與使用者設定。

iOS SDK 提供 UserDefaults 類別來管理使用者的預設資料庫讓你持久性儲存鍵值對。藉由 SwiftUI 框架,開發者可以使用 @AppStorage 屬性包裹器,來輕鬆對預設資料庫讀取與寫入值,從而簡化了流程。

要使用 @AppStorage,可以編寫程式碼如下:

@AppStorage("hasViewedWalkthrough") var hasViewedWalkthrough: Bool = false

這會在使用者的預設資料庫中建立一個新實體,鍵設定為「hasViewedWalkthrough」, 值設定為「false」。SwiftUI 將會持續監看 hasViewedWalkthrough 的值,並相應更新 UI, 而且當我們更新其值時,更新後的值也會寫入使用者的預設資料庫中。

現在將上列的程式碼插入 RestaurantListView 中,根據 hasViewedWalkthrough 的值, App 會決定是否啟動導引視圖。將 onAppear 修飾器加到導覽堆疊:

.onAppear() {
    showWalkthrough = hasViewedWalkthrough ? false : true
}

當清單視圖出現時,我們檢查 hasViewedWalkthrough 的值來看是否應開啟導引視圖。

此外,將 showWalkthrough 的預設值更新為「false」,因為我們現在依賴 hasViewed Walkthrough 來決定 showWalkthrough 的值。

@State private var showWalkthrough = false

現在切換到 TutorialView 並宣告下列的變數:

@AppStorage("hasViewedWalkthrough") var hasViewedWalkthrough: Bool = false

在使用者閱讀完導覽畫面後,我們需要更新 hasViewedWalkthrough 的值為「true」,因此在「Next」按鈕的動作閉包中插入下列這行程式碼,以將狀態更新為「true」:

hasViewedWalkthrough = true

你可以將程式碼放在執行 dismiss() 之前。同一行程式碼也應該加到「Skip」按鈕的動作閉包中,如圖 20.9 所示。

是時候來進行測試了,在模擬器中執行這個 App,你應該會在啟動 App 時看到導覽畫面。

圖 20.9. 首次啟動 App 時顯示導覽畫面
圖 20.9. 首次啟動 App 時顯示導覽畫面

本章小結

在本章中,我們介紹了 TabView 的基本知識,並示範了顯示頁面視圖的用法。我們也探討了 @AppStorage 屬性包裹器在讀取和寫入值到使用者的預設系統時的便利性。藉由 SwiftUI 的這些強大功能,現在你已經掌握為使用者首次啟用 App 時建立導覽或教學畫面的知識。

在本章所準備的範例檔中,有最後完整的 Xcode 專案可供你下載參考:http://www.appcoda.com/resources/swift59/swiftui-foodpin-walkthrough.zip