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组件~

感谢您的阅读,欢迎批评与指正,或在评论区进行交流~