SwiftUI の TabView、完全に理解した

## 上タブでインタラクティブなインジケーターを作りたい

iOS アプリを作る際に、Swift はとても便利である。SwiftUI は、UI を組み立てていく上でパズル？的な感覚でホイホイ仕上がっていくのが気持ちいい。何がどっち方向にどう重なっているのかがイメージできれば、とても簡単だ。

今回は、これ使わないアプリあるの？ってくらい使用頻度の高い `TabView` を深堀って行きたい。

現在の Swift の stable バージョンは、`6.0.1` だ。それで進めることにする。対象は iPhone で iOS 17+。

最終的に作りたいものは、こんな感じ。

![intaractiveTab.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/447b260a-7b82-951e-40e9-665cc8d8681d.gif)

極めて普通。なのだが、実装的には若干トリッキー？だ。
いきなりこれを目指すと、分かりづらいので Step by Step で進める。理屈が分かれば、何でも自由自在に組めるようになる。

## 一番基本の TabView

```swift:BasicTabView.swift
import SwiftUI

struct BasicTabView: View {
    @State var selectedTab: Int = 0
    var body: some View {
        TabView(selection: $selectedTab) {
            PlainPage(text: "Page-0")
                .tag(0)
                .toolbarBackground(.visible, for: .tabBar)
                .tabItem {
                    Label("Menu-0", systemImage: "figure.walk.circle")
                }
            PlainPage(color: .blue, text: "Page-1")
                .tag(1)
                .toolbarBackground(.visible, for: .tabBar)
                .tabItem {
                    Label("Menu-1", systemImage: "figure.walk.diamond")
                }
            PlainPage(color: .green, text: "Page-2")
                .tag(2)
                .toolbarBackground(.visible, for: .tabBar)
                .tabItem {
                    Label("Menu-2", systemImage: "figure.run.square.stack")
                }
        }
    }
}

#Preview {
    BasicTabView()
}
```

画面下部に、タブメニューがくっついてるヤツ。これが言ってみれば Root Layout みたいな使われ方をすることが多いものと思われる。うっかり iPad で見ると、いやーんな感じに表示されるので、注意されたい。

`TabView` には、くっつく横スクロールのような `.page` スタイルがあり、`.tabViewStyle(.page)` とすることで、それが実現できる。画面下部に現れるドットが並ぶヤツを消したい場合は、`.page(indexDisplayMode: .never))` と指定すれば良い。

```swift
        TabView(selection: $selectedTab) {
          ...
        }
        .tabViewStyle(.page(indexDisplayMode: .never)) // indexDisplayMode: always, automatic, never
```

他にもあるけど、色々いじってみてね、ということで省略。なお、上記サンプルコード内の `PlainPage` は、何でも良かったので、仮で以下のようにして使い回す。実際には、ScrollView の中に入れたりするが、今回説明したいこととは関係ないので、これで。

```swift:PlainPage.swift
import SwiftUI

struct PlainPage: View {
    var color: Color = .pink
    var text: String = "Plain Page View"
    var textColor: Color = .white
    
    var body: some View {
        ZStack {
            color.ignoresSafeArea()
            Text(text)
                .font(.largeTitle)
                .foregroundColor(textColor)
        }
    }
}

#Preview {
    PlainPage(color: .green, text: "Sample Page View")
}
```

BasicTabView.swift はコードがダサいので、以下のように修正する。

```swift: NormalizedTabView.swift
import SwiftUI
// struct MenuItem -> Constant

struct NormarizedBasicTabView: View {
    @State var selectedTab: Int = 0
    
    let menuItems: [MenuItem] = [
        .init(text: "Menu-0", label: "Label-0", color: .pink, icon: "figure.walk.circle"),
        .init(text: "Menu-1", label: "Label-1", color: .blue, icon: "figure.walk.diamond"),
        .init(text: "Menu-2", label: "Label-2", color: .green, icon: "figure.walk.triangle")
    ]
    
    var body: some View {
        TabView(selection: $selectedTab) {
            ForEach(0..<menuItems.count, id: \.self) { index in
                PlainPage(color: menuItems[index].color, text: menuItems[index].text)
                    .tag(index)
                    .toolbarBackground(.visible, for: .tabBar)
                    .tabItem {
                        Label(menuItems[index].label, systemImage: menuItems[index].icon)
                    }
            }
        }
    }
}

#Preview {
    NormarizedBasicTabView()
}
```

```swift
struct MenuItem {
    let text: String
    let label: String
    let color: Color
    let icon: String
}
```

## 上タブ

次に、画面上部にタブメニューが表示されるものを作りたい。

![upperTab.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/7995f3b6-4701-4d70-7f8b-2ebef44aec77.gif)


構造は以下のように考える。

1. 画面を VStack でタブメニュー部分とページ部分に分割する
1. タブメニュー部分は、HStack で横並びにする
1. Body 部分は `.tabViewStyle(.page(indexDisplayMode: .never))` な TabView にする


