SwiftUI 開發之旅:自定義 TabView

語言: CN / TW / HK

theme: smartblue

開啟掘金成長之旅!這是我參與「掘金日新計劃 · 12 月更文挑戰」的第6天,點選檢視活動詳情

好久不見,我是 new_cheng。

關於自定義 TabView,首先要明白,為什麼不使用官方的 TabView,為什麼要自定義一個 TabView?

有幾個值得這麼做的理由:

  1. 更靈活的控制 TabView 的顯示;
  2. 高度的定製化,比如給 TabView 設定面板(誰能不愛好看的面板呢?);

以上 2 點就值得你自定義一個 TabView。

話不多說,開搞。

實現思路

一個標準的 TabView, 先來看看完成圖:

image.png

實現思路也很簡單:

  1. 建立一個路由控制器;
  2. 建立一個 TabBarIcon;
  3. 自定義檢視
  4. 檢測路由變化,切換檢視;

建立路由控制器

這裡的路由只是一個稱呼,和前端領域的路由不同。

ViewRouter.swift:

```swift import SwiftUI

enum Page {     case home     case my }

class ViewRouter: ObservableObject { @Published var currentPage: Page = .home } ```

ViewRouter 是一個遵循 ObservableObject 協議的類,它的 currentPage 屬性是用 @Published 進行包裝的。當屬性值發生變化時,使用該類的任何檢視都會自動重新呼叫 body 屬性,來保持介面與資料的一致性。

建立 TabBarIcon

接著,建立 TabView 的選單內容,我們得封裝一個 TabBarIcon

ViewRouter.swift 檔案中新增以下程式碼:

```swift struct TabBarIcon: View {

@StateObject var viewRouter: ViewRouter

let assignedPage: Page

let width, height: CGFloat

let systemIconName, tabName: String

var body: some View {         VStack {             Image(systemName: systemIconName)                 .resizable()                 .aspectRatio(contentMode: .fit)                 .frame(width: width, height: height)                 .padding(.top, 6)             Text(tabName)                 .font(.footnote)                 .font(.system(size: 16))             Spacer()         }         .padding(.horizontal, -2)         .onTapGesture {             viewRouter.currentPage = assignedPage         }         .foregroundColor(viewRouter.currentPage == assignedPage ? .blue : .gray)     } } ```

當用戶點選的時候,會去更新當前路由;值得一提的是,這裡我們採用的是 @StateObject,而不是 @ObservedObject,@ObservedObject 不管儲存,會隨著檢視的建立被多次建立。而 @StateObject 保證物件只會被建立一次。因此,如果是在視圖裡自行建立的 ObservableObject model 物件,使用 @StateObject 會是更正確的選擇。

自定義檢視

為了顯示路由檢視,我們還需要自定義檢視。藉助 GeometryReader,我們可以很輕鬆的做到這一點。GemoetryReader 是一個容器檢視,能夠根據其自身大小和座標空間定義其內容,簡單來說,GeometryReader 是一種特別的 View,在其中可以拿到一些你在其他 View 中拿不到的資訊,比如父級檢視的 size。

ContentView.siwft: ```swift struct ContentView: View {

var body: some View {
    GeometryReader { geometry in
        VStack {
            Spacer()
            ZStack {
                HStack {
                    TabBarIcon(viewRouter: viewRouter, assignedPage: .home, 
                    width: geometry.size.width/5, height: geometry.size.height/32, 
                    systemIconName: "chart.pie.fill", tabName: "首頁")
                        .frame(maxWidth: .infinity)
                    TabBarIcon(viewRouter: viewRouter, assignedPage: .my,
                    width: geometry.size.width/5, height: geometry.size.height/32, 
                    systemIconName: "person.crop.circle.fill", tabName: "我的")
                        .frame(maxWidth: .infinity)
                }
                // 將寬度設定為父檢視的寬度大小,高度需要微調,可以設定為具體是數值,比如 100
                .frame(width: geometry.size.width, height: geometry.size.height/9)
            }
        }
        .ignoresSafeArea(edges: .bottom)
    }
}

} ```

檢測路由變化,切換檢視

switch 來切換檢視:

```swift struct ContentView: View { @StateObject var viewRouter: ViewRouter

var body: some View {
    GeometryReader { geometry in
        VStack {
            switch viewRouter.currentPage {
                case .home:
                    Home()
                case .my:
                    My()
            }
            Spacer()
            ZStack {
                HStack {
                    TabBarIcon(viewRouter: viewRouter, assignedPage: .home, 
                    width: geometry.size.width/5, height: geometry.size.height/32, 
                    systemIconName: "chart.pie.fill", tabName: "首頁")
                        .frame(maxWidth: .infinity)
                    TabBarIcon(viewRouter: viewRouter, assignedPage: .my,
                    width: geometry.size.width/5, height: geometry.size.height/32, 
                    systemIconName: "person.crop.circle.fill", tabName: "我的")
                        .frame(maxWidth: .infinity)
                }
                // 將寬度設定為父檢視的寬度大小,高度需要微調,可以設定為具體是數值,比如 100
                .frame(width: geometry.size.width, height: geometry.size.height/9)
            }
        }
        .ignoresSafeArea(edges: .bottom)
    }
}

}

// 設定預覽 struct ContentView_Previews: PreviewProvider {     static var previews: some View {         ContentView(viewRouter: ViewRouter())     } } ```

到這裡,一個自定義 TabView 就完成了。

面板

我們的 TabView 既然都是完全自定義的了,那給它開發面板自然不在話下了;像招行的 APP 就有很多漂亮的面板,這對提高使用者粘性來說,是一個不錯的方式(含淚給王者農藥打錢😭)。當然這完全是由你或者設計師來決定的,按照設計稿開搞就是了。

image.png

關於怎麼做 SwiftUI 的面板定製,先給自己挖個坑,以後來填上😋。

額外收穫

當使用 swiftui 的 NavigationView 進行導航時,如果你在一個父級檢視中使用了 NavigationView,然後在子級檢視中也使用了 NavigationView,那在進行導航的時候,就有可能出現 2 個導航欄的情況。

image.png

這時候的解決辦法是就是僅在頂級父檢視,也就是 ContentView.swift 中使用 NavigationView:

```swift struct ContentView: View { @StateObject var viewRouter: ViewRouter

var body: some View {
    NavigationView {
        // ...
    }
}

} ```

此時,如果你使用的是 swiftui 自帶的 TabView 檢視,而非自定義的,而又想在子檢視中不顯示 TabView,這會很麻煩,需要藉助第三方庫:SwiftUI-Introspect

到這,還沒有完,用 SwiftUI-Introspect 控制官方 TabView 的顯示,顯示和隱藏的時候,會有一個過渡動畫...

如果你不介意在每次從子檢視返回首頁的時候,TabView 顯示的這個過度動畫帶來的延遲效果,那就可以採用官方的 TabView。

而當你使用自定義的 TabView 時,這些問題都迎刃而解💯。

總結

我們用簡單的程式碼就實現了一個自定義的 TabView,通過她,我們能做到高度的自定義效果,值得一試!

這是 SwiftUI 開發之旅專欄的文章,是 swiftui 開發學習的經驗總結及實用技巧分享,歡迎關注該專欄,會堅持輸出。同時歡迎關注我的個人公眾號 @JSHub:提供最新的開發資訊速報,優質的技術乾貨推薦。或是檢視我的個人部落格:Devcursor

👍點贊:如果有收穫和幫助,請點個贊支援一下!

🌟收藏:歡迎收藏文章,隨時檢視!

💬評論:歡迎評論交流學習,共同進步!