The Composable Architecture(TCA)でアクションシート(Action Sheet)を実装する方法

f:id:bamboohero:20210604225756p:plain

The Composable Architecture(TCA)のサンプルからアクションシート(Action Sheet)の実装方法について学びます。

今回扱うサンプルはこちらです。

01-GettingStarted-AlertsAndActionSheets


「Action sheet」ボタンをタップするとアクションシートが表示され、「Increment」をタップするとCountの値が1増加し、「Decrement」をタップするとCountの値が1減少します。

f:id:bamboohero:20210604220100g:plain


なお、同じサンプルコードにあるアラート(Alert)の実装についてはこちらの記事で解説しています。合わせてご覧ください。

bamboo-hero.com



ネイティブSwiftUIでのアクションシート実装例

同じ機能をTCAを使わずネイティブなSwiftUIで実装するとこんな感じになると思います。

struct ContentView: View {
    @State var count = 0
    @State private var showActionSheet = false

    var body: some View {
        Form {
            Text("Count: \(count)")
            Button("Action sheet") {
                showActionSheet = true
            }
            // [1]
            .actionSheet(isPresented: $showActionSheet, content: {
                ActionSheet(
                    title: Text("Action sheet"),
                    message: Text("This is an action sheet."),
                    buttons: [
                        .cancel(),
                        .default(Text("Increment"), action: increment),
                        .default(Text("Decrement"), action: decrement)
                    ]
                )
            })
        }
    }

    private func increment() {
        count += 1
    }

    private func decrement() {
        count -= 1
    }
}

[1]
「Action sheet」ボタンをタップするとshowActionSheetがtrueになり、アクションシートが表示されます。 「Increment」「Decrement」のいずれかのボタンをタップしてアクションシートが非表示になると、Bindingを通じてshowActionSheetがfalseになります。


SwiftUIではこのように双方向バインディングを使用してアクションシートの表示・非表示をコントロールしていますが、TCAは単方向データフローが原則です。

このため、TCAはアクションシートの実装で単方向データフローを守るための仕組みを用意しています。


TCAでのアクションシート実装例

サンプルからアクションシートを表示するビューの実装だけを抜き出したのがこちらです。

// [1]
Button("Action sheet") { viewStore.send(.actionSheetButtonTapped) }
  // [2]
  .actionSheet(
    self.store.scope(state: \.actionSheet),
    dismiss: .actionSheetDismissed
  )

[1]
ボタンがタップされたら.actionSheetButtonTappedアクションを送信します。

[2]
actionSheet(_:dismiss:)はTCAが提供するViewのエクステンションメソッドです。 メソッドのシグネチャは以下のようになっています。ActionSheetStateというのはTCAが提供する構造体で、Storeが持っているActionSheetStateがnon-nilになるとアクションシートが表示されます。 アクションシートのボタンをタップするなどしてアクションシートが非表示になると、dismiss:のアクションが送信されます。

func actionSheet<Action>(_ store: Store<ActionSheetState<Action>?, Action>, dismiss: Action) -> some View


このように、TCAのアクションシート実装を利用することでボタンタップやアクションシートの非表示時にアクションを送信し、ReducerでStateを変更するという単方向データフローの原則に従うことができます。


続いて、State、Reducerの実装を見ていきます。

struct AlertAndSheetState: Equatable {
  // [1]
  var actionSheet: ActionSheetState<AlertAndSheetAction>?
  var alert: AlertState<AlertAndSheetAction>?
  var count = 0
}

let alertAndSheetReducer = Reducer<
  AlertAndSheetState, AlertAndSheetAction, AlertAndSheetEnvironment
> { state, action, _ in

  switch action {
  // [2]
  case .actionSheetButtonTapped:
    state.actionSheet = .init(
      title: .init("Action sheet"),
      message: .init("This is an action sheet."),
      buttons: [
        .cancel(),
        // [3]
        .default(.init("Increment"), send: .incrementButtonTapped),
        .default(.init("Decrement"), send: .decrementButtonTapped),
      ]
    )
    return .none

  // [4]
  case .incrementButtonTapped:
    state.alert = .init(title: .init("Incremented!"))
    state.count += 1
    return .none
  }

  case .decrementButtonTapped:
    state.alert = .init(title: .init("Decremented!"))
    state.count -= 1
    return .none

  // [5]
  case .actionSheetDismissed:
    state.actionSheet = nil
    return .none

  // [6]
  case .alertDismissed:
    state.alert = nil
    return .none

  // [7]
  case .actionSheetCancelTapped:
    return .none
}

[1]
ActionSheetStateのプロパティはオプショナルで宣言しておきます。先ほど説明したように、このプロパティにActionSheetStateのインスタンスをセットすると、アクションシートが表示されるようになります。

[2]
画面上の「Action sheet」ボタンをタップするとactionSheetButtonTappedアクションが送信され、Reducerがstate.actionSheetにActionSheetStateのインスタンスをセットします。これでアクションシートが画面に表示されます。

[3]
アクションシート内の「Increment」ボタンをタップするとincrementButtonTappedアクションが送信されます。

[4]
incrementButtonTappedアクションが送信されたら、Reducerがstate.alertにAlertStateのインスタンスをセットします。これで「Incremented!」というタイトルのアラートが画面に表示されます(アラートについては上述のこちらの記事参照)。

[5]
アクションシートのいずれかのボタンがタップされアクションシートが非表示になるとactionSheetDismissedが送信され、Reducerでstate.actionSheetをnilに変更します。
ここでstate.actionSheetをnilにしておかないと、もう一度「Action sheet」ボタンをタップしたときにactionSheetButtonTappedアクションが送信されたあとすぐにactionSheetDismissedアクションが送信されるという動きをしました。
深く追えてはいないのですが、想定外の動きにならないようにnilにしておくのが良いでしょう。

[6]
アラートのOKボタンがタップされアラートが非表示になるとalertDismissedが送信され、Reducerでstate.alertをnilに変更します。
ここでstate.alertをnilにしておかないと、もう一度「Action sheet」ボタンをタップし、さらに「Increment(Decrement)」ボタンをタップした直後にalertDismissedアクションが送信され、OKボタンをタップしてアラートが非表示になると再度alertDismissedアクションが送信されるという動きをしました。
こちらも深く追えてはいないのですが、想定外の動きにならないようにnilにしておくのが良いでしょう。

[7]
アクションは定義されていますが、このアクションはどこからも送信されていないようです。アクションシート内の「Cancel」ボタンタップ時に何かしたい場合はこちらを実装することになると思います。


改めて全体の動きをまとめると以下のようになります。

  1. 画面上の「Action sheet」ボタンがタップされるとactionSheetButtonTappedアクションが送信される
  2. Reducerによってstate.actionSheetにActionSheetStateのインスタンスがセットされ、アクションシートが表示される
  3. アクションシート内の「Increment」ボタンがタップされるとincrementButtonTappedアクションが送信され、アクションシートは非表示になる
  4. Reducerによってstate.alertにAlertStateのインスタンスがセットされ、アラートが表示される。また、state.countの値が1増える
  5. アラート内の「OK」ボタンがタップされるとアラートが非表示になる。同時に、alertDismissedアクションが送信される
  6. Reducerによってstate.alertにnilがセットされる


まとめ

TCAでアクションシートを実装する方法について説明しました。

ネイティブなSwiftUIで実装すると双方向のデータフローになりますが、TCAが提供してくれているアクションシートの拡張機能を使うことで単方向データフローの原則に従った実装をすることができることがわかりました。