iT邦幫忙

2023 iThome 鐵人賽

DAY 24
0
Mobile Development

SwiftUI 的大大小小系列 第 24

Day 24 - SwiftUI 的 NavigationStack 與 path - 完結

  • 分享至 

  • xImage
  •  

hero

前情提要

在第 22 天,用了陣列儲存需要想式的資料物件,或稱它 "data-driven navigation" ,我們到昨天的範例中仍然使用陣列來儲存,那今天就來替換成 NavigationPath

NavigationPath 可以讓我們不需要透過任何資料型別之間的配合貨抽象化,只要是該型別有遵守 Hashable 即可推入,是個還滿方便的工具。

起始程式碼

繼第 23 天結束

  • 有 Flavor 物件, NavigationStack 能夠透過 Flavor 來開啟指定頁面
  • 有 Deep Link 根據字串 flavors 觸發 NavigationStack 進行導向

程式碼請到文章尾端「第 23 天為止的程式碼」段落查看

新增一個資料型別

struct Dessert: Identifiable, Hashable {
    let id = UUID().uuidString
    let name: String
    let amount: UInt
}

調整 ContentView

新增 dessert 陣列

struct ContentView: View {
    /* 省略 */
    private let desserts = [
        Dessert(name: "草莓蛋糕", amount: 10),
        Dessert(name: "甜甜圈", amount: 23),
        Dessert(name: "焙茶拿鐵", amount: 12),
        Dessert(name: "金箔蛋糕", amount: 13)
    ]
    @State private var path = [Flavor]()
    var body: some View { 
        /* 省略 */
    }
}

替換 path 成 NavigationPath

struct ContentView: View {
    /* 省略 */
    private let desserts = [ /* 省略 */ ]
    @State private var path = NavigationPath()
    var body: some View { 
        /* 省略 */
    }
}

這時候「好手氣」按鈕的 action 會出錯,因為 NavigationPath 並沒有 append(contentsOf:) 這個方法,把 append 拆開即可:

Button {
    if let flavor1 = flavors.randomElement(),
        let flavor2 = flavors.randomElement() {
        // 這邊會有錯誤:
        // path.append(contentsOf: [flavor1, flavor2])

        // 換成這樣即可
        path.append(flavor1)
        path.append(flavor2)
    }
} label: {
    Text("好手氣")
}

建立畫面

  • 顯示列表的地方新增 desserts
  • Dessert 型別新增 navigationDestination
  • 新增 DessertView
VStack {
    ForEach(flavors) { flavor in
        NavigationLink(flavor.name, value: flavor).padding()
    }
    ForEach(desserts) { dessert in
        NavigationLink(dessert.name, value: dessert).padding()
    }
}
.navigationDestination(for: Flavor.self) { flavor in
    Text(flavor.name)
        .navigationTitle(flavor.name)
}
.navigationDestination(for: Dessert.self) { dessert in
    DessertView(dessert: dessert)
}
.navigationTitle("點心")
struct DessertView: View {
    @State var dessert: Dessert

    var body: some View {
        VStack {
            Text(dessert.name)
                .font(.largeTitle)
                .padding()
            VStack {
                Text("剩餘數量")
                Text("\(dessert.amount)")
                    .padding()
                    .font(.system(size: 40, weight: .bold))
                    .background(
                        RoundedRectangle(cornerRadius: 10)
                            .foregroundColor(.teal)
                    )
            }
        }
        .navigationTitle(dessert.name)
    }
}

2401

改寫 random

接下來改寫「好手氣」按鈕 random 的邏輯,讓 NavigationPath 實際發揮作用!

原本是這樣,只有 append flavor ,讓我們來試著也加入 Dessert 吧!

Button {
    if let flavor1 = flavors.randomElement(),
       let flavor2 = flavors.randomElement() {
        path.append(flavor1)
        path.append(flavor2)
    }
} label: {
    Text("好手氣")
}
.foregroundColor(.purple)
.padding()

其中的 random 邏輯改成這樣:

let pool: [any Hashable] = flavors + desserts
if let element = pool.randomElement() {
    path.append(element)
}

由於兩者都是繼承 Hashable ,因此可以讓 pool 的型別為 [Hashable] 但是由於 Hashable 是 protocol ,所以需要寫成 [any Hashable]

執行結果

random 結果看起來有點糟 XD

很大部份竟然都落在 dessert

2402

Bonus: 加入 Deep Link

既然都到這邊了,就再加個 deep link 唄

來訂這樣的需求

當收到這樣 Deep Link 的時候,顯示相對應的畫面

ironman2023://desserts/{:index}

若是其他網址,單純開啟 app 不需要做任何跳轉

例如,收到

ironman2023://desserts/1

就應該要顯示位於位置陣列位置 [1] 的「甜甜圈」的頁面

重構

由於 deep link 處理變多了,因此把他先封裝到一個方法裡

在實務中,這適合抽出成另外一個專們的 class 來處理,避免在 view 裡面有太多和畫面不直接相關的程式碼。

.onOpenURL(perform: handleDeepLink(url:))
private func handleDeepLink(url: URL) {
    guard let host = url.host() else { return }
    switch host {
    case "flavors":
        let pathComponents = url.pathComponents.filter { $0 != "/" }
        guard let first = pathComponents.first,
              let index = Int(first),
              index < flavors.count else { break }
        let flavor = flavors[index]
        path.append(flavor)
    default: break
    }
}

加入 "desserts" 的 Deep Link 處理邏輯

加入和 flavors 類似的邏輯,這邊可以看到程式碼重複很多,實務上這當然也需要再次重構,這邊就不處理了。

