AdventCalendarの時期はとうに過ぎ去りましたが、Calendarを作ってしまった以上気にせず投稿を続けます。
さて、このSwiftUIAdventCalendarでもモーダルの表示方法について2本ほど記事を書きましたが1 2、結局どれを採用するのが正解なのでしょうか?
色々と試行錯誤した結果ですが、最も安定して使えるのは 現在表示している最前面のViewController上にUIHostingControllerをpresentする です。
以下はその実装です。
struct ModalPresenter {
func present(_ content: () -> AnyView) {
guard let frontViewController = self.detectFrontViewController(from: self.rootViewController) else {
return
}
let hostingController = ModalHostingViewController(rootView: content())
hostingController.modalPresentationStyle = .fullScreen
frontViewController.present(hostingController, animated: true, completion: nil)
}
private var rootViewController: UIViewController? {
UIApplication.shared.windows.first?.rootViewController
}
private func detectFrontViewController(from viewController: UIViewController?) -> UIViewController? {
if let tabController = viewController as? UITabBarController {
if let selected = tabController.selectedViewController {
return detectFrontViewController(from: selected)
}
}
if let navigationController = viewController as? UINavigationController {
return detectFrontViewController(from: navigationController.visibleViewController)
}
if let presented = viewController?.presentedViewController {
return detectFrontViewController(from: presented)
}
return viewController
}
}
// MARK: - Modalを閉じるためのEnvironment定義
private struct ModalEnvironmentKey: EnvironmentKey {
static var defaultValue = PassthroughSubject<Void, Never>()
}
extension EnvironmentValues {
var dismissModal: PassthroughSubject<Void, Never> {
get { self[ModalEnvironmentKey.self] }
set { self[ModalEnvironmentKey.self] = newValue }
}
}
// MARK: - Modal本体
private class ModalHostingViewController: UIHostingController<AnyView> {
private let dismissSubject = PassthroughSubject<Void, Never>()
private var cancellable: Cancellable?
override var preferredStatusBarStyle: UIStatusBarStyle {
// 必要であればここでステータスバーの色を設定する
return .default
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.cancellable =
self.dismissSubject
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let `self` = self else { return }
self.dismiss(animated: true, completion: nil)
}
let rootView = self.rootView.environment(\.dismissModal, self.dismissSubject)
self.rootView = AnyView(rootView)
}
}
この実装の何がいいのか?
この実装の利点は以下の3つです。
- NavigationViewの中でフルスクリーンモーダル遷移した後、その中にNavigationViewがあるケースで安定して動作する
- SwiftUIでモーダルのようなアニメーションに見せかける遷移の場合、Navigation In Navigationのケースで様々な問題が発生します
- ControllerがUIKit製なので、表示・非表示アニメーションの完了を待って処理をフック出来る
- 上記の実装にはこのフック処理は含まれていませんが、UIViewControllerのライフサイクルに合わせたタイミングで処理を差し込むことが可能になります
- アニメーションが安定している
- SwiftUIでモーダルのようなモーダル遷移のアニメーションを見せかける場合、何かがUIスレッドに影響しているとアニメーションを妨げたりしてアニメーションが安定しませんが、この方法は常に安定したアニメーションで動作します
この利点の中でも、Navigation遷移に関する問題やアニメーションに関する問題はiOS13を対応範囲に含める場合、とても苦心するところなのでこれらの問題から開放されるこの方法は現時点でのモアベターだと思われます。
(ベストはSwiftUIの表現のみで対応出来ることですが、そんな方法は存在するのでしょうか?)
とはいえ、多少の問題はある
この方法を採用してみて、大きな問題は発生しませんが所々UIViewControllerRepresentableを利用したViewControllerとの連携でおかしな挙動をするケースがあります。
Pickerなど、独自に表示・非表示のライフサイクルを管理するViewControllerにSwiftUIの状態管理を当て込もうとしたことが原因だったりするのですが、
UIKitとSwiftUIのライフサイクル、どちらを採用するべきなのかという感覚を身に着けておく必要はあるでしょう。