```swift
    var body: some View {
        VStack(spacing: 0) {
            // MARK: TopMenuBar
            HStack(spacing: 0) {
                ForEach(0..<menuItems.count, id: \.self) { index in
                    Button {
//                        selectedTab = index // not smooth
                        withAnimation {
                            selectedTab = index
                        }
                    } label: {...}
                }
            }
            .frame(height: 50)
            
            // MARK: TabView
            TabView(selection: $selectedTab) {
                ForEach(0..<menuItems.count, id: \.self) { index in
                    PlainPage(color: menuItems[index].color, text: menuItems[index].text)
                        .tag(index)
                }
            }
            .tabViewStyle(.page(indexDisplayMode: .never))
            .ignoresSafeArea()
        }
    }
```
ピッタリくっつけたいので、`spacing: 0` としている。

タブの中身をボタンにしているのは、押したら `selectedTab` を切り替えたいためで、必要なければ `Text` などでも良い。Button Action で、`withAnimation` にしないと、押した際のヌルヌル感が損なわれる。withAnimation にすることで、押すと Body 部がパッと切り替わるのが、ヌルヌルっとスライドして切り替えるようになる。

次に、どのタブが選択されているのか分かるように、Active なタブには下線（インジケーター）的なものをつけたい。これには色々なやり方があるような気もするが、今回は、以下のように細長い `Rectangle` を VStack で文字の下に配置する。Active なものは色が付き、それ以外は、透明（`.clear`）。

```swift
                    Button {
                        withAnimation {
                            selectedTab = index
                        }
                    } label: {
                        VStack(spacing: 0) {
                            Spacer()
                            Text(menuItems[index].label)
                                .foregroundColor(index == selectedTab ? .black : .gray)
                            Spacer()
                            Rectangle()
                                .fill(index == selectedTab ? .black : .clear)
                                .frame(height: 3, alignment: .bottom)

                        }
                    }
```

## 横スクロールする上タブ

タブの数が多い場合、画面分けたら？と思うが、タブ自体を横スクロールするようなことも考えられる。

![scrollableTab.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/785f2e50-5bd9-fb0c-8538-60ace4817476.gif)


横スクロールは簡単で、メニュー部分を `ScrollView(.horizontal)` で囲ってやれば良い。

```swift
                ScrollView(.horizontal, showsIndicators: false) {
                    // MARK: TopMenuBar
                    HStack(spacing: 0) {...}
                }
```
横スクロールのインジケーターはいらないので、`showsIndicators: false` とした。
また、このままだとぎっちり詰まってしまうので、タブ幅を固定した。

```swift
Text(menuItems[index].label)
  .foregroundColor(index == selectedTab ? .black : .gray)
  .frame(width: 120)
```

これで、一応できたが、例えば Body 部を横に進めていき、Menu 部で初期表示位置が画面外のページにたどり着くと、Menu 部を自分でスクロールさせないといけなくて、しんどい。なので、Focus があたっているタブが勝手に表示されるように `ScrollViewReader` を使う。

```swift
            ScrollViewReader { scrollProxy in
                ScrollView(.horizontal, showsIndicators: false) {
                    ...
                }
                .onChange(of: selectedTab) {
                    withAnimation {
                        scrollProxy.scrollTo(selectedTab, anchor: .center)
                    }
                }
            }

```

Button アクションで、`scrollTo` すると、Body 部とは連携されないので、`ScrollView` に `onChange` でつける。

なお、`.onChange` は、iOS 17 以降で仕様が代わり、以前の書き方は非推奨になった。のに、非推奨の書き方をサジェストするのは、XCode の一人ボケツッコミなのだろうか。

## ついてくるインジケーター

さて、これでだいたい OK な気がしなくもないが、アクティブなタブを表すインジケーター的下線が、これまでの方法だとパチっパチっと切り替わり、Body 部が左右にスワイプしてスクロールできるのに対して、非連続的で少々違和感がある。どう実装しているか分かってるから納得いかないだけかもしれないが、インジケーターもヌルヌル動かしたい。こういうの、大事。

Body 部の横方向の座標に合わせてインジケーターをインタラクティブに動かしたい。動かしたいのだが、横方向の座標が GeometryReader などでは、うまく取れなかった。

色々調べてみると、完全にインタラクティブにインジケーターを操作するために、TabView を諦め、horizontal な ScrollView にして実現するという方法があるようだ。ScrollViewReader で座標を取得する。

ただ、読んでパッと理解できない程度の複雑さになるのと、やっぱり TabView という偉大なアセットを放棄してしまうもったいなさとを踏まえ、あがいてみることにした。

### 実装方針
横スクロールするタブは忘れ、画面内に収まる固定のタブをいじる。
まずはタブメニュー部の構造を変える。

![Screenshot 2024-11-30 at 15.48.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/9d621395-9bf2-8ea4-e950-e130a38ac7de.png)

このように組み直し、Rectangle の開始位置を選択されているタブの位置にフワっと動かしてやれば良い作戦。