private func handleDeepLink(url: URL) {
    guard let host = url.host() else { return }
    let pathComponents = url.pathComponents.filter { $0 != "/" }

    switch host {
    case "flavors":
        guard let first = pathComponents.first,
              let index = Int(first),
              index < flavors.count else { break }
        let flavor = flavors[index]
        path.append(flavor)
    case "desserts":
        guard let first = pathComponents.first,
              let index = Int(first),
              index < desserts.count else { break }
        let dessert = desserts[index]
        path.append(dessert)
    default: break
    }
}

執行結果

2403

結語

從第 20 天開始,從透過 NavigationLink 可以從畫面觸發導向 (navigation)

從第 21 天開始,分享了 data driven 的導向方式,並概略性的分享如何以 deep link 為範例利用這個導向方式:

雖然實務上還需要做許多處理,例如有些畫面不一定是會透過 NavigationStack 來導向,可能會使用 sheet 或是其他呈現方式。不過相信這一系列的分享會是一個起始點。

以上!

今天的 SwiftUI 的大大小小就到這裡,明天見!

完整程式碼

第 23 天為止的程式碼

struct Flavor: Identifiable, Hashable {
    let id = UUID().uuidString
    let name: String
}

struct ContentView: View {
    private let flavors = ["綠豆", "紅豆", "芋粿", "愛玉"].map(Flavor.init)
    @State private var path = [Flavor]()
    var body: some View {
        NavigationStack(path: $path) {
            Button {
                if let flavor1 = flavors.randomElement(),
                   let flavor2 = flavors.randomElement() {
                    path.append(contentsOf: [flavor1, flavor2])
                }
            } label: {
                Text("好手氣")
            }
            .foregroundColor(.purple)
            .padding()
            ForEach(flavors) { flavor in
                NavigationLink(flavor.name, value: flavor).padding()
            }
            .navigationDestination(for: Flavor.self) { flavor in
                Text(flavor.name)
                    .navigationTitle(flavor.name)
            }
            .navigationTitle("點心")
        }
        .onOpenURL { url in
            guard let host = url.host() else { return }
            switch host {
            case "flavors":
                let pathComponents = url.pathComponents.filter { $0 != "/" }
                guard let first = pathComponents.first,
                      let index = Int(first),
                      index < flavors.count else { break }
                let flavor = flavors[index]
                path.append(flavor)
            default: break
            }
        }
    }
}

本篇最後的程式碼

struct Flavor: Identifiable, Hashable {
    let id = UUID().uuidString
    let name: String
}
struct Dessert: Identifiable, Hashable {
    let id = UUID().uuidString
    let name: String
    let amount: UInt
}
struct ContentView: View {

    // MARK: Data Arrays

    private let flavors = ["綠豆", "紅豆", "芋粿", "愛玉"].map(Flavor.init)
    private let desserts = [
        Dessert(name: "草莓蛋糕", amount: 10),
        Dessert(name: "甜甜圈", amount: 23),
        Dessert(name: "焙茶拿鐵", amount: 12),
        Dessert(name: "金箔蛋糕", amount: 13)
    ]

    // MARK: Navigation

    @State private var path = NavigationPath()

    // MARK: View

    var body: some View {
        NavigationStack(path: $path) {
            Button {
                let pool: [any Hashable] = flavors + desserts
                if let element = pool.randomElement() {
                    path.append(element)
                }
            } label: {
                Text("好手氣")
            }
            .foregroundColor(.purple)
            .padding()
            VStack {
                ForEach(flavors) { flavor in
                    NavigationLink(flavor.name, value: flavor).padding()
                }
                ForEach(desserts) { dessert in
                    NavigationLink(dessert.name, value: dessert).padding()
                }
            }
            .navigationDestination(for: Flavor.self) { flavor in
                Text(flavor.name)
                    .navigationTitle(flavor.name)
            }
            .navigationDestination(for: Dessert.self) { dessert in
                DessertView(dessert: dessert)
            }
            .navigationTitle("點心")
        }
        .onOpenURL(perform: handleDeepLink(url:))
    }

    private func handleDeepLink(url: URL) {
        guard let host = url.host() else { return }
        let pathComponents = url.pathComponents.filter { $0 != "/" }

        switch host {
        case "flavors":
            guard let first = pathComponents.first,
                  let index = Int(first),
                  index < flavors.count else { break }
            let flavor = flavors[index]
            path.append(flavor)
        case "desserts":
            guard let first = pathComponents.first,
                  let index = Int(first),
                  index < desserts.count else { break }
            let dessert = desserts[index]
            path.append(dessert)
        default: break
        }
    }
}
struct DessertView: View {
    @State var dessert: Dessert

    var body: some View {
        VStack {
            Text(dessert.name)
                .font(.largeTitle)
                .padding()
            VStack {
                Text("剩餘數量")
                Text("\(dessert.amount)")
                    .padding()
                    .font(.system(size: 40, weight: .bold))
                    .background(
                        RoundedRectangle(cornerRadius: 10)
                            .foregroundColor(.teal)
                    )
            }
        }
        .navigationTitle(dessert.name)
    }
}

環境

  • Xcode 15

本篇使用到的 UI 元件和 modifiers 基本上沒有受到版本更新影響。若要在 Xcode 14 等環境下使用也是沒問題的。


上一篇
Day 23 - 在 SwiftUI 中如何獲取和解析 Deep Link 並跳轉
下一篇
Day 25 - 在 SwiftUI 中使用 Menu 建立下拉式選單
系列文
SwiftUI 的大大小小30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言