【UIKit版】The Composable Architecture(TCA)で画面遷移を実装する方法

f:id:bamboohero:20210601091823p:plain

前回TCAで画面遷移を実装する方法について学びましたが、UIKit版の同様のサンプルも提供されているので、今回はこちらを説明したいと思います。
SwiftUI版との違いに注目して説明するので、是非以下の記事と照らし合わせて見てください。

bamboo-hero.com

対象となるサンプルはこちらです。



State、Action、Environment、Reducerの実装はSwiftUI版と全く同じ

こちらの記事でも触れましたが、TCAの各コンポーネントの実装はSwiftUI版と全く同じです。
なので以降ではビューの実装に注目します。

bamboo-hero.com


画面遷移したあとデータをロードする

まずは画面遷移したあとデータをロードする NavigateAndLoad のサンプルから見ていきましょう。

f:id:bamboohero:20210601093557g:plain


実装で注目すべきはこのあたりですね。

override func viewDidLoad() {
  ...

  // [1]
  button.addTarget(self, action: #selector(loadOptionalCounterTapped), for: .touchUpInside)

  // [2]
  self.viewStore.publisher.isNavigationActive.sink { [weak self] isNavigationActive in
    guard let self = self else { return }
    if isNavigationActive {
      self.navigationController?.pushViewController(
        // [3]
        IfLetStoreController(
          store: self.store
            .scope(state: \.optionalCounter, action: EagerNavigationAction.optionalCounter),
          then: CounterViewController.init(store:),
          else: ActivityIndicatorViewController.init
        ),
        animated: true
      )
    } else {
      self.navigationController?.popToViewController(self, animated: true)
    }
  }
  .store(in: &self.cancellables)

   ...
}

override func viewDidAppear(_ animated: Bool) {
  super.viewDidAppear(animated)

  // [4]
  if !self.isMovingToParent {
    self.viewStore.send(.setNavigation(isActive: false))
  }
}

// [1]
@objc private func loadOptionalCounterTapped() {
  self.viewStore.send(.setNavigation(isActive: true))
}


// Reducerの実装
switch action {
case .setNavigation(isActive: true):
  state.isNavigationActive = true
  return Effect(value: .setNavigationIsActiveDelayCompleted)
    .delay(for: 1, scheduler: environment.mainQueue)
    .eraseToEffect()
case .setNavigation(isActive: false):
  state.isNavigationActive = false
  state.optionalCounter = nil
  return .none
case .setNavigationIsActiveDelayCompleted:
  state.optionalCounter = CounterState()
  return .none
...

[1]
「Load optional counter」ボタンをタップすると.setNavigation(isActive: true)が送信されます。

[2]
StateのisNavigationActiveをサブスクライブしています。
「Load optional counter」ボタンがタップされるとすぐにisNavigationActiveがtrueになり、UINavigationControllerによる画面遷移が行われます。

[3]
IfLetStoreControllerはUIViewControllerを継承したクラスで、SwiftUI用のIfLetStoreと同じような働きをします。
すなわち、optionalCounterがnilの間はActivityIndicatorViewControllerが表示され、non-nilになるとCounterViewControllerが表示されます。

[4]
isMovingToParentはViewControllerがナビゲーションスタックに積まれたときにtrueになります。
なので、遷移先の画面から戻ってきたときにここの分岐がtrueになり、.setNavigation(isActive: false)が送信されます。


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

  1. 「Load optional counter」ボタンがタップされると.setNavigation(isActive: true)が送信され、すぐに画面遷移する
  2. 画面遷移直後はまだoptionalCounterがnilであるため、インジケータが表示される
  3. 1秒後、setNavigationIsActiveDelayCompletedが送信され、optionalCounterがnon-nilになる
  4. optionalCounterがnon-nilになったので、CounterViewControllerが表示される
  5. 遷移元画面に戻ると.setNavigation(isActive: false)が送信され、optionalCounterがnilになる


データをロードしたあと画面遷移する

続いて、データをロードしたあとに画面遷移する LoadThenNavigate の実装を見ていきましょう。

f:id:bamboohero:20210601093657g:plain


実装で注目すべきはこのあたりです。

override func viewDidLoad() {
  ...

  // [1]
  button.addTarget(self, action: #selector(loadOptionalCounterTapped), for: .touchUpInside)

  // [2]
  self.viewStore.publisher.isActivityIndicatorHidden
    .assign(to: \.isHidden, on: activityIndicator)
    .store(in: &self.cancellables)

  self.store
    .scope(state: \.optionalCounter, action: LazyNavigationAction.optionalCounter)
    .ifLet(  // [3]
      then: { [weak self] store in
        self?.navigationController?.pushViewController(
          CounterViewController(store: store), animated: true)
      },
      else: { [weak self] in
        guard let self = self else { return }
        self.navigationController?.popToViewController(self, animated: true)
      }
    )
    .store(in: &self.cancellables)
}

override func viewDidAppear(_ animated: Bool) {
  super.viewDidAppear(animated)

  // [4]
  if !self.isMovingToParent {
    self.viewStore.send(.setNavigation(isActive: false))
  }
}

// [1]
@objc private func loadOptionalCounterTapped() {
  self.viewStore.send(.setNavigation(isActive: true))
}


// Reducerの実装
switch action {
  case .setNavigation(isActive: true):
    state.isActivityIndicatorHidden = false
    return Effect(value: .setNavigationIsActiveDelayCompleted)
      .delay(for: 1, scheduler: environment.mainQueue)
      .eraseToEffect()
  case .setNavigation(isActive: false):
    state.optionalCounter = nil
    return .none
  case .setNavigationIsActiveDelayCompleted:
    state.isActivityIndicatorHidden = true
    state.optionalCounter = CounterState()
    return .none
...

[1]
「Load optional counter」ボタンタップ時に.setNavigation(isActive: true)が送信されるのは先程と同じですね。

[2]
先程は遷移先の画面でインジケータを表示していましたが、本サンプルでは遷移元の画面にインジケータを表示します。
activityIndicatorisHiddenプロパティにisActivityIndicatorHiddenをassignし、isActivityIndicatorHiddenの値が変わるとインジケータの表示・非表示が切り替わるようにしています。

[3]
Store.ifLet(then:else:)を使い、optionalCounterがnilかnon-nilかによって画面遷移処理を切り替えています。
optionalCounterがnon-nilになるとthen:クロージャが実行され、CounterViewControllerの画面に遷移します。
optionalCounterがnilになるとelse:クロージャが実行され、遷移先の画面から本画面に戻します。

[4]
こちらも先程と同じで、遷移元画面から戻ってくると.setNavigation(isActive: false)が送信されます。


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

  1. 「Load optional counter」ボタンがタップされると.setNavigation(isActive: true)が送信され、インジケータが表示される
  2. 1秒後、setNavigationIsActiveDelayCompletedが送信され、optionalCounterがnon-nilになる。また、インジケータが非表示になる
  3. optionalCounterがnon-nilになったので、CounterViewControllerの画面に遷移する
  4. 遷移元画面に戻ると.setNavigation(isActive: false)が送信され、optionalCounterがnilになる


まとめ

UIkitでTCAの画面遷移を実装する方法について説明しました。

UIKit側で画面遷移をコントロールできれば、遷移先の一部の画面だけSwiftUI TCAで実装する、ということもできそうです。

すでにUIkitで組まれているアプリにあとからSwiftUIとTCAを導入するということも、この仕組みがあればできそうですね。