SwiftUI 2.0 でカスタムタブビューを作ってみる

## SwiftUI2.0で追加されたAPI

SwiftUI 2.0 で追加された`PageTabViewStyle`を使ってみたかったので、カスタムタブのようなものを作ってみました。

- [TabView](https://developer.apple.com/documentation/swiftui/tabview)
- [PageTabViewStyle](https://developer.apple.com/documentation/swiftui/pagetabviewstyle?changes=l_6_4__8)

## とりあえずの完成形

こういったものを作っていこうと思います。
![999.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7478/19103a85-3f92-36dc-ce41-f08edff69ea1.gif)
GitHubはこちらです。
https://github.com/hoshi005/custom-tab

## 開発環境

- Xcode 12.1
- iOS 14.1

## 事前準備

- アニメーションgifを利用したかったので[SDWebImageSwiftUI](https://github.com/SDWebImage/SDWebImageSwiftUI)を利用しています。
- [ぴよたそ](https://hiyokoyarou.com)さんからアニメーションgifファイルを利用させてもらっています。
  - https://hiyokoyarou.com/nyoronyoro-gif/
  - https://hiyokoyarou.com/cat-to-hiyoko-aruku/
  - https://hiyokoyarou.com/odoru-penguin/
  - https://hiyokoyarou.com/tobu-penguin/

## タブ部分の作成

![スクリーンショット 2020-10-21 21.12.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7478/4b5d450b-865c-c294-ae53-17034e0e89ee.png)

アニメーションgifファイルはこのように名前をつけて配置したので、名前を合わせる形でenumを定義しています。

```swift
enum TabItem: String, CaseIterable {
    case piyo
    case pen
    case neko
    case tobipen
    
    var name: String {
        "\(self.rawValue).gif"
    }
}
```

タブの一つ一つを表すための`TabItemView`を追加して、以下のように定義しました。

```swift
struct TabItemView: View {
    
    let tabItem: TabItem
    @Binding var selected: TabItem
    
    var body: some View {
        // SDWebImageSwiftUIのimportが必要.
        AnimatedImage(name: tabItem.name)
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: 40)
            .onTapGesture {
                selected = tabItem // タップしたら自身をselectedに.
            }
    }
}
```

メインとなる`ContentView`には、以下のようにタブビューを定義しました。

```swift
struct ContentView: View {
    
    // タブの選択値と初期値.
    @State private var selected: TabItem = .piyo
    
    var body: some View {
        
        // タブビュー部分.
        HStack {
            ForEach(TabItem.allCases, id: \.self) { tabItem in
                TabItemView(tabItem: tabItem, selected: $selected)
            }
        }
        .padding(.vertical, 10.0)
        .padding(.horizontal, 20.0)
        .background(Color.white.clipShape(Capsule()))
        .shadow(color: Color.black.opacity(0.3), radius: 5, x: -5, y: 5)
    }
}
```

出来上がったのはこちらです。このままだと、選択状態がよくわからないですね。
![001.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7478/35d0c1eb-0a02-e3d8-fdc4-ca4b55f3fba5.gif)

## 選択状態がわかるように見た目を調整する

選択時／非選択時で見た目を切り替えるため、`TabItemView`を以下のように書き換えます。

- frameを調整
- paddingを調整
- offsetを調整
- タップ時の処理にアニメーションを伴わせる

```swift
var body: some View {
   AnimatedImage(name: tabItem.name)
       .resizable()
       .aspectRatio(contentMode: .fit)
       // 選択状態によって、サイズや間隔を調整する.
       .frame(width: tabItem == selected ? 100 : 40)
       .padding(.vertical, tabItem == selected ? -30 : 0)
       .padding(.horizontal, tabItem == selected ? -14 : 16)
       .offset(y: tabItem == selected ? -15 : 0)
       .onTapGesture {
           withAnimation(.spring()) {
               selected = tabItem // タップしたら自身をselectedに.
           }
       }
}
```

見た目はこのようになります。選択状態が一目でわかるようになりました。
![002.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7478/6a4be9d1-e290-7255-8410-4f7607ffa977.gif)

## 背景色の設定と、タブの配置調整

`ContentView`の見た目を調整します。

- 全体を`ZStack`で囲う
- 最背面に`Color("bg").ignoresSafeArea()`を配置して背景色とする
- タブビュー部分を`VStack`と`Spacer`を利用して画面下部に配置

```swift
var body: some View {
    
    ZStack {
        
        // 背景色.
        Color("bg").ignoresSafeArea()
        
        VStack {
            
            Spacer(minLength: 0)
            
            // タブビュー部分.
            HStack {
                ForEach(TabItem.allCases, id: \.self) { tabItem in
                    TabItemView(tabItem: tabItem, selected: $selected)
                }
            }
            .padding(.vertical, 10.0)
            .padding(.horizontal, 20.0)
            .background(Color.white.clipShape(Capsule()))
            .shadow(color: Color.black.opacity(0.3), radius: 5, x: -5, y: 5)
        }
    }
}
```

見た目はこうなりました。それっぽくなってきましたね。
![003.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7478/c93ab8da-ebdf-7b09-a979-a37d58ef706e.gif)

## 画面をタブで切り替える

タブは用意したので、このタブに連動して画面が切り替わるようにします。

まずはダミーで画面部分を用意します。
適当なので、こちらは好きに作ってもらって良いと思います。

```swift
struct HomeView: View {
    var body: some View {
        Text("Home")
            .font(.largeTitle)
            .fontWeight(.heavy)
            .foregroundColor(.red)
    }
}

struct ListView: View {
    var body: some View {
        Text("List")
            .font(.largeTitle)
            .fontWeight(.heavy)
            .foregroundColor(.green)
    }
}

struct SearchView: View {
    var body: some View {
        Text("Search")
            .font(.largeTitle)
            .fontWeight(.heavy)
            .foregroundColor(.blue)
    }
}

struct SettingView: View {
    var body: some View {
        Text("Setting")
            .font(.largeTitle)
            .fontWeight(.heavy)
            .foregroundColor(.yellow)
    }
}
```

最後に、これらのViewを`TabView`で定義し、カスタムタブと連動するようにします。

- `TabView`の引数に`selected`を指定することで、カスタムタブと連動させる
- `PageTabViewStyle`を指定することで、横スワイプでの切り替えを可能にする

```swift
ZStack {
    
    // 背景色.
    Color("bg").ignoresSafeArea()
    
    // メイン画面部分はTabViewで定義.
    TabView(selection: $selected) {
        HomeView()
            .tag(TabItem.piyo)
        ListView()
            .tag(TabItem.pen)
        SearchView()
            .tag(TabItem.neko)
        SettingView()
            .tag(TabItem.tobipen)
    }
    // PageTabスタイルを利用する(インジケータは非表示).
    .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
    
    VStack {
        // 省略.
    }
}
```

## まとめ

タブの定義がずいぶん簡単にできる印象ですが、それ以上に「切り替え用のUI」を簡単に作成できるのは嬉しいですね。
[こちらの記事](https://qiita.com/hoshi005/items/83cafdc3270bd0e248c0)で作った切替ビューでも同じようなことができそうです。もしよかったら試してみてください。