TCAでViewStateとViewActionを利用する

5 min read読了の目安(約4500字

はじめに

The Composable Architcture(TCA)のサンプルではViewStateとViewActionをもとのStateとActionから分離している例があります。それについて小さなコードで説明してみます。

ViewStateやViewActionとは何か

ViewState

  • 実装方法
    • Stateに対してComputed propertyをextensionで増やす
  • 用途
    • View特有の変換をしたい場合などに使える
      • ViewHelper的な使い方

ViewAction

  • 実装方法
    • ViewActionからActionへ変換する関数を用意する
  • 用途
    • ViewActionから渡される値を変更したい
      • 例: 値がnilの場合にカラ文字に置き換える
  • できないこと
    • Stateを変更することはできない
    • ViewActionがあるからといって、もとのActionの呼び出しを封じたりはできない
      • ActionはViewActionからも呼び出せるし、Viewからも呼び出せる

実装例

-ボタンと+ボタンのあるカウンター画面を実装してみる。

仕様

  • -ボタンで-1
  • +ボタンで+1
  • 3桁以上ならカンマ区切り
    • 例: 1,000
  • 0以上なら
    • 「0以上だよ!」表示
    • 背景色を緑に
  • 0未満なら
    • 「マイナスだよ!」表示
    • 背景色を赤に

スクリーンショット

設計方針

  • TCAで必須ではないがenumでContentCoreとして名前空間的にまとめておく
    • 例: ContentCore.StateContentCore.Actionとし、ContentActionContentActionにはしない

ソースコード

import SwiftUI
import ComposableArchitecture

enum ContentCore {
    struct State: Equatable {
        var count = 1000
    }

    enum Action {
        case onAppear
        case increment
        case decrement
    }

    struct Environment { }

    static let reducer = Reducer<State, Action, Environment> { state, action, _ in
        switch action {
        case .onAppear:
            return .none

        case .decrement:
            state.count -= 1
            return .none

        case .increment:
            state.count += 1
            return .none
        }
    }

    struct View: SwiftUI.View {
        // MARK: -
        struct State: Equatable {
            var count: String
            var description: String
            var backgroundColor: Color
        }

        enum Action {
            case increment
            case decrement
        }

        // MARK: -

        let store: Store<ContentCore.State, ContentCore.Action>

        var body: some SwiftUI.View {
            WithViewStore(store) { _ in
	        // ViewStateとViewActionを取り出す
                WithViewStore(
                    store.scope(
                        state: { $0.view },
                        action: ContentCore.Action.view
                    )
                ) { viewStore in

                    VStack {
                        Text("\(viewStore.count)")

                        Text("\(viewStore.description)")
                            .background(viewStore.backgroundColor)
                            .padding()

                        HStack {
                            Button("-") {
                                viewStore.send(.decrement)
                            }

                            Button("+") {
                                viewStore.send(.increment)
                            }
                        }
                    }
                }
            }
        }
    }
}

// MARK: - ViewState

extension ContentCore.State {
    // Stateに対してプロパティを増やしている。
    // Stateに対してView特有の変換をしたい場合などのViewHelperみたいな用途に使える。
    var view: ContentCore.View.State {
        .init(
            count: String.localizedStringWithFormat("%d", count),
            description: count >= 0 ? "0以上だよ!" : "マイナスだよ!",
            backgroundColor: count >= 0 ? Color.green : Color.red
        )
    }
}

// MARK: - ViewAction

extension ContentCore.Action {
    // LocalのViewActionからActionに変換する関数。
    // Stateを変更したりするものではない。
    // LocalでないActionは外部から変更できてしまうのは変わらないため、あまり使い所はないかも。
    static func view(_ localAction: ContentCore.View.Action) -> Self {
        switch localAction {
        case .decrement:
            return decrement

        case .increment:
            return increment
        }
    }
}

// MARK: -

extension ContentCore {
    static let live = Store(
        initialState: ContentCore.State(),
        reducer: ContentCore.reducer,
        environment: ContentCore.Environment()
    )
}

// MARK: - preview

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentCore.View(
            store: ContentCore.live
        )
    }
}

解説

scopeによるViewStateとActionの取り出し

2つ目のWithViewStoreでは、scopeによりStoreから{ $0.view }を呼び出すクロージャによってviewプロパティの値を取り出しています。そしてactionではContentCore.Action.viewを指定し、ViewActionからActionに変換する関数を渡しています。

WithViewStore(
    store.scope(
	state: { $0.view },
	action: ContentCore.Action.view
    )
) 

その他

TCAのサンプルでViewStateと検索してSwiftUIのサンプルだけ見ると詳しくわかるはずです

https://github.com/pointfreeco/swift-composable-architecture/search?q=ViewState