🤹

TCAで2つのコンポーネントで共有の値を使う

2021/10/04に公開

概要

TCAで複数のコンポーネントで共有の値を持つための公式サンプルがあったので書いておきます。サンプル名は「Shared State Demo」で、Counter画面とProfile画面があってCounter画面で増減した値がProfile画面で表示されています。

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

Shared State Demo

共有するStateはどこにあるか

SharedStateというStateがRootのStateととなっており、その子としてCounterStateprofileStateを持ちます。

省略したStateを示します。

struct SharedState: Equatable {
  var counter = CounterState()
  var profile: ProfileState { ... }
  // タブ切り替えActionで切り替えられる
  var currentTab = Tab.counter
 ...
}
  • SharedState
    • CounterState
      • カウンターを増減(こいつがソース)
    • ProfileState
      • 増減された情報を表示
      • リセットもできる

(共有したいStateはCounterStateにあるわけで、SharedStateにもたせているわけではありません。基本的に共通のStateを持つ場合にそれら共有したい子要素の親が共有されるStateを持たせたいと思いますが、このサンプルはそうなっていません)

共有するStateはどのタイミングで共有されるのか

タイミングはSwitchをタップされProfile画面が表示されるときです。その際にProfile画面用のStateがCounterStateから作成されます。

上記で省略していたSharedStateについてもう少し詳しく書きます。

struct SharedState: Equatable {
  var counter = CounterState()
  var currentTab = Tab.counter
  
  struct CounterState: Equatable {
    var alert: AlertState<SharedStateAction.CounterAction>?
    // 増減するcount
    var count = 0
    var maxCount = 0
    var minCount = 0
    var numberOfCounts = 0
  }

  var profile: ProfileState {
    get { // ここでProfileStateが取得される際にcounterから作成される
      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
    }
  }
  ...

ProfileStateが取得されるとは?

SwichがタップされたときにProfile画面を出そうとする際にSharedStateProfileView表示しようとしてstore.scopeでprofileStateを取得します(getが動作する)。

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(SegmentedPickerStyle())

        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()
  }
}

self.store.scope(state: \.profile, action: SharedStateAction.profile))の部分で\.profileでKeyPathとしてStateからprofileへアクセスしています。

KeyPathについての蛇足: SE-0249: Key Path Expressions as Functions

これは「SE-0249: Key Path Expressions as Functions」でしょう。KeyPathで式書いたらそれがクロージャに解釈されるよってな感じです。

  • KeyPathで\.profileって式を書いて関数に渡す
    • { $0.profile } クロージャが関数に渡されててここでgetされる
      • $0SharedState

まとめ

  • TCAのShared State DemoはデータをCounterとProfileの2画面で共有します
    • データ自体はCounterStateにあります
    • Profile画面は表示時にProfileStateCounterStateから作られます
      • プロパティのget時にProfileStateを作っています
        • getが動作するのは画面表示の際にstore.scope(state: \.profile, action: SharedStateAction.profile))が実行されるためです

これはあくまでサンプルのやり方であって違うやり方もできるでしょう。他にはaccessTokenなどの副作用限定のグローバル変数の扱いはもっと良いやり方がありますし、RootのStateに共有のStateをもたせることもありでしょう。

ちなみにProfile画面でresetする際に元データをリセットしてるのはsend(アクション)時にプロパティのsetが動くから。Reducerの関数が動作するとStateが書き換わるのを利用してそう。

Discussion