SwiftUI精講:自定義 Tabbar 組件 (包含過渡效果)

語言: CN / TW / HK

theme: channing-cyan highlight: xcode


Tabbar是我們日常開發中經常使用到的組件,然而在SwiftUI中,Tabbar目前只有TabView有相關的實現,這顯然是不符合我們日常開發的需求的,所以讓我們一起看看如何實現自定義的Tabbar吧~

1.使用TabView實現

1-1:文檔查看

要用TabView前,我們老規矩,先看看文檔,如圖所示: image.png 我們通過文檔給我們提供的文字描述以及相關例子,我們可以知道 TabView 是配合着 .tabItem 修飾符進行使用的。有認真看文檔的小夥伴,能發現文檔裏面描述了這麼一句話:

Use a Label for each tab item, or optionally a Text, an Image, or an image followed by text. Passing any other type of view results in a visible but empty tab item.

大致翻譯過來就是:你別看這個 .tabItem 修飾符的傳參是符合View協議的,但可不是你傳啥我就給你顯示啥。我這裏只接收 Label 控件,或者傳入一個 Text 控件 和 Image 控件。

1-2:代碼實現

根據上方大致的描述,我們能夠輕鬆地寫出以下代碼:

```swift struct ContentView:View{ @State var currentSelectd: Int = 1

struct TabItem{
    var id:Int
    var text:String
    var icon:String
}

let tabItems = [
    TabItem(id:1,text:"首頁",icon:"book"),
    TabItem(id:2,text:"地址",icon:"location"),
    TabItem(id:3,text:"收藏",icon:"heart"),
    TabItem(id:4,text:"我的",icon:"person"),
]

var body:some View{
    VStack{
        Text("當前觸發的是:\(currentSelectd)")
        TabView(selection: $currentSelectd) {
            ForEach(tabItems,id:\.id){ item in
                Text(item.text).tabItem{
                    Label(item.text, systemImage: item.icon)
                   // 下面這種寫法同樣生效

// VStack{ // Text(item.text).foregroundColor(.red) // Image(systemName: item.icon) // } } } } } } } ``` 效果如圖所示:

image.png

有的朋友可能會問,為啥你的 .tabItem 後面,不用跟 .tag 修飾符去給視圖設置唯一值呢?嘿嘿,我們來看看 .tag 的文檔,它是這麼描述的:

ForEach automatically applies a default tag to each enumerated view using the id parameter of the corresponding element.

嗚呼~文檔告訴我們 ForEach 使用相應元素的 id 參數會自動標記並應用於每個視圖。

那麼上方的代碼,還有可以優化的點嗎?答案是:有的。

```swift struct ContentView:View{ @State var currentSelectd: Int = 1

struct TabItem:Identifiable{
    var id:Int
    var text:String
    var icon:String
}

let tabItems = [
    TabItem(id:1,text:"首頁",icon:"book"),
    TabItem(id:2,text:"地址",icon:"location"),
    TabItem(id:3,text:"收藏",icon:"heart"),
    TabItem(id:4,text:"我的",icon:"person"),
]

var body:some View{
    VStack{
        Text("當前觸發的是:\(currentSelectd)")
        TabView(selection: $currentSelectd) {
            ForEach(tabItems){ item in
                Text(item.text).tabItem{
                    Label(item.text, systemImage: item.icon)
                }
            }
        }
    }
}

} ``` 我們修改結構體 TabItem,使其符合名為 Identifiable 的新協議。這樣做有什麼好處呢?大家可以看到,我把 ForEach 中的 id 參數給刪除了。這就是它的好處,我們不再需要告訴 ForEach 使用哪個屬性作為唯一的標識符。

1-3:樣式調整

雖然 TabView 的樣式自定製比較雞肋,但我們還是可以稍微改點樣式的,比如我們希望修改 TabView 被成功激活後的顏色,我們可以使用 .tink 修飾符,如下所示:

swift TabView(selection: $currentSelectd) { ForEach(tabItems){ item in Text(item.text).tabItem{ Label(item.text, systemImage: item.icon) } } } .tint(.pink) 樣式如圖所示:

image.png

如果我們想隱藏下方的 tabbar 欄,通過手勢滑動來切換視圖,可以使用 .tabViewStyle 修飾符,如下所示: swift TabView(selection: $currentSelectd) { ForEach(tabItems){ item in Text(item.text).tabItem{ Label(item.text, systemImage: item.icon) } } } .tabViewStyle(PageTabViewStyle(indexDisplayMode:.never)) 效果如圖所示: ddd.gif

