【SwiftUI】カスタムTabViewを実装してみた

この記事はand factory.inc Advent Calendar 2022 19日目の記事です。
昨日は @arusu0629 さんの[個人開発アプリに Swift Package Manager を導入してみた](https://qiita.com/arusu0629/items/cb4eca682aba05c49a16)でした。

## はじめに
基本的な実装は経験があったので今回は少しアニメーションなども加えたカスタムなTabViewの実装を行ってみました。
- [TabView](https://developer.apple.com/documentation/swiftui/tabview)

## 完成形
![CustomTabView.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2951640/4809f634-7dcb-9d98-3bf3-925557ed675b.gif)

## 開発環境
- Xcode 14.1
- iOS 16.1

## TabのModelを作成
`TabBar`に表示する`enum`を定義
```swift:Tab.swift
enum Tab: CaseIterable {
    case home
    case search
    case ranking
    case book
    case setting
}
// MARK: - SF Symbols Name
extension Tab {
    func symbolName() -> String {
        switch self {
        case .home:
            return "house"
        case .search:
            return "magnifyingglass"
        case .ranking:
            return "crown"
        case .book:
            return "book"
        case .setting:
            return "gearshape"
        }
    }
}
```
- `allCases`を使用したいため`CaseIterable`プロトコルを使用
- `TabBar`に表示する画像は`SFSymbols`を参照するのでそれもここで書いておく
## 表示画面の作成
メインの`ContentView`は以下のように定義しました、この時点ではまだ`TabBar`は実装してません
```swift:ContentView
struct ContentView: View {
    init() {
        // デフォルトのTabBarは使用しないので隠しておく
        UITabBar.appearance().isHidden = true
    }
    @State var currentTab: Tab = .home
    var body: some View {
        VStack(spacing: 0) {
            TabView(selection: $currentTab) {
                Text("ホーム")
                    .tag(Tab.home)
                Text("検索")
                    .tag(Tab.search)
                Text("ランキング")
                    .tag(Tab.ranking)
                Text("ブック")
                    .tag(Tab.book)
                Text("設定")
                    .tag(Tab.setting)
            }
            Divider()  // 区切り線
        }
    }
}
```
- 状態監視のため`@State var currentTab: Tab`を定義
(初期値は`.home`としています)
- `Tab`切り替えにより画面が変化している事を知りたいため`Text()`にて表示名をつける

##  TabBarの実装
`TabBar`の実装は以下のように行いました。
```swift: CustomTabView
struct CustomTabBar: View {

    @Binding var currentTab: Tab

    var body: some View {
        GeometryReader { proxy in
            HStack(spacing: 0) {
                ForEach(Tab.allCases, id: \.hashValue) { tab in
                    Button {
                        currentTab = tab
                    } label: {
                        Image(systemName: tab.symbolName())
                            .renderingMode(.template)
                            .resizable()
                            .aspectRatio(contentMode: .fit)
                            .frame(width: 30, height: 30)
                            .frame(maxWidth: .infinity)
                            .foregroundColor(currentTab == tab ? .black : .gray)
                    }
                }
            }
            .frame(maxWidth: .infinity)
        }
        .frame(height: 30)
        .padding(.bottom, 10)
        .padding([.horizontal, .top])
    }
}
```
`ContentView`の`currentTab`との紐付けの為、`@Binding`を定義
```swift: CustomTabView
@Binding var currentTab: Tab
```
`HStack`の要素が知りたい為、`GeometryReader`を使用
```swift: CustomTabView
GeometryReader { proxy in
```
- `ForEach`で`enum`で定義した`Tab`の要素数を回して、`Image`やタップした処理などを設定する
- 下記処理にてTabの選択状態の色を変更
```swift: CustomTabView
.foregroundColor(currentTab == tab ? .black : .gray)
```
## 途中経過
- この時点で普通のTabViewのような動きにはなるので`CustomTabView`をメインの`ContentView`で呼び出し
- `currentTab`をここで紐付ける
```swift: ContentView
struct ContentView: View {
    init() {
        UITabBar.appearance().isHidden = true
    }
    @State var currentTab: Tab = .home
    var body: some View {
        VStack(spacing: 0) {
            TabView(selection: $currentTab) {
                Text("ホーム")
                    .tag(Tab.home)
                Text("検索")
                    .tag(Tab.search)
                Text("ランキング")
                    .tag(Tab.ranking)
                Text("ブック")
                    .tag(Tab.book)
                Text("設定")
                    .tag(Tab.setting)
            }
            Divider()
            CustomTabBar(currentTab: $currentTab)    // ここで呼び出す
        }
    }
}
```
##### 今の時点での動きはこんな感じ
![TabView.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2951640/31e5e932-e785-82dd-7c5e-482629bc8fc3.gif)

## アニメーションつけてみる
- `HStack`の横幅を取得
```swift: CustomTabView
    var body: some View {
        GeometryReader { proxy in
            /// HStackの横幅
            let width = proxy.size.width
            
            HStack(spacing: 0) {
```
- `Tab`ごとの`Index`を取得する関数の作成
```swift: CustomTabView
func getIndex() -> Int {
        switch currentTab {
        case .home:
            return 0
        case .search:
            return 1
        case .ranking:
            return 2
        case .book:
            return 3
        case .setting:
            return 4
        }
    }
```
- タップされたTabの位置を返す関数を作成、先程作成した`getIndex()`はここで呼び出す
- 引数の`width`は`HStack`の横幅
```swift: CustomTabView
func indicatorOffset(width: CGFloat) -> CGFloat {
        let index = CGFloat(getIndex())
        if index == 0 {
            return 0
        }
        /// Tabアイテムの横幅
        let buttonWidth = width / CGFloat(Tab.allCases.count)

        return index * buttonWidth
    }
```
最後にCustomTabViewへの実装は以下のようになってます。
```swift: CustomTabView
            HStack(spacing: 0) {
                ForEach(Tab.allCases, id: \.hashValue) { tab in
                    Button {
                        // ここで更新
                        withAnimation(.easeInOut(duration: 0.2)) {
                            currentTab = tab
                        }
                    } label: {
                        Image(systemName: tab.symbolName())
                            .renderingMode(.template)
                            .resizable()
                            .aspectRatio(contentMode: .fit)
                            .frame(width: 30, height: 30)
                            .frame(maxWidth: .infinity)
                            .foregroundColor(currentTab == tab ? .black : .gray)
                    }
                }
            }
            .frame(maxWidth: .infinity)
            .background(alignment: .leading) {    // ここでアニメーションさせたいオブジェクトを定義
                Circle()
                    .fill(.orange)
                    .frame(width: 25, height: 25)
                    .offset(x: 20) // 初期x座標
                    .offset(x: indicatorOffset(width: width)) // 移動後x座標
            }
```
- `HStack`の`background`に`Circle()`を表示。
- `offset(x:`にて`Circle()`の初期x座標、移動後のx座標を書いておく。
(初期: 固定値20、移動後: 先程作成した`indicatorOffset()`より位置座標を取得表示)
- `Tab`をタップしたタイミングで`withanimation(_:_:)`内で`currentTab`の更新を行う
[withAnimation(_:_:)](https://developer.apple.com/documentation/swiftui/withanimation(_:_:))については、弊社エンジニアの @ichikawa7ss が記事 　([【SwiftUI】明示的アニメーションと暗黙的アニメーションを理解して使い分けよう](https://qiita.com/ichikawa7ss/items/164a2dfd3c3ab2b1eae8#%E6%98%8E%E7%A4%BA%E7%9A%84%E3%82%A2%E3%83%8B%E3%83%A1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3))を書いてますのでそちらをご参照ください。

## 最後に
思っていたよりアニメーションが簡単に実装できたのが驚きでした。
今回は簡単なカスタムTabViewを作成しましたが、まだまだ応用が効きそうなので面白い実装ができたらまた記事にしてみます。

最後まで読んでいただきありがとうございます。
明日の[Advent Calendar](https://qiita.com/advent-calendar/2022/andfactory)の記事もお楽しみに:santa: