Lento con forza

大学生気分のIT系エンジニアが色々書いてく何か。ブログ名決めました。

UIHostingControllerとUINavigationControllerを組み合わせてleftBarButtonItemをカスタマイズするまで

SwiftUIは進化を続けていて日々できることが増えています。iOS 16ではナビゲーションのAPIが拡充され、より柔軟な画面遷移の実装がSwiftUIだけでできるようになりました。

画面遷移はUIKitで実装

一方、サポートOSのバージョンの都合で最新のAPIが使えない場合もあります。そのような場合には、画面遷移はUIKitで、画面の実装はSwiftUIで、という回避策をとることがあります。

つまり、全体はUINavigationControllerなどのUIKitのナビゲーションで管理し、それぞれの画面をSwiftUIで作ります。SwiftUIで作った画面はUIHostingControllerでUIViewControllerとして扱えるようにし、遷移前の画面からpushやpresentで遷移します。

class ViewController: UIViewController {
    @IBAction func push(_ sender: UIButton) {
        let vc = UIHostingController(
            rootView: Text("OK")
        )
        show(vc, sender: self)
    }
}

画面を作る時に、ナビゲーションバー左の戻るボタンをカスタマイズしたいユースケースはよくあります。そのようなケースに対応するために、UIViewController.navigationItem を使ってみます。

class ViewController: UIViewController {
    @IBAction func push(_ sender: UIButton) {
        let vc = UIHostingController(
            rootView: Text("OK")
        )
        vc.navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .close)
        show(vc, sender: self)
    }
}

SwiftUIとUIHostingControllerは自動的にnavigationItemのleftItemsSupplementBackButtonをtrueにするようで、バックボタンが消えずに、2つのボタンが表示されるようになってしまいます。

これを防ぐために、Viewに対してnavigationBarBackButtonHidden()モディファイアを適用します

class ViewController: UIViewController {
    @IBAction func push(_ sender: UIButton) {
        let vc = UIHostingController(
            rootView: Text("OK")
                .navigationBarBackButtonHidden()
        )
        vc.navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .close)
        show(vc, sender: self)
    }
}

これで動作します。

SwiftUIを使ったもう一つの方法

SwiftUIにもナビゲーションバーを制御する方法は用意されていて、それはUIKitのUINavigationControllerと組み合わせて使った場合にも動作します。つまり、以下のようなコードでナビゲーションバーの左側のビューをカスタマイズ可能です。

class ViewController: UIViewController {
    @IBAction func push(_ sender: UIButton) {
        let vc = UIHostingController(
            rootView: Text("OK")
                .navigationBarBackButtonHidden()
                .toolbar {
                    ToolbarItem(placement: .navigationBarLeading) {
                        Button("<") {
                            self.navigationController?.popViewController(animated: true)
                        }
                    }
                }
        )
        show(vc, sender: self)
    }
}

ただし、この方法ではデフォルトのナビゲーションバーが一瞬表示されてしまい、後からカスタムされたものに置き換わるような挙動をしてしまいます。

これはSwiftUI onlyでアプリを作っている場合には発生せず、UIKitと組み合わせた場合に起こるようです。以下のサンプルコードでは綺麗に遷移が行えました。

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationView {
                NavigationLink("Push") {
                    Second()
                }
            }
        }
    }
}

struct Second: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Text("OK")
            .navigationBarBackButtonHidden()
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    Button("<") {
                        dismiss()
                    }
                }
            }
    }
}

スムーズな画面遷移を実装するために、UIKitを使ったナビゲーションを行う場合は、UIKitのnavigationItemを使った遷移が望ましいと思います。この辺りはもう少し動作を追えば良い解決策が見つかるかもしれないと思っているの知っている方がいたら教えてください!

UIBarButtonItemにcustomViewを渡す

さて、少し凝ったボタンにしたいということで、UIBarButtonItemにcustomViewを渡してみましょう。

class ViewController: UIViewController {
    @IBAction func push(_ sender: UIButton) {
        let vc = UIHostingController(
            rootView: Text("OK")
                .navigationBarBackButtonHidden()
        )
        let button = UIButton(type: .close, primaryAction: UIAction { [weak self] _ in
            self?.navigationController?.popViewController(animated: true)
        })
        vc.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: button)
        show(vc, sender: self)
    }
}

そうすると、iOS 16では動作するのですが、iOS 15では一瞬表示された戻るボタンは消えてしまいました。

どうやら、UIBarButtonItemにcustomViewを与えた時、UIViewControllerのviewDidLoadからviewDidAppearまでのどこかで、navigationItem.leftBarButtonItem が上書きされてしまうようでした。なので、以下のようにviewDidAppearで上書きすると動作します。

class MyHostingController<Content>: UIHostingController<Content> where Content: View {
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        let button = UIButton(type: .close, primaryAction: UIAction { [weak self] _ in
            self?.navigationController?.popViewController(animated: true)
        })
        navigationItem.leftBarButtonItem = UIBarButtonItem(customView: button)
    }
}

もうちょっと整理して、最終的にはこのような感じでしょうか。

class MyHostingController<Content>: UIHostingController<Content> where Content: View {
    private var customLeftBarButtonItem: UIBarButtonItem!

    override func viewDidLoad() {
        super.viewDidLoad()
        let button = UIButton(type: .close, primaryAction: UIAction { [weak self] _ in
            self?.navigationController?.popViewController(animated: true)
        })
        customLeftBarButtonItem = UIBarButtonItem(customView: button)

        if #available(iOS 16.0, *) {
            navigationItem.leftBarButtonItem = customLeftBarButtonItem
        } else {
            // iOS 15以下ではviewDidAppearで表示しないと空になってしまうので、まずは非表示にしておく
            navigationItem.leftBarButtonItem = UIBarButtonItem()
        }
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        if #unavailable(iOS 16.0) {
            //  iOS 15以下ではviewDidAppearで表示しないと空になってしまうのでここで代入する
            navigationItem.leftBarButtonItem = customLeftBarButtonItem
        }
    }
}

SwiftUIで戻るボタンのカスタマイズ

ここまでやるなら、SwiftUIでボタンを作ってしまった方が良い気もしてきたので、最後にそのパターンも考えておきます。これまでの組み合わせで、以下のような感じの実装でしょうか。

class ViewController: UIViewController {
    @IBAction func push(_ sender: UIButton) {
        let vc = MyHostingController(
            rootView: Text("OK")
                .navigationBarBackButtonHidden()
                .toolbar {
                    ToolbarItem(placement: .navigationBarLeading) {
                        Button("<") {
                            self.navigationController?.popViewController(animated: true)
                        }
                    }
                }
        )
        show(vc, sender: self)
    }
}

class MyHostingController<Content>: UIHostingController<Content> where Content: View {
    override func viewDidLoad() {
        super.viewDidLoad()
        // SwiftUIからleftBarButtonItemを表示すると一瞬デフォルトのビューが見えてしまうのを抑止する
        navigationItem.leftBarButtonItem = UIBarButtonItem()
    }
}

画面遷移終了後に戻るボタンが表示されてしまうので、UIKit版のiOS 16のような綺麗な画面遷移にはならないのですが、許容範囲ではあるかな〜という印象です。SwiftUIでビューを構築できるメリットも大きそうですね。僕が実装するときはこの2つのパターンのどちらかで実装することになると思います。何か他にもいい方法を知ってる人がいたら教えてください!