TabView の `selecton` が切り替わるタイミングは、各タブの表示領域が50％を超えたタイミング、つまり引っ張ってる最中に切り替わるので、インジケーターが動き出すタイミングも、完全に Body 部が切り替わる前になる。あとは、animation の duration を調整してやれば、違和感なくヌルヌル感を演出することができる。


### 実装
図解しておいてなんだが、今回は VStack ではなく、`overlay` で組む。そっちの方が、簡単だから。Rectangle の長さを計算するために、画面の横幅が必要になるので、`GeometryReader` を使う。

```swift
    @State var indicatorOffset: CGFloat = 0

    var body: some View {
        GeometryReader { geometry in
            VStack(spacing: 0) {
                // MARK: TopMenuBar
                HStack(spacing: 0) {
                    ForEach(0..<menuItems.count, id: \.self) { index in
                        Button {
                            withAnimation {
                                selectedTab = index
                            }
                        } label: {
                            Spacer()
                            Text(menuItems[index].label)
                                .foregroundColor(index == selectedTab ? .black : .gray)
                            Spacer()
                        }
                    }
                }
                .frame(height: 50)
                .overlay(alignment: .bottomLeading) {
                    Rectangle()
                        .frame(width: geometry.size.width / CGFloat(menuItems.count), height: 3)
                        .offset(x: indicatorOffset, y: 0)
                }

            // MARK: TabView
            ....
            }
        }
    }
```

Button の label 内の VStack をやめ、Rectangle を overlay に切り出した。Rectangle の offset で指定している `indicatorOffset` の計算は、onChange で行う。

```swift
                // MARK: TopMenuBar
                HStack(spacing: 0) {
                    ...
                }
                .frame(height: 50)
                .overlay(alignment: .bottomLeading) {
                    ...
                }
                .onChange(of: selectedTab) {
                    withAnimation(.easeInOut(duration: 0.3)) {
                        indicatorOffset = geometry.size.width / CGFloat(menuItems.count) * CGFloat(selectedTab)
                    }
                }
```

好みの問題な気もするが、`duration` は、だいたい 0.3 くらいにしておくと、違和感なくヌルヌルになる。

![intaractiveTab.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/447b260a-7b82-951e-40e9-665cc8d8681d.gif)

どうだろう？むしろ完全に連動していない方が良い気さえしてきた笑

以下がコード全文だ。

```swift:UpperIntaractiveIndicatorTabView.swift
import SwiftUI

struct UpperIntaractiveIndicatorTabView: View {
    @State var selectedTab: Int = 0
    @State var indicatorOffset: CGFloat = 0
    
    var menuItems: [MenuItem] = []
    
    var body: some View {
        GeometryReader { geometry in
            VStack(spacing: 0) {
                // MARK: TopMenuBar
                HStack(spacing: 0) {
                    ForEach(0..<menuItems.count, id: \.self) { index in
                        Button {
                            withAnimation {
                                selectedTab = index
                            }
                        } label: {
                            Spacer()
                            Text(menuItems[index].label)
                                .foregroundColor(index == selectedTab ? .black : .gray)
                            Spacer()
                        }
                    }
                }
                .frame(height: 50)
                .overlay(alignment: .bottomLeading) {
                    Rectangle()
                        .frame(width: geometry.size.width / CGFloat(menuItems.count), height: 3)
                        .offset(x: indicatorOffset, y: 0)
                }
                .onChange(of: selectedTab) {
                    withAnimation(.easeInOut(duration: 0.3)) {
                        indicatorOffset = geometry.size.width / CGFloat(menuItems.count) * CGFloat(selectedTab)
                    }
                }
 
                // MARK: TabView
                TabView(selection: $selectedTab) {
                    ForEach(0..<menuItems.count, id: \.self) { index in
                        PlainPage(color: menuItems[index].color, text: menuItems[index].text)
                            .tag(index)
                    }
                }
                .tabViewStyle(.page(indexDisplayMode: .never))
                .ignoresSafeArea()
            }
        }
    }
}

#Preview {
    let menuItems: [MenuItem] = [
        .init(text: "Menu-0", label: "Label-0", color: .pink, icon: "figure.walk.circle"),
        .init(text: "Menu-1", label: "Label-1", color: .blue, icon: "figure.walk.diamond"),
        .init(text: "Menu-2", label: "Label-2", color: .green, icon: "figure.walk.triangle")
    ]
    UpperIntaractiveIndicatorTabView(menuItems: menuItems)
}

```

## 課題

これであなたも TabView マスター。どんなものにも対応できるはずだ。本当にできるか、TabView を使って以下のような画面を実装せよください。

![Nested.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/f04fa013-8b8a-cfe8-8d64-336cfad7ce7b.gif)

ポイントは、上タブのデザインが変更になり、インジケーターが下線ではなく、背景になっている点だ。overlay だと上に重なってしまうので、そうならないようにするにはどうするのか？ TabView がネストしているのは、驚くほどどうでも良い。

<details><summary>ほぼ答えなヒント</summary>

![Screenshot 2024-11-30 at 17.01.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/bc08e94f-d8f9-4ea1-0844-c15233d771ad.png)

</details>

Good Luck 👍️