如果想保留 tabbar 的圖標並支持手勢滑動的話,可以使用 .indexViewStyle 修飾符,如下所示: swift TabView(selection: $currentSelectd) { ForEach(tabItems){ item in Text(item.text).tabItem{ Label(item.text, systemImage: item.icon) } } } .tabViewStyle(PageTabViewStyle(indexDisplayMode:.always)) .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always)) 效果如圖所示:

ddd.gif

在感受到 TabView 如此"強大"自定義樣式功能後,相信不少人已經跟我一樣,內心緩緩説出兩個字:

image.png

2.自定義 Tabbar 組件

2-1: 新建相關目錄結構

我們新建 Views 文件夾,在裏面新建4個視圖文件,並簡單的將視圖名作為 Text 的輸入值即可,如圖所示:

image.png

接着我們新建 Model 文件夾,在裏面新建 Tab 文件,並寫入以下代碼:

```swift enum Tab: CaseIterable{ case home case location case collect case mine

var text:String{
    switch self{
    case .home:
        return "首頁"
    case .location:
        return "地址"
    case .collect:
        return "收藏"
    case .mine:
        return "我的"
    }
}

var icon:String{
    switch self{
    case .home:
        return "book"
    case .location:
        return "location"
    case .collect:
        return "heart"
    case .mine:
        return "person"
    }
}

} ``` 關於CaseIterable,沒用過的朋友可以點擊查看文檔哦。這是目前我認為比較簡潔的方式了~在先前我們定義 Struct TabItem,現在可以不用啦。代碼總是越寫越好的,你覺得呢?

接着我們新建Components文件夾,在裏面新增tabbar文件。至此,我們的目錄結構如圖所示:

image.png

大家有沒有覺得很熟悉,在前端的工程化項目中,也有類似的結構。~~(因為我就是前端)~~

2-2: tabbar 組件實現

在前端的組件實現中, tabbar組件通常是單獨抽出來的,大家通常會在各大組件庫中,找到不錯的實現。今天我們也來實現一下 SwiftUI 版本的。首先,我們遍歷枚舉Tab,渲染出相關元素。代碼如下: ```swift struct tabbar: View { @State var currentSelected: Tab = .home

var body: some View {
    HStack{
        ForEach(Tab.allCases, id: \.self) { tabItem in
            Button{
                currentSelected = tabItem
            } label:{
                VStack{
                    Image(systemName: tabItem.icon)
                    Text(tabItem.text)
                }
            }
        }
    }
}

} ``` 效果圖如下:

image.png

接下來,我們加上一些想要的樣式,包括選中後的圖標樣式,整體背景色等,代碼如下: ```swift struct tabbar: View { @State var currentSelected: Tab = .home

var body: some View {
    HStack{
        ForEach(Tab.allCases, id: \.self) { tabItem in
            Button{
                currentSelected = tabItem
            } label:{
                VStack{
                    Image(systemName: tabItem.icon)
                        .font(.system(size: 24))
                        .frame(height: 30)
                    Text(tabItem.text)
                        .font(.body.bold())
                }
                .foregroundColor(currentSelected == tabItem ? .pink : .secondary)
                .frame(maxWidth: .infinity)
            }
        }
    }
    .padding(6)
    .background(.white)
    .cornerRadius(10)
    .shadow(color: Color.black.opacity(0.3), radius: 10, x: 0, y: 4)
    .padding(.horizontal)
}

} ``` 效果如圖所示:

ddd.gif

哎呀,這看着不過癮吶。如果我還想要點擊的時候有背景色,並且背景色在點擊的時候,要有移動的過渡效果,這怎麼辦呢?

2-3:withAnimation 與 matchedGeometryEffect

在SwiftUI中,若要使用動畫,我們可以使用到 withAnimation,我們先寫個小例子來感受一下:

```swift struct ContentView:View{ @State var distance: CGFloat = -100

var body:some View{
    VStack {
        Button{
            withAnimation(.easeOut(duration: 1)){
                distance = 100
            }
        } label: {
            Text("點擊觸發動畫")
        }

        Rectangle().fill(.pink).frame(width: 100,height: 100).offset(x:distance)
    }
}

} ``` 效果如圖所示: ddd.gif

matchedGeometryEffect 則是 ios14 版本出的修飾符,我們來看看它的參數是怎麼傳的。

swift func matchedGeometryEffect<ID>( id: ID, in namespace: Namespace.ID, properties: MatchedGeometryProperties = .frame, anchor: UnitPoint = .center, isSource: Bool = true ) -> some View where ID : Hashable - id: 由於該方法可以同步不同視圖組的幾何圖形,id 參數可以讓我們對它們進行相應的分組。它可以是任何 Hashable 類型(例如,Int、String) - namespace: 為了避免 id 衝突,兩個視圖的配對由 id + namespace確定。 - properties: 要從源視圖複製的屬性。什麼是源視圖?isSource = true就是源視圖了,那麼properties會用在非源視圖中。源視圖始終共享其所有幾何圖形(size、position),該參數默認值為 .frame ,意味着它同時匹配着 size 和 position。我們可以在非源視圖中,通過 properties:.size/.position來指定要從源視圖複製的屬性。 - anchor: 視圖中用於生成其共享位置值的相對位置。 - isSource:默認為 true, 視圖將被應用為其他視圖的幾何源。

