SwiftUI 집중: 사용자 정의 Tabbar 구성 요소(전환 효과 포함)

Tabbar 는 우리가 일상적인 개발에서 자주 사용하는 구성 요소입니다.하지만 SwiftUI에서 Tabbar는 현재 TabView 관련 구현만 있어 일상적인 개발 요구 사항을 분명히 충족하지 못하므로 사용자 정의 Tabbar를 구현하는 방법을 살펴보겠습니다 .

1. TabView를 사용하여 달성

1-1: 문서 보기

TabView 를 사용하기 전에 이전 규칙이 있습니다. 먼저 그림과 같이 문서를 살펴보십시오. 이미지.png문서에서 제공하는 텍스트 설명 및 관련 예제를 통해 TabView 가 .tabItem 수정자 와 함께 사용됨 을 알 수 있습니다. 문서를 주의 깊게 읽어본 사람은 문서에 다음과 같은 문장이 설명되어 있음을 알 수 있습니다.

각 탭 항목에 레이블을 사용하거나 선택적으로 텍스트, 이미지 또는 이미지 뒤에 텍스트를 사용하십시오. 다른 유형의 보기를 전달하면 표시되지만 비어 있는 탭 항목이 생성됩니다.

대충 번역하면: View 프로토콜에 따라 .tabItem 수정 자의 매개변수 전달을 보지 마세요 . 여기서는 레이블 컨트롤 만 받거나 텍스트 컨트롤과 이미지 컨트롤을 전달합니다 .

1-2: 코드 구현

위의 일반적인 설명에 따르면 다음 코드를 쉽게 작성할 수 있습니다.

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)
//                        }
                    }
                }
            }
        }
    }
}
复制代码

그 효과는 그림에 나와 있습니다.

이미지.png

有的朋友可能会问,为啥你的 .tabItem 后面,不用跟 .tag 修饰符去给视图设置唯一值呢?嘿嘿,我们来看看 .tag 的文档,它是这么描述的:

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

呜呼~文档告诉我们 ForEach 使用相应元素的 id 参数会自动标记并应用于每个视图。

那么上方的代码,还有可以优化的点吗?答案是:有的。

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 修饰符,如下所示:

TabView(selection: $currentSelectd) {
    ForEach(tabItems){ item in
        Text(item.text).tabItem{
            Label(item.text, systemImage: item.icon)
        }
    }
}
.tint(.pink)
复制代码

样式如图所示:

이미지.png

如果我们想隐藏下方的 tabbar 栏,通过手势滑动来切换视图,可以使用 .tabViewStyle 修饰符,如下所示:

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 修饰符,如下所示:

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 如此"强大"自定义样式功能后,相信不少人已经跟我一样,内心缓缓说出两个字:

이미지.png

2.自定义 Tabbar 组件

2-1: 新建相关目录结构

我们新建 Views 文件夹,在里面新建4个视图文件,并简单的将视图名作为 Text 的输入值即可,如图所示:

이미지.png

接着我们新建 Model 文件夹,在里面新建 Tab 文件,并写入以下代码:

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,渲染出相关元素。代码如下:

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

接下来,我们加上一些想要的样式,包括选中后的图标样式,整体背景色等,代码如下:

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,我们先写个小例子来感受一下:

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 版本出的修饰符,我们来看看它的参数是怎么传的。

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背景增加过渡效果

通过以上的了解,我们可以对之前的代码,稍作修改:

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 修饰符,如下所示:

withAnimation(.spring(response: 0.3,dampingFraction: 0.7)) {
  currentSelected = tabItem
}
复制代码

效果如下:

ddd.gif

怎么样,动画效果是不是更加流畅了~

2-5:完善 Tabbar

在上方的代码中,我们已经做出了一个大致的 Tabbar 组件了~但还是有问题,正常的tabbar是置于底部的,我们需要把它先放到页面的底部去,如下所示:

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넣습니다 .

struct ContentView:View{
  var body:some View{
    VStack {
      tabbar()
    }
  }
}
复制代码

끝났어? 내 대답은 아니오 야. 웹 개발 경험이 있는 친구들은 이전에 웹 사이드에서 탭바 컴포넌트를 사용했을 때 부모 컴포넌트가 탭바 컴포넌트 의 현재 전환 상태를 알아야 한다는 것을 느낄 수 있어야 합니다 .일반적으로 웹 사이드에서는 Vue의 emit , 또는 React Call 에서 부모 구성 요소 가 tabbar 의 즉각적인 상태 를 알 수 있도록 부모 구성 요소 에서 전달된 콜백 함수 . 그렇다면 SwiftUI에서는 어떻게 해야 할까요?

@Binding 데코레이터를 사용할 수 있습니다 . 탭바 구성 요소 에서 아래와 같이 currentSelected의 @State 데코레이터를 @Binding 으로 변경합니다.

@State var currentSelected: Tab = .home // 旧
@Binding var currentSelected: Tab // 新
复制代码

동시에 더 이상 필요하지 않기 때문에 아래의 tabbar_Previews를 주석 처리할 수도 있습니다. 그런 다음 ContenView 의 코드를 다음과 같이 변경합니다 .

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)
        }
    }
}
复制代码

이러한 방식으로 다른 탭에 따라 다른 보기로 전환할 수 있으며 그 효과는 다음과 같습니다.

ddd.gif

여기까지 하면 멋진 탭바 컴포넌트가 완성되었습니다~

읽어 주셔서 감사합니다. 비판과 수정을 환영하거나 댓글 영역에서 소통하십시오 ~

추천

출처juejin.im/post/7205085465083641917