【SwiftUI】TCAでTabViewをいい感じに実装する

# はじめに

こんにちは。
今回はTCAでTabViewをいい感じに実装する方法を記事にまとめてみました。

TabViewを使用する際、タブの選択状態にアクセスしたり、タブ切り替え時に任意の処理を行いたいことはよくありますよね。しかし、[TCAのチュートリアル](https://pointfreeco.github.io/swift-composable-architecture/main/tutorials/composablearchitecture/01-04-composingfeatures/)で紹介されているTabViewの実装はかなりシンプルであるため、いい感じのTabViewを実装したい場合には少し情報が足りないかもしれません。

そこで本記事では、TabViewのよくあるユースケースの実装方法を紹介します。
具体的には以下のとおりです。

- タブ選択状態に応じてラベルの表示をカスタマイズする
- 現在選択中のタブのラベルをタップした時に、そのタブのプッシュ遷移状態をリセットする
- 現在選択中のタブのラベルをタップした時に、そのタブのスクロール位置を一番上に戻す

本記事がTCAでTabViewを実装する際の参考になれば幸いです。

## 対象読者

- TCAを使用している人
- TabViewをカスタマイズしたい人

:::note warn
本記事の実装環境は以下の通りです。

- Xcode 16.1
- Swift 5.10
- iOS 18.1
- swift-composable-architecture 1.17.0

:::

# 実装

## 今回つくるもの

今回は以下のような2つのタブ（Pushタブ、Scrollタブ）を持つTabViewを実装します。

| Pushタブ | Scrollタブ |
| --- | --- |
| <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646568/39745f17-a1ad-e2fd-7181-8aa805509840.png" width="300"/> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646568/f7ba63eb-d95b-68c2-95d1-28989e26c5d0.png" width="300"/> |

### Pushタブの仕様

- プッシュ遷移が可能
- to Childボタンをタップすると、再帰的にChild画面に遷移できる
- ️タブの選択状態に応じて以下のようにラベルのシステムイメージを変更する⭐
  - 選択されている: `text.page.fill`
  - 選択されていない:`apple.logo`
- Pushタブ選択時にPushタブのラベルをタップするとプッシュ遷移状態をリセットする⭐

### Scrollタブの仕様

- スクロールが可能
- タブの選択状態に応じて以下のようにラベルのシステムイメージを変更する⭐
  - 選択されている: `scroll.fill`
  - 選択されていない:`apple.logo`
- Scrollタブ選択時にScrollタブのラベルをタップするとスクロール位置を一番上に戻す⭐

以下では⭐️の部分を中心に実装方法を説明します。

## TabViewの実装

いきなり⭐️の部分の実装方法を説明するとわかりにくいため、まずはTabViewの基本的な実装方法を説明します。

### Reducer

まずTabの状態を管理するためのReducerについて見ていきます。

Stateは以下のように定義します。

```swift: AppRouter.swift
    @ObservableState
    struct State: Equatable {
        public init() {}

        var pushNavigation = MyPushNavigation.State()
        var scroll = MyScroll.State()
        var selectedTab = Tab.pushNavigation

        enum Tab: Equatable {
            case pushNavigation
            case scroll
        }
    }
```

ここでは、それぞれのタブの状態を管理するために`pushNavigation`プロパティと`scroll`プロパティを持ち、選択中のタブを`selectedTab`で管理しています。
また、`selectedTab`は`Tab`というenumで定義しています。

次にActionは以下のとおりです。

```swift: AppRouter.swift
    enum Action {
        case selectedTabChanged(State.Tab) // タブ切り替え用アクション
        case pushNavigation(MyPushNavigation.Action)
        case scroll(MyScroll.Action)
    }
```

ここでは、タブ切り替え時に呼び出す`selectedTabChanged`アクションをまず定義しています。
また、それぞれのタブのReducerを埋め込むために`pushNavigation(MyPushNavigation.Action)`と`scroll(MyScroll.Action)`を定義していますが、これらは今回使用しません。[^1]

[^1]: これらはタブ（子Reducer）内のアクションを検知した場合に、親Reducer側で何らかの処理を行いたい場合に使用します。このパターンは[【SwiftUI】マルチモジュール構成のためのTCA×Routerパターンを考えてみた](https://qiita.com/sho_sininen/items/cb7cbc22143668c96030#%E8%A9%B3%E7%B4%B0)で頻繁に使用しているため、気になる方はそちらを参照してください！

次にReducer部分を見ていきます。

```swift: AppRouter.swift
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .pushNavigation:
                return .none
            case .scroll:
                return .none
            case let .selectedTabChanged(nextTab):
                if nextTab == state.selectedTab {
                    // 現在表示しているタブと同じ場合
                    switch nextTab {
                    case .pushNavigation:
                        // プッシュ遷移状態をリセットする
                        // ...
                    case .scroll:
                        // スクロール位置を一番上に戻す
                        // ...
                    }
                } else {
                    // 現在表示しているタブと異なる場合
                    state.selectedTab = nextTab
                    return .none
                }
            }
        }
        Scope(state: \.pushNavigation, action: \.pushNavigation) {
            MyPushNavigation()
        }
        Scope(state: \.scroll, action: \.scroll) {
            MyScroll()
        }
    }
```

ここでは、タブ切り替え時に`selectedTabChanged`アクションの具体的な処理を記述しています。
このアクションが呼ばれた時、次に選択されるタブと現在選択中のタブの比較を行い、以下のように処理を分岐しています。

- 同じ場合
  - Pushタブが選択された場合: プッシュ遷移状態をリセットする
  - Scrollタブが選択された場合: スクロール位置を一番上に戻す
- 異なる場合
  - 選択中のタブを更新する

上記の「同じ場合」の処理については後述します。

### View

次にTabViewのView側の実装を見ていきます。

```swift: ContentView.swift
    @Bindable var store = StoreOf<AppRouter>(initialState: AppRouter.State()) {
        AppRouter()
            ._printChanges()
    }

    var body: some View {
        TabView(selection: $store.selectedTab.sending(\.selectedTabChanged)) {
            Tab(value: .pushNavigation) {
                MyPushNavigationView(store: store.scope(state: \.pushNavigation, action: \.pushNavigation))
            } label: {
                // タブの選択状態に応じてラベルの表示をカスタマイズ
                // ...
            }

            Tab(value: .scroll) {
                MyScrollView(store: store.scope(state: \.scroll, action: \.scroll))
            } label: {
                // タブの選択状態に応じてラベルの表示をカスタマイズ
                // ...
            }
        }
    }
```

ポイントとなるのは、`TabView`の`selection`に`$store.selectedTab.sending(\.selectedTabChanged)`を設定している点です。これにより、タブの選択状態が変更された際に`selectedTabChanged`アクションが呼ばれるようになります。

また、`Tab`の`label`パラメーターでタブバーのラベルをカスタマイズできます。こちらは後ほど説明します。

## いい感じにする

これでTabViewの基本的な実装は完了しましたので、ここからいい感じに改良します！

### タブの選択状態に応じてラベルのシステムイメージを変更する

それではPushタブのラベルをカスタマイズするコードを見ていきます。

```swift: ContentView.swift
            Tab(value: .pushNavigation) {
                MyPushNavigationView(store: store.scope(state: \.pushNavigation, action: \.pushNavigation))
            } label: {
                // タブの選択状態に応じてラベルの表示をカスタマイズ
                Image(systemName: store.selectedTab == .pushNavigation ? "text.page.fill" : "apple.logo") // 👈
                    .resizable()                                                                          // 👈 
                    .frame(width: 24, height: 24)                                                         // 👈
                Text("Push")                                                                              // 👈
            }
```

ここでは`store.selectedTab`にアクセスすることでタブの選択状態が取得できるため、これを`Image`の`systemName`パラメーター内の三項間演算子で使用して、`text.page.fill`か`apple.logo`のどちらを使用するか制御しています。[^2]

またScrollタブも上記の方法でラベルをカスタマイズできます。

[^2]: 実際の開発では、おそらくオリジナルの画像が使用されることが多いと思いますが、ここでは説明を簡略化するためにシステムイメージを使用しています！

### Pushタブのラベルタップでプッシュ遷移状態をリセット

処理の基本的な流れは以下の通りです。

1. Pushタブのラベルをタップする ✅
1. `selectedTabChanged`アクションが呼ばれる ✅
1. `selectedTabChanged`アクション内で、PushタブのReducerのロジックを呼び出す ❌
1. 呼び出されたロジック内で、プッシュ遷移状態をリセットする ❌

このうち、✅の部分はすでに実装していますが、❌の部分がまだ実装されていません。

まずは`selectedTabChanged`アクション内でPushタブのReducerのロジックを呼び出す処理を追加します。

```swift: AppRouter.swift
    case let .selectedTabChanged(nextTab):
        if nextTab == state.selectedTab {
            switch nextTab {
            case .pushNavigation:
                return MyPushNavigation().reduce(into: &state.pushNavigation, action: .toRoot) // 👈
                    .map(Action.pushNavigation)                                                // 👈
            case .scroll:
                // スクロール位置を一番上に戻す
                // ...
            }
        } else {
            state.selectedTab = nextTab
            return .none
        }
```

ここでは、PushタブのReducer（`MyPushNavigation`）の`toRoot`アクションのロジックを呼び出しています。

:::note info
ここではPushタブのActionではなく、Reducerの`reduce`メソッドを使用することでPushタブのReducerが持つロジック（今回はプッシュ遷移状態のリセット）を呼び出しています。
これはActionを実行するコストが高いためであり、[公式ドキュメント](https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/performance/)にその旨が記載されいます。
:::

それでは、`toRoot`アクションの詳細、呼び出されたロジック内でプッシュ遷移状態をリセットする処理を見ていきます。

```swift: MyPushNavigation.swift
@Reducer
public struct MyPushNavigation {
    public init() {}

    @Reducer
    public enum Path {
        case child(MyPushNavigationChild)
    }

    @ObservableState
    public struct State: Equatable {
        public init() {}
        var path = StackState<Path.State>()
    }

    public enum Action {
        case path(StackActionOf<Path>) 
        case toChild
        case toRoot
    }

    public var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .path(.element(id: _, action: .child(.delegate(.nextChildButtonTapped)))):
                state.path.append(.child(MyPushNavigationChild.State()))
                return .none
            case .path:
                return .none
            case .toChild:
                state.path.append(.child(MyPushNavigationChild.State()))
                return .none
            case .toRoot:
                state.path.removeAll() // 👈
                return .none
            }
        }
        .forEach(\.path, action: \.path)
    }
}
```

詳細は省略しますが、Stateの`path`プロパティでプッシュ遷移状態を管理しており、`toRoot`アクションが呼ばれた際に`state.path.removeAll()`を実行することで、そのプッシュ遷移状態をリセットしています。

これでPushタブのラベルをタップするとプッシュ遷移状態がリセットされるようになりました！

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646568/80b33562-2859-baf7-8c01-ffbb81a7e617.gif" width="300"/>

### Scrollタブのラベルタップでスクロール位置を一番上に戻す

処理の基本的な流れは以下の通りです。

1. Scrollタブのラベルをタップする ✅
1. `selectedTabChanged`アクションが呼ばれる ✅
1. `selectedTabChanged`アクション内で、ScrollタブのReducerのロジックを呼び出す ❌
1. 呼び出されたロジック内で、スクロール位置を一番上に戻す ❌

ほとんど[Pushタブのラベルタップ](#pushタブのラベルタップでプッシュ遷移状態をリセット)と同じで、このうち✅の部分はすでに実装していますが、❌の部分がまだ実装されていません。

まずは`selectedTabChanged`アクション内でScrollタブのReducerのロジックを呼び出す処理を追加します。

```swift: AppRouter.swift
    case let .selectedTabChanged(nextTab):
        if nextTab == state.selectedTab {
            switch nextTab {
            case .pushNavigation:
                return MyPushNavigation().reduce(into: &state.pushNavigation, action: .toRoot)
                    .map(Action.pushNavigation) 
            case .scroll:
                return MyScroll().reduce(into: &state.scroll, action: .scrollToTop) // 👈
                    .map(Action.scroll)                                             // 👈
            }
        } else {
            state.selectedTab = nextTab
            return .none
        }
```

ここでもPushタブと同じように、ScrollタブのReducer（`MyScroll`）の`scrollToTop`アクションのロジックを呼び出しています。

次に、`scrollToTop`の詳細、および`MyScroll` Reducerについて見ていきます。

```swift: MyScroll.swift
@Reducer
public struct MyScroll {
    public init() {}

    @ObservableState
    public struct State: Equatable {
        public init() {}

        var position = ScrollPosition(edge: .top)
    }

    public enum Action: BindableAction {
        case binding(BindingAction<State>)
        case scrollToTop
    }

    public var body: some Reducer<State, Action> {
        BindingReducer()
        
        Reduce { state, action in
            switch action {
            case .binding:
                return .none
            case .scrollToTop:
                withAnimation {                         // 👈
                    state.position.scrollTo(edge: .top) // 👈
                }                                       // 👈
                return .none 
            }
        }
    }
}
```

こちらも詳細は省略しますが、Stateの`position`プロパティでスクロール位置を管理しており、`scrollToTop`アクションが呼ばれた際に`state.position.scrollTo(edge: .top)`を実行することで、スクロール位置を一番上に戻しています。
また、`withAnimation`を使用することでスクロール位置を変更する際にアニメーションを付与しています。

これでScrollタブのラベルをタップするとスクロール位置が一番上に戻るようになりました！

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646568/1a77b43c-7347-abf9-fc98-53dacb6f5c09.gif" width="300"/>

# サンプルコード

今回実装したサンプルコードは以下のリポジトリにまとめています。詳細を知りたい方はこちらを参照してください。

<!-- markdownlint-disable MD034 -->
https://github.com/rukawa07/TCATabViewSample
<!-- markdownlint-enable MD034 -->

# 参考文献

<!-- markdownlint-disable MD034 -->
https://developer.apple.com/documentation/swiftui/tabview?changes=latest_minor

https://betterprogramming.pub/swiftui-navigation-stack-and-ideal-tab-view-behaviour-e514cc41a029

https://www.hackingwithswift.com/quick-start/swiftui/how-to-scroll-to-exact-locations-inside-a-scrollview

https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/performance/

<!-- markdownlint-enable MD034 -->