[SwiftUI][TCA] OptionalState、Stateの共有
概要
この記事ではTCA初心者の筆者が理解を深めていくために、
pointfreeco
公式のサンプルアプリを基に理解しやすく整理していきます。
今回はbindingの使用例を整理して理解していきます。
今回扱うファイル
今回は公式サンプルの以下の2つのファイルです。
OptionalState
SharedState
OptionalState
State,Action
今回の場合State
のoptionalCounter
が、
optional型であるが1つポイントとなります。
そのoptional型State
の扱い方をここでは整理していきます。
struct OptionalBasicsState: Equatable {
var optionalCounter: CounterState? // optional型
}
enum OptionalBasicsAction: Equatable {
case optionalCounter(CounterAction)
case toggleCounterButtonTapped
}
Reducer
toggleCounterButtonTapped
のActionがコールされた際に、
State
の更新の部分でoptionalの場合を考慮していて、
nilの場合はCounterStateを入れ、non-nilの場合はnilを入れています。
let optionalBasicsReducer =
counterReducer
.optional() // ポイント
.pullback(
state: \.optionalCounter,
action: /OptionalBasicsAction.optionalCounter,
environment: { _ in CounterEnvironment() }
)
// ポイント
.combined(
with: Reducer<
OptionalBasicsState, OptionalBasicsAction, OptionalBasicsEnvironment
> { state, action, environment in
switch action {
case .toggleCounterButtonTapped:
// optionalの場合を考慮
state.optionalCounter =
state.optionalCounter == nil
? CounterState()
: nil
return .none
case .optionalCounter:
return .none
}
}
)
そしてここでのポイントである、
optional()
とcombined(with:)
について整理します。
optional
optional()
についての公式ドキュメントです。
ドキュメントには以下の説明記載がされています。
非オプションの状態に対して動作するリデューサを、 オプションでない状態に対して動作するリデューサに変換し、 state が non-nil のときだけ非オプションのリデューサを実行します。
pullback(state:action:environment:) と一緒に使うと、非オプショナルな子ドメインのリデューサを、 オプショナルな子ドメインを含む親ドメインのリデューサと結合できるリデューサに変換できることがよくある。
combined(with:)
combined(with:)
についての公式ドキュメントです。
親の前に子が結合されるcombine
に対して、
combined(with:)
は親の前に子が実行されます。
View,Store
ここでのポイントはIfLetStore
を使用し、
State
のnil判定を行なっている部分となります。
var body: some View {
WithViewStore(self.store) { viewStore in
Form {
Section(header: Text(template: readMe, .caption)) {
Button("Toggle counter state") {
viewStore.send(.toggleCounterButtonTapped)
}
// ポイント
IfLetStore(
self.store.scope(
state: \.optionalCounter,
action: OptionalBasicsAction.optionalCounter
),
then: { store in
VStack(alignment: .leading, spacing: 16) {
Text(template: "`CounterState` is non-`nil`", .body)
CounterView(store: store)
.buttonStyle(.borderless)
}
},
else: {
Text(template: "`CounterState` is `nil`", .body)
}
)
}
}
}
.navigationBarTitle("Optional state")
}
ポイントである、IfLetStore
について整理します。
IfLetStore
State
のnil判定でnon-nilの場合thenクロージャが実行され、
そうでない場合はelseクロージャが実行されます。
使い道としてはState
の状態によって、表示する2つのViewを決定するのに便利です。
今回のケースで言うと、
non-nilのthenクロージャ内ではCounterViewを表示し、
nilのelseクロージャ内ではTextを表示するように制御されています。
SharedState
これまでより少し複雑なので簡単に今回の仕様を整理しておきます。
まず画面上部のセグメントタブで2つの画面を出し分けています。
初期表示のSharedStateCounterView
では、
これまでのようなCounter機能と、
Counterの数字が素数かどうか判定するボタンがあります。
もう一つのSharedStateProfileView
では、
Counterの現在の数字と、
表示した数字の最大・最小の数字を表示と、
インクリメント、デクリメントボタンを押した総回数を表示し、
Counterの状態をinitするリセットボタンがあります。
State,Action
まずSharedState
が今回の共通のState
で、
その子としてCounterState
とProfileState
を2つ定義しています。
ポイントとしてはProfileState
で1つcountという値を例に挙げると、
self.counter.count
このようにCounterState
から参照していることで、
CounterState
の値が更新されることで、
ProfileState
のcountなどの値も更新されるようになっています。
struct SharedState: Equatable {
var counter = CounterState()
var currentTab = Tab.counter
enum Tab { case counter, profile }
struct CounterState: Equatable {
var alert: AlertState<SharedStateAction.CounterAction>?
var count = 0
var maxCount = 0
var minCount = 0
var numberOfCounts = 0
}
// The ProfileState can be derived from the CounterState by getting and setting the parts it cares
// about. This allows the profile feature to operate on a subset of app state instead of the whole
// thing.
var profile: ProfileState {
get {
ProfileState(
currentTab: self.currentTab,
count: self.counter.count,// ポイント
maxCount: self.counter.maxCount,
minCount: self.counter.minCount,
numberOfCounts: self.counter.numberOfCounts
)
}
set {
self.currentTab = newValue.currentTab
self.counter.count = newValue.count
self.counter.maxCount = newValue.maxCount
self.counter.minCount = newValue.minCount
self.counter.numberOfCounts = newValue.numberOfCounts
}
}
struct ProfileState: Equatable {
private(set) var currentTab: Tab
private(set) var count = 0
private(set) var maxCount: Int
private(set) var minCount: Int
private(set) var numberOfCounts: Int
fileprivate mutating func resetCount() {
self.currentTab = .counter
self.count = 0
self.maxCount = 0
self.minCount = 0
self.numberOfCounts = 0
}
}
}
enum SharedStateAction: Equatable {
case counter(CounterAction)
case profile(ProfileAction)
case selectTab(SharedState.Tab)
enum CounterAction: Equatable {
case alertDismissed
case decrementButtonTapped
case incrementButtonTapped
case isPrimeButtonTapped
}
enum ProfileAction: Equatable {
case resetCounterButtonTapped
}
}
Reducer
上記Stateの説明で記載したように、
ProfileState
はCounterState
の値を参照しています。
まずはCounter画面のReducerです。
ここではこれまでのCounter機能と同じように、
インクリメント、デクリメントボタンのアクションと、
素数を求めるPrimeButtonのアクションから、
CounterState
の値を更新しています。
let sharedStateCounterReducer = Reducer<
SharedState.CounterState, SharedStateAction.CounterAction, Void
> { state, action, _ in
switch action {
case .alertDismissed:
state.alert = nil
return .none
case .decrementButtonTapped:
state.count -= 1
state.numberOfCounts += 1
state.minCount = min(state.minCount, state.count)
return .none
case .incrementButtonTapped:
state.count += 1
state.numberOfCounts += 1
state.maxCount = max(state.maxCount, state.count)
return .none
case .isPrimeButtonTapped:
state.alert = .init(
title: isPrime(state.count)
? .init("👍 The number \(state.count) is prime!")
: .init("👎 The number \(state.count) is not prime :(")
)
return .none
}
}
次にProfile画面のReducerです。
resetCounterButtonのアクションから、
ProfileState
のresetCount()
をコールし、
初期状態にState
の値を戻すだけとなっています。
ProfileState
の他のState
に関しては、
CounterState
の値を参照してるので、
特にProfile画面でのアクションには必要がありません。
let sharedStateProfileReducer = Reducer<
SharedState.ProfileState, SharedStateAction.ProfileAction, Void
> { state, action, _ in
switch action {
case .resetCounterButtonTapped:
state.resetCount()
return .none
}
}
最後に今回の共通部分のReducerです。
前の2つのReducerをcombineし、
セグメントタブ切り替えのアクションのみStateの更新を行います。
let sharedStateReducer = Reducer<SharedState, SharedStateAction, Void>.combine(
sharedStateCounterReducer.pullback(
state: \SharedState.counter,
action: /SharedStateAction.counter,
environment: { _ in () }
),
sharedStateProfileReducer.pullback(
state: \SharedState.profile,
action: /SharedStateAction.profile,
environment: { _ in () }
),
Reducer { state, action, _ in
switch action {
case .counter, .profile:
return .none
case let .selectTab(tab):
state.currentTab = tab
return .none
}
}
)
View,Store
SharedStateView
では、
今回の親Viewとして、
セグメントタブと2つのViewをState
で判定し表示しています。
struct SharedStateView: View {
let store: Store<SharedState, SharedStateAction>
var body: some View {
WithViewStore(self.store.scope(state: \.currentTab)) { viewStore in
VStack {
Picker(
"Tab",
selection: viewStore.binding(send: SharedStateAction.selectTab)
) {
Text("Counter")
.tag(SharedState.Tab.counter)
Text("Profile")
.tag(SharedState.Tab.profile)
}
.pickerStyle(.segmented)
if viewStore.state == .counter {
SharedStateCounterView(
store: self.store.scope(state: \.counter, action: SharedStateAction.counter))
}
if viewStore.state == .profile {
SharedStateProfileView(
store: self.store.scope(state: \.profile, action: SharedStateAction.profile))
}
Spacer()
}
}
.padding()
}
}
SharedStateCounterView
は、
主にCounter機能の各種アクションを送っています。
ここではCounterState
の値のみで状態管理が完結しています。
struct SharedStateCounterView: View {
let store: Store<SharedState.CounterState, SharedStateAction.CounterAction>
var body: some View {
WithViewStore(self.store) { viewStore in
VStack(spacing: 64) {
Text(template: readMe, .caption)
VStack(spacing: 16) {
HStack {
Button("−") { viewStore.send(.decrementButtonTapped) }
Text("\(viewStore.count)")
.font(.body.monospacedDigit())
Button("+") { viewStore.send(.incrementButtonTapped) }
}
Button("Is this prime?") { viewStore.send(.isPrimeButtonTapped) }
}
}
.padding(16)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .top)
.navigationBarTitle("Shared State Demo")
.alert(self.store.scope(state: \.alert), dismiss: .alertDismissed)
}
}
}
SharedStateProfileView
では、
CounterState
の値を参照するState
から表示する値を取得しており、
ProfileState
の値のみで状態管理をしているコンポーネントがありません。
CounterState
の値を参照することで今回の目的である、
共通のState
を利用することが出来る様になっています。
struct SharedStateProfileView: View {
let store: Store<SharedState.ProfileState, SharedStateAction.ProfileAction>
var body: some View {
WithViewStore(self.store) { viewStore in
VStack(spacing: 64) {
Text(
template: """
This tab shows state from the previous tab, and it is capable of reseting all of the \
state back to 0.
This shows that it is possible for each screen to model its state in the way that makes \
the most sense for it, while still allowing the state and mutations to be shared \
across independent screens.
""",
.caption
)
VStack(spacing: 16) {
Text("Current count: \(viewStore.count)")
Text("Max count: \(viewStore.maxCount)")
Text("Min count: \(viewStore.minCount)")
Text("Total number of count events: \(viewStore.numberOfCounts)")
Button("Reset") { viewStore.send(.resetCounterButtonTapped) }
}
}
.padding(16)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .top)
.navigationBarTitle("Profile")
}
}
}
Stateの共有
今回のサンプルの場合ProfileState
はCounterState
の値を参照しているので、
共通のState
を使用しているというと少し自分の思っていた形とは違っていました。
今回の実装で言えばSharedState
で共通のState
を保持し、
SharedState
だけで各画面の状態管理が出来れば、
同じStateを各画面から参照・更新するというStateの共有と言う本来の目的に沿うかなと感じました。
次回
次回はAlertとDialogについて理解していきます。
記事はこちら
Discussion