🍎

[SwiftUI][TCA] OptionalState、Stateの共有

2022/05/17に公開

概要

この記事ではTCA初心者の筆者が理解を深めていくために、
pointfreeco公式のサンプルアプリを基に理解しやすく整理していきます。
今回はbindingの使用例を整理して理解していきます。

前回のbindingについてはこちら

今回扱うファイル

今回は公式サンプルの以下の2つのファイルです。

OptionalState

https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift

SharedState

https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift

OptionalState

State,Action

今回の場合StateoptionalCounterが、
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()についての公式ドキュメントです。
https://pointfreeco.github.io/swift-composable-architecture/Reducer/#reducer.optional(file:line:)
ドキュメントには以下の説明記載がされています。

非オプションの状態に対して動作するリデューサを、 オプションでない状態に対して動作するリデューサに変換し、 state が non-nil のときだけ非オプションのリデューサを実行します。


pullback(state:action:environment:) と一緒に使うと、非オプショナルな子ドメインのリデューサを、 オプショナルな子ドメインを含む親ドメインのリデューサと結合できるリデューサに変換できることがよくある。

combined(with:)

combined(with:)についての公式ドキュメントです。
https://pointfreeco.github.io/swift-composable-architecture/Reducer/#reducer.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

https://pointfreeco.github.io/swift-composable-architecture/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で、
その子としてCounterStateProfileStateを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の説明で記載したように、
ProfileStateCounterStateの値を参照しています。

まずはCounter画面のReducerです。
ここではこれまでのCounter機能と同じように、
インクリメント、デクリメントボタンのアクションと、
素数を求めるPrimeButtonのアクションから、
CounterStateの値を更新しています。

sharedStateCounterReducer
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のアクションから、
ProfileStateresetCount()をコールし、
初期状態にStateの値を戻すだけとなっています。
ProfileStateの他のStateに関しては、
CounterStateの値を参照してるので、
特にProfile画面でのアクションには必要がありません。

sharedStateProfileReducer
let sharedStateProfileReducer = Reducer<
  SharedState.ProfileState, SharedStateAction.ProfileAction, Void
> { state, action, _ in
  switch action {
  case .resetCounterButtonTapped:
    state.resetCount()
    return .none
  }
}

最後に今回の共通部分のReducerです。
前の2つのReducerをcombineし、
セグメントタブ切り替えのアクションのみStateの更新を行います。

sharedStateReducer
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で判定し表示しています。

SharedStateView
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の値のみで状態管理が完結しています。

SharedStateCounterView
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を利用することが出来る様になっています。

SharedStateProfileView
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の共有

今回のサンプルの場合ProfileStateCounterStateの値を参照しているので、
共通のStateを使用しているというと少し自分の思っていた形とは違っていました。
今回の実装で言えばSharedStateで共通のStateを保持し、
SharedStateだけで各画面の状態管理が出来れば、
同じStateを各画面から参照・更新するというStateの共有と言う本来の目的に沿うかなと感じました。

次回

次回はAlertとDialogについて理解していきます。
記事はこちら

Discussion