前回TCAで画面遷移を実装する方法について学びましたが、UIKit版の同様のサンプルも提供されているので、今回はこちらを説明したいと思います。
SwiftUI版との違いに注目して説明するので、是非以下の記事と照らし合わせて見てください。
対象となるサンプルはこちらです。
State、Action、Environment、Reducerの実装はSwiftUI版と全く同じ
こちらの記事でも触れましたが、TCAの各コンポーネントの実装はSwiftUI版と全く同じです。
なので以降ではビューの実装に注目します。
画面遷移したあとデータをロードする
まずは画面遷移したあとデータをロードする NavigateAndLoad のサンプルから見ていきましょう。
実装で注目すべきはこのあたりですね。
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)
が送信されます。
全体の動きをまとめると以下のようになります。
- 「Load optional counter」ボタンがタップされると
.setNavigation(isActive: true)
が送信され、すぐに画面遷移する - 画面遷移直後はまだ
optionalCounter
がnilであるため、インジケータが表示される - 1秒後、
setNavigationIsActiveDelayCompleted
が送信され、optionalCounter
がnon-nilになる optionalCounter
がnon-nilになったので、CounterViewControllerが表示される- 遷移元画面に戻ると
.setNavigation(isActive: false)
が送信され、optionalCounter
がnilになる
データをロードしたあと画面遷移する
続いて、データをロードしたあとに画面遷移する LoadThenNavigate の実装を見ていきましょう。
実装で注目すべきはこのあたりです。
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]
先程は遷移先の画面でインジケータを表示していましたが、本サンプルでは遷移元の画面にインジケータを表示します。
activityIndicator
のisHidden
プロパティにisActivityIndicatorHidden
をassignし、isActivityIndicatorHidden
の値が変わるとインジケータの表示・非表示が切り替わるようにしています。
[3]
Store.ifLet(then:else:)
を使い、optionalCounter
がnilかnon-nilかによって画面遷移処理を切り替えています。
optionalCounter
がnon-nilになるとthen:
クロージャが実行され、CounterViewControllerの画面に遷移します。
optionalCounter
がnilになるとelse:
クロージャが実行され、遷移先の画面から本画面に戻します。
[4]
こちらも先程と同じで、遷移元画面から戻ってくると.setNavigation(isActive: false)
が送信されます。
全体の動きをまとめると以下のようになります。
- 「Load optional counter」ボタンがタップされると
.setNavigation(isActive: true)
が送信され、インジケータが表示される - 1秒後、
setNavigationIsActiveDelayCompleted
が送信され、optionalCounter
がnon-nilになる。また、インジケータが非表示になる optionalCounter
がnon-nilになったので、CounterViewControllerの画面に遷移する- 遷移元画面に戻ると
.setNavigation(isActive: false)
が送信され、optionalCounter
がnilになる
まとめ
UIkitでTCAの画面遷移を実装する方法について説明しました。
UIKit側で画面遷移をコントロールできれば、遷移先の一部の画面だけSwiftUI TCAで実装する、ということもできそうです。
すでにUIkitで組まれているアプリにあとからSwiftUIとTCAを導入するということも、この仕組みがあればできそうですね。