12
4

More than 1 year has passed since last update.

[TCA] ViewStateを使用する場合に@BindingStateを使う方法

Last updated at Posted at 2023-04-02

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の動作を再現することができました。

使用方法

HogeReducer.swift
struct HogeReducer: ReducerProtocol {
    struct State: Equatable {
        @BindingState var text = ""
    }

    enum Action: Equatable, BindableAction {
        case binding(BindingAction<State>)
    }

    var body: some ReducerProtocolOf<Self> {
        BindingReducer()
    }
}

HogeView.swift
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 さんにコメントで教えていただきました、公式のやり方があるそうです!

HogeReducer.swift
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()
    }
}
HogeView
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

12
4
2

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
12
4