1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

TabViewを使用する際、タブの選択状態にアクセスしたり、タブ切り替え時に任意の処理を行いたいことはよくありますよね。しかし、TCAのチュートリアルで紹介されているTabViewの実装はかなりシンプルであるため、いい感じのTabViewを実装したい場合には少し情報が足りないかもしれません。

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

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

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

対象読者

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

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

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

実装

今回つくるもの

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

Pushタブ Scrollタブ

Pushタブの仕様

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

Scrollタブの仕様

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

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

TabViewの実装

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

Reducer

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

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

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で管理しています。
また、selectedTabTabというenumで定義しています。

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

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

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

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側の実装を見ていきます。

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: {
                // タブの選択状態に応じてラベルの表示をカスタマイズ
                // ...
            }
        }
    }

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

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

いい感じにする

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

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

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

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にアクセスすることでタブの選択状態が取得できるため、これをImagesystemNameパラメーター内の三項間演算子で使用して、text.page.fillapple.logoのどちらを使用するか制御しています。2

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

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

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

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

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

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

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アクションのロジックを呼び出しています。

ここではPushタブのActionではなく、Reducerのreduceメソッドを使用することでPushタブのReducerが持つロジック(今回はプッシュ遷移状態のリセット)を呼び出しています。
これはActionを実行するコストが高いためであり、公式ドキュメントにその旨が記載されいます。

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

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タブのラベルをタップするとプッシュ遷移状態がリセットされるようになりました!

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

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

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

ほとんどPushタブのラベルタップと同じで、このうち✅の部分はすでに実装していますが、❌の部分がまだ実装されていません。

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

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について見ていきます。

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タブのラベルをタップするとスクロール位置が一番上に戻るようになりました!

サンプルコード

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

参考文献

  1. これらはタブ(子Reducer)内のアクションを検知した場合に、親Reducer側で何らかの処理を行いたい場合に使用します。このパターンは【SwiftUI】マルチモジュール構成のためのTCA×Routerパターンを考えてみたで頻繁に使用しているため、気になる方はそちらを参照してください!

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?