Open9

SwiftUIからUINavigationControllerベースの画面遷移をしたい

だじだじ

SwiftUIへの移行は始まったが、まだまだアプリ全体を置き換えるには程遠いので、画面ごとにSwiftUI.Viewを作成してUIHostingControllerでラップして利用する方法をとることがしばしば

(Firstの前にまだUIKitの画面があったり、SecondのあとにUIKitの画面があるため、大元はUINavigationControllerを利用するしかない状況、、、という前提)

class FirstViewController: UIViewController {
    @IBAction func buttonPressed(_ sender: UIButton) {
        // let secondVC = SecondViewController()
        // 👇置き換え
        let secondVC = UIHostingController(rootView: SecondScreen())
        navigationController?.pushViewController(secondVC, animated: true)
    }
}
だじだじ

👆の例でFirstViewControllerまで置き換えが進んだ時、以下のようなコードになる

struct FirstScreen: View {
    var body: some View {
        Button("Button") {
            pushSecondScreen()
        }
    }

    func pushSecondScreen() {
        guard let rootVC = (UIApplication.shared.delegate as? AppDelegate)?.window?.rootViewController,
              let navigationVC = rootVC as? UINavigationController else {
            return
        }
        let secondVC = UIHostingController(rootView: SecondScreen())
        navigationVC.pushViewController(secondVC, animated: true)
    }
}

UINavigationControllerを掘り出す処理は、SwiftUIの世界からUIKitのAPIを呼び出すなんて無理なことをしているのである程度目をつぶる🙈
つぶったとしても、将来的にSwiftUI化が完了したときのことを考えて、SwiftUIのAPIにインターフェースを揃えて開発を進めたい。以下はSwiftUIでのナビゲーション遷移の一例👇

struct FirstScreen: View {
    @State var isPresented = false

    var body: some View {
        Button("Button") {
            isPresented = true
        }
        .navigationDestination(isPresented: $isPresented) {
            SecondScreen()
        }
    }
}
だじだじ

onChange を利用することでisPresentedがtrueになった時にプッシュして、falseになった時にポップするようなAPIがまず考えられる
(本当はポップするViewControllerが対象のものかチェックした方がいいが一旦考えない)

extension View {
    func hostingNavigationDestination<V: View>(
        isPresented: Binding<Bool>,
        @ViewBuilder destination: @escaping () -> V
    ) -> some View {
        onChange(of: isPresented.wrappedValue) {
            guard let rootVC = (UIApplication.shared.delegate as? AppDelegate)?.window?.rootViewController,
                  let navigationVC = rootVC as? UINavigationController else {
                return
            }
            if isPresented.wrappedValue {
                let vc = UIHostingController(rootView: destination())
                navigationVC.pushViewController(vc, animated: true)
            } else {
                navigationVC.popViewController(animated: true)
            }
        }
    }
}

これでisPresentedを変化させることでUINavigationControllerでの画面遷移ができるようになる… はずだったが、遷移後にisPresentedをfalseにするような操作を行ってもonChangeが反応せず、isPresentedの操作でポップができない🤔

struct FirstScreen: View {
    @State var isPresented = false

    var body: some View {
        Button("Button") {
            isPresented = true
        }
        .hostingNavigationDestination(isPresented: $isPresented) {
            Button("Second Button") {
                // ここには到達しているが、hostingNavigationDestination内部のonChangeが発火しない
                isPresented = false
            }
        }
    }
}
だじだじ

試しに同じようなAPIとしてsheet版を作ってみたが、こちらは問題なく期待通りの動作をする

extension View {
    func hostingSheet<C: View>(
        isPresented: Binding<Bool>,
        @ViewBuilder content: @escaping () -> C
    ) -> some View {
        onChange(of: isPresented.wrappedValue) {
            guard let rootVC = (UIApplication.shared.delegate as? AppDelegate)?.window?.rootViewController else {
                return
            }
            if isPresented.wrappedValue {
                let vc = UIHostingController(rootView: content())
                rootVC.present(vc, animated: true)
            } else {
                rootVC.dismiss(animated: true)
            }
        }
    }
}
だじだじ

