TCAでアプリを作る際、View特有の変換をしたい場合や不必要な再描画を防ぎパフォーマンスを上げる目的でViewStateの導入をするときがあると思います。
しかし、ViewStateを使う場合、@BindingStateを使用することは現状のTCAのAPIでは提供されていなく、
以下のような便利なAPIは使えないです。
viewStore.binding(\.$text)
viewStore.send(.set(\.$text, "")
今回はViewStateでもBindingStateの恩恵を受けられるようにコードを書いていきます。
方法1 ViewState用のPropertyWrapperを定義する方法
@propertyWrapper
public struct ViewStateBinding<State, Value> {
let keyPath: WritableKeyPath<State, BindingState<Value>>
let bindingState: BindingState<Value>
init(_ state: State, _ keyPath: WritableKeyPath<State, BindingState<Value>>) {
self.keyPath = keyPath
self.bindingState = state[keyPath: keyPath]
}
public var wrappedValue: Value {
bindingState.wrappedValue
}
public var projectedValue: ViewStateBinding<State, Value> {
self
}
}
extension ViewStateBinding: Equatable where Value: Equatable {}
extension ViewStateBinding: Hashable where Value: Hashable {}
ViewStateBindingでは、ReducerのStateとそのStateのpropertyへのkeyPathを引数に持ち、wrappedValue
でBindingStateのwrappedValue
にアクセスできるようにしています。
そしてViewStoreに以下のようなsubscriptを追加します。
extension ViewStore {
public subscript<ParentState, Value>(
dynamicMember keyPath: KeyPath<ViewState, ViewStateBinding<ParentState, Value>>
) -> Binding<Value>
where ViewAction: BindableAction, ViewAction.State == ParentState, Value: Equatable {
let parentKeyPath = self.state[keyPath: keyPath].keyPath
return self.binding(
get: { $0[keyPath: keyPath].wrappedValue },
send: { .binding(.set(parentKeyPath, $0)) }
)
}
}
これはSwift5.1で追加されたKeyPath Member Lookupという機能を使っています。
この機能を使用することでViewStoreがあたかもViewStateのpropertyを持つかのような挙動を実現できます。
getでは、ViewStateBindingのwrappedValue
プロパティを使用して値を取得し、sendではTCAのActionを使用して、Stateの状態を変更しています。
これによって、2つのkeypathをリンクさせることができ、Bindingの動作を再現することができました。
使用方法
struct HogeReducer: ReducerProtocol {
struct State: Equatable {
@BindingState var text = ""
}
enum Action: Equatable, BindableAction {
case binding(BindingAction<State>)
}
var body: some ReducerProtocolOf<Self> {
BindingReducer()
}
}
struct HogeView: View {
private let store: StoreOf<HogeReducer>
struct ViewState: Equatable {
@ViewStateBinding<HogeReducer.State, String> var text: String
init(state: HogeReducer.State) {
_text = ViewStateBinding(state, \.$text)
}
}
var body: some View {
WithViewStore(store, observe: ViewState.init) { viewStore in
TextField("Hoge", text: viewStore.$text)
}
}
}
ViewStateの定義がすこし面倒ですが、これでViewStateを使用していてもBindingStateを使用することができました。
方法2 ViewStoreにメソッドを追加で定義する
こちらの方法の方が個人的には好きです。
public extension ViewStore {
func binding<ParentState, Value>(
_ parentKeyPath: WritableKeyPath<ParentState, BindingState<Value>>,
as keyPath: KeyPath<ViewState, Value>
) -> Binding<Value>
where ViewAction: BindableAction, ViewAction.State == ParentState, Value: Equatable
{
binding(
get: { $0[keyPath: keyPath] },
send: { .binding(.set(parentKeyPath, $0)) }
)
}
}
やっていることは方法2のViewStoreのsubscript部分とほぼ同じで、
引数に、parentKeyPath
でReducerProtocolのStateのpropertyへの書き込み可能なKeyPathを、keyPath
でViewStateのpropertyへの読み込みのみ可能なKeyPathを持つことで、
bindingのget
でViewStateの値を取得し, send
ではParentStateへ値を送ることができ、Binding<Value>
の動作を再現しています。
使用方法
HogeReducerは方法1と同じのを利用しています。
struct HogeView: View {
private let store: StoreOf<HogeReducer>
struct ViewState: Equatable {
let text: String
init(state: HogeReducer.State) {
text = state.text
}
}
var body: some View {
WithViewStore(store, observe: ViewState.init) { viewStore in
TextField("Hoge", text: viewStore.binding(\.$text, as: \.text))
}
}
}
こちらの方法では、方法1のようにViewStateでPropertyWrapperを使用する必要もなく、よりスマートに書けているのではないでしょうか。
方法3(追記) 公式のやり方
@kalupas226 さんにコメントで教えていただきました、公式のやり方があるそうです!
struct HogeReducer: ReducerProtocol {
struct State: Equatable {
@BindingState var text = ""
var view: HogeView.ViewState {
get { .init(text: text) }
set { self.text = newValue.text }
}
}
enum Action: Equatable, BindableAction {
case binding(BindingAction<State>)
static func view(_ viewAction: HogeView.ViewAction) -> Self {
switch viewAction {
case let .binding(action):
return .binding(action.pullback(\.view))
}
}
}
var body: some ReducerProtocolOf<Self> {
BindingReducer()
}
}
struct HogeView: View {
private let store: StoreOf<HogeReducer>
init(store: StoreOf<HogeReducer>) {
self.store = store
}
struct ViewState: Equatable {
@BindingState var text: String
}
enum ViewAction: Equatable, BindableAction {
case binding(BindingAction<ViewState>)
}
var body: some View {
WithViewStore(store, observe: \.view, send: HogeReducer.Action.view) { viewStore in
TextField("Hoge", text: viewStore.binding(\.$text))
}
}
}
方法2では、viewStore.binding(\.$text, as: \.textFieldText)
のように、parentKeyPathとviewStateのkeyPathとで必ずしもpropertyの名前が同じになるとは限らず、あまりスマートではなかったのですが、これで公式のAPI(viewStore.binding(\.$text)
)が使えるようになり、方法2の問題も解決しました!
まとめ
今回はViewStateを使用する場合にBindingStateを使用する方法を紹介しました。
以前は、ViewStateを使用する際、BindingStateの導入を諦めていてTCAの古いBinding方法を使っていたので、かなり利便性が上がったのではないでしょうか。
参考文献
https://pointfreeco.github.io/swift-composable-architecture/0.46.0/documentation/composablearchitecture/performance
https://www.pointfree.co/blog/posts/63-the-composable-architecture-%EF%B8%8F-swiftui-bindings
https://github.com/pointfreeco/swift-composable-architecture/discussions/769