はじめに
こんにちは。
今回は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は以下のように定義します。
@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は以下のとおりです。
enum Action {
case selectedTabChanged(State.Tab) // タブ切り替え用アクション
case pushNavigation(MyPushNavigation.Action)
case scroll(MyScroll.Action)
}
ここでは、タブ切り替え時に呼び出すselectedTabChanged
アクションをまず定義しています。
また、それぞれのタブのReducerを埋め込むためにpushNavigation(MyPushNavigation.Action)
とscroll(MyScroll.Action)
を定義していますが、これらは今回使用しません。1
次にReducer部分を見ていきます。
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側の実装を見ていきます。
@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タブのラベルをカスタマイズするコードを見ていきます。
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タブも上記の方法でラベルをカスタマイズできます。
Pushタブのラベルタップでプッシュ遷移状態をリセット
処理の基本的な流れは以下の通りです。
- Pushタブのラベルをタップする ✅
-
selectedTabChanged
アクションが呼ばれる ✅ -
selectedTabChanged
アクション内で、PushタブのReducerのロジックを呼び出す ❌ - 呼び出されたロジック内で、プッシュ遷移状態をリセットする ❌
このうち、✅の部分はすでに実装していますが、❌の部分がまだ実装されていません。
まずはselectedTabChanged
アクション内でPushタブのReducerのロジックを呼び出す処理を追加します。
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
アクションの詳細、呼び出されたロジック内でプッシュ遷移状態をリセットする処理を見ていきます。
@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タブのラベルタップでスクロール位置を一番上に戻す
処理の基本的な流れは以下の通りです。
- Scrollタブのラベルをタップする ✅
-
selectedTabChanged
アクションが呼ばれる ✅ -
selectedTabChanged
アクション内で、ScrollタブのReducerのロジックを呼び出す ❌ - 呼び出されたロジック内で、スクロール位置を一番上に戻す ❌
ほとんどPushタブのラベルタップと同じで、このうち✅の部分はすでに実装していますが、❌の部分がまだ実装されていません。
まずはselectedTabChanged
アクション内でScrollタブのReducerのロジックを呼び出す処理を追加します。
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について見ていきます。
@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タブのラベルをタップするとスクロール位置が一番上に戻るようになりました!
サンプルコード
今回実装したサンプルコードは以下のリポジトリにまとめています。詳細を知りたい方はこちらを参照してください。
参考文献
-
これらはタブ(子Reducer)内のアクションを検知した場合に、親Reducer側で何らかの処理を行いたい場合に使用します。このパターンは【SwiftUI】マルチモジュール構成のためのTCA×Routerパターンを考えてみたで頻繁に使用しているため、気になる方はそちらを参照してください! ↩
-
実際の開発では、おそらくオリジナルの画像が使用されることが多いと思いますが、ここでは説明を簡略化するためにシステムイメージを使用しています! ↩