🚨

TCA で Alert・ActionSheet を表示する方法

2021/03/13に公開

今回は TCA で Alert や ActionSheet を表示する方法について説明しようと思います。
TCA には Alert や ActionSheet を制御するための State が備わっており、複数の Alert を出す時なども柔軟に制御することができます。

通常の SwiftUI における Alert(ActionSheet)の表示

通常の SwiftUI では、Alert や ActionSheet を表示する際、Alert の状態を決める State を定義して、その State をバインドすることによって Alert を表示します。
例えば ↓ のような感じです。

struct ContentView: View {
  @State private var isShowAlert = false
  
  var body: some View {
    Button("Alert Button") {
      isShowAlert = true
    }
    .alert(isPresented: $isShowAlert) {
      Alert(title: Text("Alert Title"))
    }
  }

十分シンプルで扱いやすいですが、アラートの状態を変化させるコードが View の中に含まれていたり、Alert が複数になってくると若干工夫が必要になってくることが微妙な点です。

TCA における Alert(ActionSheet)

TCA では状態を単一方向で扱うため、標準の Alert の仕組みを利用することはできません(正確に言うとできなくはないですが、TCA の仕組みからは外れてしまうことになります)。
しかし、TCA では前述したように Alert(ActionSheet)用の State が用意されているため、それを利用することができます。
また、TCA の仕組みに則って Alert(ActionSheet)も表示するため、Alert のテストをするということも簡単にできるようになります。

今回は「TCA を利用した Alert(ActionSheet)の表示方法」と「Alert(ActionSheet)の状態のテスト方法」を軽く説明します。

TCA を利用した Alert(ActionSheet)の表示方法

Alert(ActionSheet)State を利用しているコードは以下にあるので、それを題材に軽く説明します。
https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndActionSheets.swift

題材のアプリは以下のようなものになっています。

単純なアプリですが、Alert を押すと Alert が表示され、Increment ボタンを押せばカウントが増えるのと同時に「Increment!」と書かれた Alert が表示され、Cancel ボタンを押せば Alert が dismiss されるというものになっています。
以降では Alert の機能を実現するためのコードについて説明します。(ActionSheet の実現方法はほぼ同じであるため説明しないです🙏 )

State

まず、TCA で重要な State, Action, Reducer, View を見ていきます(重要な部分のみ抜粋)。
Environment は今回の題材では存在していないため、説明しません。

State のコードは以下のようになっています。

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

単純なコードですが、TCA が用意してくれている ActionSheetStateAlertState を利用した変数、count 用の変数をそれぞれ定義しています。AlertAndSheetAction は後ほど定義します。

Action

Action のコードは以下のようになっています。

enum AlertAndSheetAction: Equatable {
  // ...
  case alertButtonTapped
  case alertCancelTapped
  case alertDismissed
}

それぞれの Action は以下のように定義されています。