關於 matchedGeometryEffect ,我們在本篇內容只會用到 id + namespace,所以大家的心理負擔不用太重。

2-4:Tabbar背景增加過渡效果

通過以上的瞭解,我們可以對之前的代碼,稍作修改:

```swift struct tabbar: View { @State var currentSelected: Tab = .home @Namespace var animationNamespace

var body: some View {
    HStack{
        ForEach(Tab.allCases, id: \.self) { tabItem in
            Button{
                withAnimation(.easeInOut) {
                    currentSelected = tabItem
                }
            } label:{
                VStack{
                    Image(systemName: tabItem.icon)
                        .font(.system(size: 24))
                        .frame(height: 30)
                    Text(tabItem.text)
                        .font(.body.bold())
                }
                .foregroundColor(currentSelected == tabItem ? .pink : .secondary)
                .frame(maxWidth: .infinity)
                // 新增背景過渡效果
                .background(
                    ZStack{
                        if currentSelected == tabItem {
                            RoundedRectangle(cornerRadius: 10)
                                .fill(.pink.opacity(0.2))
                            .matchedGeometryEffect(id: "background_rectangle", in: animationNamespace)
                        }
                    }
                )
            }
        }
    }
    .padding(6)
    .background(.white)
    .cornerRadius(10)
    .shadow(color: Color.black.opacity(0.3), radius: 10, x: 0, y: 4)
    .padding(.horizontal)
}

} ``` 效果如圖所示:

ddd.gif

咦,雖然效果是我們想要的,但是為什麼往回點的時候,沒有相應的過渡動畫呢?原因是 Xcode 的preview,有時候並不能很好的呈現相關的動畫效果。我們可以按下 command + R,啟動 Simulator 來看看效果:

ddd.gif

這樣看起來正常多了~但是動畫看起來很普通,我想讓它在過渡過程中增加一些 "彈性" 的效果,那要怎麼做呢?

我們可以在 withAnimation 中,增加 .spring 修飾符,如下所示:

swift withAnimation(.spring(response: 0.3,dampingFraction: 0.7)) { currentSelected = tabItem } 效果如下:

ddd.gif

怎麼樣,動畫效果是不是更加流暢了~

2-5:完善 Tabbar

在上方的代碼中,我們已經做出了一個大致的 Tabbar 組件了~但還是有問題,正常的tabbar是置於底部的,我們需要把它先放到頁面的底部去,如下所示:

swift HStack{...} .padding(6) .background(.white) .cornerRadius(10) .shadow(color: Color.black.opacity(0.3), radius: 10, x: 0, y: 4) .padding(.horizontal) // 新增 .frame(maxHeight: .infinity,alignment: .bottom) 如果你的tabbar需求是貼着底部的話,你可以加上 .ignoresSafeArea() 修飾符,作用是忽略iphone的安全區域。

接着我們把 tabbar 組件放到 contentView 中使用。

swift struct ContentView:View{ var body:some View{ VStack { tabbar() } } } 這樣就完成了嗎?答案是:No。有web開發經驗的朋友們應該能感覺到,之前我們在web端用tabbar組件時,父組件是需要知道tabbar組件當前切換的狀態的,一般在web端,我們都會通過Vue的emit,或者在React中調用父組件傳過來的callback函數,做到讓父組件知曉這個tabbar的即時狀態。那麼在SwiftUI中,我們應該如何去做呢?

我們可以使用 @Binding 修飾器,在 tabbar 組件中,我們將 currentSelected 的 @State 修飾器改為 @Binding,如下所示: swift @State var currentSelected: Tab = .home // 舊 @Binding var currentSelected: Tab // 新 同時我們也可以把下方的 tabbar_Previews 註釋掉,因為我們已經不需要了~ 接着我們在 ContenView 中,將代碼改為: ```swift struct ContentView:View{ @State private var currentSelected:Tab = .location var body:some View{ VStack { switch currentSelected { case .home: HomeView() case .location: LocationView() case .collect: CollectView() case .mine: MineView() }

        tabbar(currentSelected:$currentSelected)
    }
}

} ``` 這樣我們就能實現根據不同的tab切換到不同的View啦,效果如下所示:

ddd.gif

至此,大功告成,我們已經完成了一個不錯的tabbar組件~

感謝您的閲讀,歡迎批評與指正,或在評論區進行交流~