ここで仮説として、onChangeはコンテキストが絶たれるようなUIKitの遷移を行うと動作しなくなるのでは、と思った。描画に関わる変数に変更が入ったとしても必要がある場合にのみ描画処理が動作しているのではないか🤔。試しに👆で作ったsheetのAPIで modalPresentationStyle = .fullScreen にしてみる。

            // ...
            if isPresented.wrappedValue {
                let vc = UIHostingController(rootView: content())
                vc.modalPresentationStyle = .fullScreen
                rootVC.present(vc, animated: true)
            } else {
            // ...

すると… ナビゲーションの時と同様に動作しなくなった! さらに .overCurrentContext のときは動作することも確認できた。ほぼこの仮説で確定な気がする。

しかしこの仮説が正しかったとすると、遷移前の画面の値を変更してポップさせるような処理を行うのは、SwiftUIのライフサイクルの中で行うのは結構厳しそう
うんうん悩んでいたら思いついた👇

だじだじ

アクティブでない画面がイベントを受け取れないのなら、アクティブな画面に託してしまおう。ということで以下のように一枚画面をラップして代わりにonChangeを行ってもらうことで解決

/// hostingNavigationDestinationのために利用するView。isPresentedの監視を行うためだけのView
fileprivate struct WrapView<C: View>: View {
    let isPresented: Binding<Bool>
    let content: () -> C
    let isPresentedChange: (Bool) -> Void
    
    var body: some View {
        content()
            .onChange(of: isPresented.wrappedValue) {
                isPresentedChange(isPresented.wrappedValue)
            }
    }
}

extension View {
    func hostingNavigationDestination<V: View>(
        isPresented: Binding<Bool>,
        @ViewBuilder destination: @escaping () -> V
    ) -> some View {
        onChange(of: isPresented.wrappedValue) {
            // このonChangeはこのViewが表示中の時にしか機能しないため、実質trueになったことを検知する役割
            guard isPresented.wrappedValue,
                  let rootVC = (UIApplication.shared.delegate as? AppDelegate)?.window?.rootViewController,
                  let navigationVC = rootVC as? UINavigationController else {
                return
            }
            // 次の画面を表示中はonChangeが働かないため、WrapViewで代わりにonChangeを行ってもらう
            let wrapView = WrapView(isPresented: isPresented, content: destination) { isPresented in
                // falseになったことだけ検知してポップする
                guard !isPresented else { return }
                navigationVC.popViewController(animated: true)
            }
            let vc = UIHostingController(rootView: wrapView)
            navigationVC.pushViewController(vc, animated: true)
        }
    }
}

複数画面を一気にポップするためにはまた一苦労しそうだが、一旦個人的な目的は達成できそうなのでこれで満足😤

だじだじ

これでisPresentedの状態から遷移の状態を一致させることができたが、navigationDestinationにさらに近づけるにはその逆の、遷移の状態からisPresentedの状態を一致させる必要がある。
例えば、ナビゲーションバーの戻るボタンから前画面に戻った時に、isPresentedの値もfalseに更新されなければならない

まずはUINavigationControllerがポップしたかどうかを検知するために、UIHostingControllerをカスタマイズ

final class PopHandlableHostingController<V: View>: UIHostingController<V> {
    private var isPreparingPop = false
    private let popHandler: () -> Void
    
    init(rootView: V, popHandler: @escaping () -> Void) {
        self.popHandler = popHandler
        super.init(rootView: rootView)
    }
    
    @MainActor required dynamic init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        // UINavigationControllerからポップされようとしているかチェック
        isPreparingPop = isBeingDismissed || isMovingFromParent
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        // ポップされようとしていた場合、ハンドラを実行する
        if isPreparingPop {
            popHandler()
        }
    }
}
だじだじ

最後に全部組み合わせて…完成🎉

extension View {
    func hostingNavigationDestination<V: View>(
        isPresented: Binding<Bool>,
        @ViewBuilder destination: @escaping () -> V
    ) -> some View {
        onChange(of: isPresented.wrappedValue) {
            // このonChangeはこのViewが表示中の時にしか機能しないため、実質trueになったことを検知する役割
            guard isPresented.wrappedValue,
                  let rootVC = (UIApplication.shared.delegate as? AppDelegate)?.window?.rootViewController,
                  let navigationVC = rootVC as? UINavigationController else {
                return
            }
            // 次の画面を表示中はonChangeが働かないため、WrapViewで代わりにonChangeを行ってもらう
            let wrapView = WrapView(isPresented: isPresented, content: destination) { isPresented in
                // falseになったことだけ検知してポップする
                guard !isPresented else { return }
                navigationVC.popViewController(animated: true)
            }
            // ポップしたことをハンドリングしてisPresentedを更新
            let vc = PopHandlableHostingController(rootView: wrapView) {
                isPresented.wrappedValue = false
            }
            navigationVC.pushViewController(vc, animated: true)
        }
    }
}

今回は雑にrootViewControllerがUINavigationControllerのパターンでやったけど、実際は最前面のUINavigationControllerをとったりして使うことになるかな?

複数階層のポップは… プッシュした先でさらにプッシュされているので、またonChangeが効かない問題にあって今度こそ厳しそう。TreeベースなナビゲーションAPIではなく、StackベースなAPIにすることで解決する…かも

だじだじ

ここまで色々こねくり回してきたけど、 NavigationLink が一発で解決できることを発見してしまった…😨 ちょっとショックw
どうやらNavigationLinkはSwiftUIのNavigationだけでなく、UINavigationControllerのことも探し出してそういつにプッシュしてくれるみたい。さらに自動的にUIHostingControllerでラップしてくれる

extension View {
    func navigationLink<V: View>(
        isPresented: Binding<Bool>,
        @ViewBuilder destination: @escaping () -> V
    ) -> some View {
        background {
            NavigationLink(
                isActive: isPresented,
                destination: destination,
                label: {
                    EmptyView()
                }
            )
        }
    }
}

NavigationLinkを直接タップするのではなく、 isActive で制御して、labelはEmptyViewで隠してbackgroundに配置することで完全に見えなくしている