  • alertButtonTapped は画面上の「Alert」ボタンをタップした際のアクション
  • incrementButtonTapped は表示された Alert の「Increment」ボタンをタップした際のアクション
  • alertCancelTapped は表示された Alert の「Cancel」ボタンをタップした際のアクション
  • alertDismissed は表示された Alert が dismiss する際のアクション

Reducer

最後に Reducer で Action を受け取った時にどんな処理が行われているかを説明します。
説明コメント付きで Reducer のコードを抜粋します。

let alertAndSheetReducer = Reducer<
  AlertAndSheetState, AlertAndSheetAction, AlertAndSheetEnvironment
> { state, action, _ in
  switch action {
  // Alert の状態を初期化している
  // title, message, primaryButton など Alert の詳細については Reducer 内で定義している
  case .alertButtonTapped:
    state.alert = .init(
      title: .init("Alert!"),
      message: .init("This is an alert"),
      primaryButton: .cancel(),
      // incrementButtonTapped Action は secondaryButton が押された時に発火させている
      secondaryButton: .default(.init("Increment"), send: .incrementButtonTapped)
    )
    return .none
  // Cancel ボタンを押した時は特にすることはないので、`none` Effect を返却するのみ
  case .alertCancelTapped:
    return .none
  // Alert が dismiss する時にこの Action は発火する
  // dismiss するということは Alert は表示されないようになって欲しいということ
  // state.alert を nil にすれば Alert が表示されないようになる
  case .alertDismissed:
    state.alert = nil
    return .none
  // IncrementButton がタップされた時には異なる Alert を表示する
  // そのため、state.alert を上書きしている
  case .incrementButtonTapped:
    state.alert = .init(title: .init("Incremented!"))
    state.count += 1
    return .none
  }
}

Reducer 内で Alert に関する処理が完結しており、異なる Alert を呼び出す時も state.alert を書き換えるだけで良いので、簡単に Alert を実現することができます。

View

最後に View のコードも軽く見ていきます。

struct AlertAndSheetView: View {
  let store: Store<AlertAndSheetState, AlertAndSheetAction>

  var body: some View {
    WithViewStore(self.store) { viewStore in
      Form {
        Section(header: Text(template: readMe, .caption)) {
          Text("Count: \(viewStore.count)")

          Button("Alert") { viewStore.send(.alertButtonTapped) }
            .alert(
              self.store.scope(state: { $0.alert }),
              dismiss: .alertDismissed
            )
        }
      }
    }
    .navigationBarTitle("Alerts & Action Sheets")
  }
}

View 側で Alert を表示する場合は、viewStore.send(.alertButtonTapped) を送って Alert を初期化します。

また、.alert Modifier を使用していますが、いつもの isPresented を引数に持つものではなく、storedismiss を引数に持つものになっています。
store は単純に alert の State を持つ Store を scope してあげれば良いだけであり、dismiss にも先ほど Action で定義した .alertDismissed を記述するだけで実現することが可能です。

Alert(ActionSheet)の状態のテスト方法

次に、Alert の状態のテスト方法を見ていきます。
テストのコードは以下にあります。

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

テストコードを説明コメント付きで抜粋します。

@testable import SwiftUICaseStudies

class AlertsAndActionSheetsTests: XCTestCase {
  func testAlert() {
    // Test 用の TestStore を定義
    let store = TestStore(
      initialState: AlertAndSheetState(),
      reducer: alertAndSheetReducer,
      environment: AlertAndSheetEnvironment()
    )

    store.assert(
      // alertButtonTapped Action が発火されたら
      .send(.alertButtonTapped) {
        // Alert がこの状態で初期化されているはずである
        $0.alert = .init(
          title: .init("Alert!"),
          message: .init("This is an alert"),
          primaryButton: .cancel(),
          secondaryButton: .default(.init("Increment"), send: .incrementButtonTapped)
        )
      },
      // incrementButtonTapped Action が発火されたら
      .send(.incrementButtonTapped) {
        // Alert は Incremented! をタイトルに持つものになっていて、
        $0.alert = .init(title: .init("Incremented!"))
	// count は 1 に変更されているはずである
        $0.count = 1
      },
      // alertDismissed Action が発火されたら
      .send(.alertDismissed) {
        // alert の状態は nil になっているはずである
        $0.alert = nil
      }
    )
  }
}

こちらも TCA のテストコードを書くことに慣れていれば、特に難しいことはなさそうです。

Alert(ActionSheet)を TCA で書いたことによって、Alert(ActionSheet)の状態すら簡単にテストできるようになっていますね👏

おわりに

TCA のコードの書き方に慣れてさえいれば、かなり簡単に Alert や ActionSheet を実現することができそうです。
また、Alert に対するテストコードを書くことが簡単に実現できるようになることも魅力だと思いました。

参考

https://www.pointfree.co/blog/posts/47-the-composable-architecture-and-swiftui-alerts

Discussion