The Composable Architecture(TCA)のサンプルからBindingの扱い方について学びます。
今回はCaseStudiesの01-GettingStarted-Bindings-Basics
と01-GettingStarted-Bindings-Forms
を例に説明します。
単方向データフローの原則を守る
TCAは単方向データフローという設計思想を持っています。
データ(State)の変更は必ず、Action→Recucer→Storeという流れで行い、最後にViewにデータの変更が通知されるというものです。
一方、SwiftUIには双方向データバインディングを行うための仕組みが備わっていて、MVVMで実装する際はこれを利用してViewModel⇔Viewという双方向でデータのやり取りを行います。
このデータバインディングの機能は便利ですが、気をつけないとコードのあちこちでデータを変更することができてしまいます。データ変更があちこちで行われてしまうと、データの流れを追うのが難しくなり、バグを生む原因にもなります。
TCAでは単方向データフローを守るための仕組みをフレームワークが提供してくれていて、開発者がうっかり間違った実装をしてしまうリスクを排除してくれます。
設計原則を強制的に守らせることができるというのが魅力ですね。
Bindingを使った実装方法
サンプルの画面はこんな感じです。コンポーネントが4つありますが、ここでは3つ目のStepperと4つ目のSliderに注目します。
StepperとSliderの実装を抜き出したのがこちらです。
enum BindingBasicsAction { case sliderValueChanged(Double) case stepCountChanged(Int) } Stepper( // [1] value: viewStore.binding( get: \.stepCount, send: BindingBasicsAction.stepCountChanged), in: 0...100 ) { Text("Max slider value: \(viewStore.stepCount)") .font(Font.body.monospacedDigit()) } HStack { Text("Slider value: \(Int(viewStore.sliderValue))") .font(Font.body.monospacedDigit()) Slider( value: viewStore.binding( // [2] get: \.sliderValue, send: BindingBasicsAction.sliderValueChanged), in: 0...Double(viewStore.stepCount) ) }
[1]
ViewStore.binding(get:send:)
でBindingを生成します。
get:
にはViewStoreが持っているStateを取り出す関数もしくはKeyPathを指定します。
send:
にはActionを指定します。画面操作でStepperの値が変更されたときに、ここで指定したActionが送信されます。
[2]
[1]と同じです。Sliderの値変更時にActionが送信されます。
ViewStore.binding(get:send:)
の実装を覗いてみましょう。
public func binding<LocalState>( get: @escaping (State) -> LocalState, send localStateToViewAction: @escaping (LocalState) -> Action ) -> Binding<LocalState> { Binding( get: { get(self.state) }, set: { newLocalState, transaction in // [1] if transaction.animation != nil { withTransaction(transaction) { self.send(localStateToViewAction(newLocalState)) } } else { self.send(localStateToViewAction(newLocalState)) } } ) }
[1]
StepperやSliderがユーザ操作を検知したとき、Stateの値を直接書き換えるのではなく、Actionを送信するようになっています。
ビューではViewStoreが持つStateを参照することはできますが、この値を書き換えることはできませんし、Bindingにおいても値を直接書き換えるのではなくActionを送信するようにしています。先述した単方向データフローがきっちりと守られていますね
Stateの変更は最終的にReducerが行います。Recucerの実装は以下のようになっています。
let bindingBasicsReducer = Reducer< BindingBasicsState, BindingBasicsAction, BindingBasicsEnvironment > { state, action, _ in switch action { case let .sliderValueChanged(value): state.sliderValue = value return .none case let .stepCountChanged(count): // [1] state.sliderValue = .minimum(state.sliderValue, Double(count)) state.stepCount = count return .none // ...(以下省略)
[1]
本サンプルでは、SliderはstepCount
より大きい値を指定することはできないという仕様になっています。このようなドメインのロジックはReducerに閉じ込めるというのがTCAにおけるルールです。
BindingActionでActionを統合する
上記の例では、StepperとSliderそれぞれに対してstepCountChanged
とsliderValueChanged
というActionが定義されていました。
これだと全てのUIコントロールに対してActionを定義しなければなりませんが、TCAはこれらを一つに統合できるBindingActionという仕組みを提供しています。
BindingActionを使用すると、実装は以下のように変わります。
enum BindingFormAction: Equatable { // [1] case binding(BindingAction<BindingFormState>) } Stepper( // [2] value: viewStore.binding(keyPath: \.stepCount, send: BindingFormAction.binding), in: 0...100 ) { Text("Max slider value: \(viewStore.stepCount)") .font(Font.body.monospacedDigit()) } HStack { Text("Slider value: \(Int(viewStore.sliderValue))") .font(Font.body.monospacedDigit()) Slider( // [2] value: viewStore.binding(keyPath: \.sliderValue, send: BindingFormAction.binding), in: 0...Double(viewStore.stepCount) ) }
[1]
BindingAction型のAssociatedValueを持つケースが一つだけ定義されています。
[2]
StepperとSliderのBindingではどちらも同じActionが指定されています。
Reducerの実装はこう変わります。
let bindingFormReducer = Reducer< BindingFormState, BindingFormAction, BindingFormEnvironment > { state, action, _ in switch action { // [1] case .binding(\.stepCount): state.sliderValue = .minimum(state.sliderValue, Double(state.stepCount)) return .none // [2] case .binding: return .none // ...(以下省略)
[1]
KeyPathで特定のActionにマッチするようにしています。ここではStepperの値変更Actionに対する実装がされています。
[2]
その他のActionについては何も実装がされていませんが、Actionを受け取ってStateに値を反映させるだけの処理はBindingActionがデフォルトで実装しているようです。例えばSliderの操作によるsliderValue
の変更はデフォルト実装によって処理されます。
まとめ
- TCAには単方向データフローを強制する仕組みが備わっている
- Bindingにおいても単方向データフローが守られている
- BindingActionを使用することで、UIコントロールに対するActionを一つに統合することができる