UINavigationControllerをカスタマイズ 〜OSの影響を受けづらいカスタムナビゲーションの実装〜

こんにちは、モバイル基盤部のヴァンサン(@vincentisambart)です。

iOS 15とXcode 13がリリースされました。最新のiOS SDKでビルドしてみたら、カスタマイズされたナビゲーションバーに修正が必要だったアプリが少なくなかったようです。しかし、iOS版のクックパッドアプリでは大きくカスタマイズされているナビゲーションバーを使ってはいるものの、iOS 15に合わせてナビゲーションバーに手を入れる必要は特になかったです。

iOS版のクックパッドアプリは最近様々な形のナビゲーションバーを使っています。例えばおすすめタブはスクロールするとナビゲーションバーの高さが変わります。

f:id:vincentisambart:20211027155756p:plain:w100 f:id:vincentisambart:20211027155853p:plain:w100 f:id:vincentisambart:20211027160039p:plain:w100

また、さがすタブは画面によってナビゲーションバーの中身や高さが違いますし、レシピ詳細ではスクロールするとレシピ名がナビゲーションバーに入ります。

f:id:vincentisambart:20211027160107p:plain:w100 f:id:vincentisambart:20211027160124p:plain:w100 f:id:vincentisambart:20211027160140p:plain:w100 f:id:vincentisambart:20211027160153p:plain:w100

なぜiOS版のクックパッドアプリには修正が必要なかったのでしょうか。 この記事では、OSの変更の影響をあまり受けない大きくカスタマイズされたナビゲーションバーをiOS版のクックパッドアプリでどうやって実装したのか説明しようと思います。でもその前に、大切な注意事項があります。

注意事項

iOSの標準のナビゲーションバーは大きくカスタマイズされるように作られていません。Appleが用意した限られた設定以上にカスタマイズしようとすると、OSが更新されるたびに壊れやすいです。

正直にいうと、ナビゲーションバーのカスタマイズをおすすめできません。この記事で紹介している仕組みは壊れるリスクが低いと思いますが、今後どうなるのか分かりません。

iOSクックパッドのナビゲーションバーの歴史

iOSクックパッドでいまのナビゲーションバーの実装に至るまで、仕組みが何回か変わりました。

最初の仕組み

僕が2014年に入社した時には、カスタムなナビゲーションバーが既に実装されていました。カスタマイズされていたのは見た目とサブビューの配置でした。なぜ配置のカスタマイズが必要かと言いますと、iOS標準のUINavigationBarの真ん中にtitleViewを入れるとき、そのtitleViewがあまり大きくなりません。なので、真ん中に大きい検索ボックスを入れたければ、カスタマイズする必要です。

どうやって実装されていたと言いますと、ナビゲーションバーのボタンの作成をシステムに任せるけど、layoutSubviewsでシステムの決めたボタンの配置を変えていました。

改修

上記の仕組みはOSの更新で調整が定期的に必要でした。Xcode 9 (2017)のiOS 11 SDKでアプリをビルドした時、ナビゲーションバーがまた壊れて、もう少し壊れにくい仕組みを実装できないのか挑戦してみました。

新しい仕組みでは、システムの作成したボタンは今回触れないで、その上に載せたビューで隠して、自分の作成したボタンをさらに上に載せていました。OSの扱っているtitleViewも触れたくないので、UINavigationItem.titleViewを使っていた画面に少し不自然なワークアラウンドが必要でしたが、結果的に狙い通り以前の仕組みより頑丈でした。

最新の仕組み

2019年の上半期のデザイン案に半透明なナビゲーションバーを導入したい要望が現れました。以前の仕組みでは、システムのものの上にビューやボタンを載せているので、それを透過させると、システムのものが見えてしまいます。システムのものをいじって完全に透過させたら実装できたかもしれませんが、最初の仕組みのようなもっと壊れやすい状態に戻ってしまいそうでした。

少し前から考えていたアイデアを試すきっかけに見えました。どういうアイデアかといいますと、UINavigationControllerは使うけどUINavigationBarは使わないことです😁。実装は試行錯誤で何週間も掛かりましたが、いま使われている仕組みができました。

なぜUINavigationControllerは使うのか

UINavigationControllerは使うけどUINavigationBarは使わない」といったうちのなぜUINavigationControllerを使うのか、という部分を説明します。

UINavigationControllerを使わないで、ゼロからナビゲーションコントローラーを独自実装した方が壊れにくいのではないでしょうか。ゼロから作って挙動をシステム標準に合わせるのがとても大変ではありますし、その上でOS標準のビューコントローラーにはAppleしか実装できないところがあります。

分かりやすいところでいうと、UIViewController.navigationControllerはシステムが提供しているものです。どうしてもというなら一応swizzlingを使って挙動を変えることはできるかもしれないけど、色々壊れるリスクがあるし、戻り値はUINavigationControllerでなければいけません。代わりに自分でUIViewController.myCustomNavigationControllerのような似たメソッドを用意できるけど、既存のコードを変えなければいけません。

その他に、UINavigationControllerと全く同じ標準のアニメーションや遷移中のシャドーを再実装するのも大変そうでした。アニメーションはできるだけシステムに任せたいです。

なぜUINavigationBarは使わないのか

UINavigationBarを自由にカスタマイズできないなら、使わなければ良いだけです。UINavigationControllersetNavigationBarHidden(_:animated:)がまさにそのためにあります。

UINavigationBarを使わないといっても、多くの画面でナビゲーションバーが表示されてほしいので、ナビゲーションバー相当の機能を普通のUIViewControllerにやらせます。そのナビゲーションバー相当ののビューコントローラーをナビゲーションコントローラーの中に表示したいので、ナビゲーションスタックには画面本来のビューコントローラーが直接入るのではなく、ナビゲーションバー相当のビューコントローラーと、画面本来のビューコントローラー両方ともを管理するラッパービューコントローラーが入ります。

画面3つがプッシュされてあるナビゲーションコントローラーの親子関係は以下のようなイメージです。

NoBarNavigationController
 |
 +- FixedHeightToolbarProvidingContainerViewController
 |   +- EmbeddedNavigationToolbarViewController
 |   +- ScreenViewController1
 |
 +- FixedHeightToolbarProvidingContainerViewController
 |   +- EmbeddedNavigationToolbarViewController
 |   +- ScreenViewController2
 |
 +- FixedHeightToolbarProvidingContainerViewController
     +- EmbeddedNavigationToolbarViewController
     +- ScreenViewController3

既存のコードをあまり変えたくないし、ビューコントローラーをプッシュするたびに手動でFixedHeightToolbarProvidingContainerViewControllerにラップする必要があったら面倒なので、ラップは自動的にやる仕組みが必要です。

では実装に入りましょう。量が多いので、クラスと機能で以下のように分けました。

  • ナビゲーションバーを使わないナビゲーションコントローラーNoBarNavigationController
    • NoBarNavigationController.init
    • NoBarNavigationController.viewDidLoad
      • NoBarNavigationControllerが継承しているUINavigationControllerの本来のdelegateの扱いと経緯
      • NoBarNavigationControllerが継承しているUINavigationControllerの本来のナビゲーションバーを隠すisNavigationBarHidden
      • スワイプで戻るジェスチャーを扱うinteractivePopGestureRecognizer
    • ナビゲーションコントローラーにプッシュされるビューコントローラーを自動的にコンテナーにラップする仕組み
      • ラップを希望しないと示すプロトコルAdditionalToolbarNotNeeded
      • どうラップされたいのか明記できるプロトコルAdditionalToolbarNeeded
      • AdditionalToolbarNotNeededにもAdditionalToolbarNeededにも準拠していない場合
    • UINavigationControllerDelegateの準拠の詳細
  • ナビゲーションコントローラーにプッシュされるビューコントローラーをラップして、ツールバーをその上に入れくれるコンテナーFixedHeightToolbarProvidingContainerViewController
  • ツールバー自体の表示
    • ツールバーを管理しているビューコントローラーEmbeddedNavigationToolbarViewController
    • EmbeddedNavigationToolbarViewControllerのビューEmbeddedNavigationToolbar

ナビゲーションコントローラー

最初に見るのは肝心のナビゲーションコントローラー自体です。

注意:ここで紹介する実装は最初からこうできたわけではなく、ここに辿り着くには試行錯誤で時間かかりましたし、使ってみたら見つけた細かい問題の修正も入っています。

NoBarNavigationController.init

まずは、initの定義に不自然なところがあまりないと思います。気になるであろうwrapIfNeeded()は後ほどで説明します。init?(coder:)は需要が特になかったので実装されていません。

public final class NoBarNavigationController: UINavigationController {
    override public init(rootViewController: UIViewController) {
        let wrappedRootViewController = Self.wrapIfNeeded(rootViewController)
        super.init(nibName: nil, bundle: nil)
        viewControllers = [wrappedRootViewController]
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

NoBarNavigationController.viewDidLoad

もっと興味深いところ、viewDidLoadの実装を見てみましょう

    private var interactivePopGestureHandler: InteractivePopGestureHandler?
    override public func viewDidLoad() {
        super.viewDidLoad()
        delegate = self

        // このナビゲーションコントローラーが自分のナビゲーションバーをもっていません。
        // 必要であれば、プッシュされるビューコントローラーが別のビューコントローラーにラップされて、
        // その別のビューコントローラーがナビゲーションバー相当の機能を提供してくれます。
        isNavigationBarHidden = true

        interactivePopGestureHandler = InteractivePopGestureHandler(controller: self)

        if let interactivePopGestureRecognizer = self.interactivePopGestureRecognizer {
            // 戻るボタンが隠れている場合(ナビゲーションバーが隠れている場合も含む)、
            // `UINavigationController`が自分の`interactivePopGestureRecognizer`を無効にしています。
            // 改めて有効にするために、自作の`delegate`を代入します。
            interactivePopGestureRecognizer.delegate = interactivePopGestureHandler
        } else {
            assertionFailure("interactivePopGestureRecognizerが作成されてあると期待されています")
        }
    }

コードが長いわけでもないのですが、だいぶ複雑なので、細かく見てみましょう。

delegate

    override public func viewDidLoad() {
        super.viewDidLoad()
        delegate = self

まず自分を自分のdelegateにしています。delegatenavigationController(_:willShow:animated:)のタイミングでやりたい処理があるので、こうするしかありませんでした。delegateのメソッドでやることはあとで説明します。

delegateを自分で使っているけど、アプリが別の用途でdelegateを使いたい時もあるので、delegateが間違って上書きされないようにassertを入れておきましたし、別のdelegate(additionalDelegate)を設定できるようにしました。

    public weak var additionalDelegate: NoBarNavigationControllerDelegate?
    override public var delegate: UINavigationControllerDelegate? {
        didSet {
            assert(delegate === self, "delegateが必要であれば、additionalDelegateをご利用ください")
        }
    }

additionalDelegateの使っているNoBarNavigationControllerDelegateにはこのナビゲーションコントローラーが対応しているUINavigationControllerDelegateからとったメソッドが入っているだけです。

public protocol NoBarNavigationControllerDelegate: AnyObject {
    func noBarNavigationController(_ navigationController: NoBarNavigationController, willShow viewController: UIViewController, animated: Bool)
    func noBarNavigationController(_ navigationController: NoBarNavigationController, didShow viewController: UIViewController, animated: Bool)
}

viewDidLoad()の中で、delegate代入の次はナビゲーションバーを隠します。

isNavigationBarHidden

    private var interactivePopGestureHandler: InteractivePopGestureHandler?
    override public func viewDidLoad() {
        // (中略)
        isNavigationBarHidden = true

UINavigationBarを使わないと既に説明したので、isNavigationBarHidden = trueは自然だと思います。ただし、isNavigationBarHiddenが何かの理由でfalseに戻されたら、変な表示になりそうですね。もともと間違った変更を防ぐためにassert()を入れてありましたが、SwiftUIのビューが入ったUIHostingControllerをプッシュしてみたら、そのassert()が引っかかっていました。SwiftUIで明示的にナビゲーションバーを隠すようにしても、SwiftUIが一瞬表示したがっているので、強引ではありますが、有効にできないようにするしかありませんでした。

    override public func setNavigationBarHidden(_ hidden: Bool, animated: Bool) {
        if hidden {
            super.setNavigationBarHidden(hidden, animated: animated)
        }
    }

var isNavigationBarHidden: Boolは裏でsetNavigationBarHidden(newValue, animated: false)を読んでいるだけみたいなので、overridesetNavigationBarHidden(_:animated:)だけで良さそうです。

viewDidLoad()の中で、ナビゲーションバーを隠したあとにinteractivePopGestureRecognizerに手をつけます。

interactivePopGestureRecognizer

    private var interactivePopGestureHandler: InteractivePopGestureHandler?
    override public func viewDidLoad() {
        // (中略)
        interactivePopGestureHandler = InteractivePopGestureHandler(controller: self)

        if let interactivePopGestureRecognizer = self.interactivePopGestureRecognizer {
            interactivePopGestureRecognizer.delegate = interactivePopGestureHandler
        } else {
            assertionFailure("interactivePopGestureRecognizerが作成されてあると期待されています")
        }
    }

ここのinteractivePopGestureRecognizerの扱いがUINavigationControllerの細かい挙動に依存していて、この実装の一番壊れやすい部分の気がします。とはいえ、試してみたどのiOSバージョンでも問題なさそうでした。

UINavigationControllerは戻るボタンが隠れている場合(ナビゲーションバーが隠れている場合も含む)、自分のinteractivePopGestureRecognizerを無効にしています。このinteractivePopGestureRecognizerがスワイプで前の画面に戻る動作を扱うUIGestureRecognizerです。

ナビゲーションバーが隠れて、無効になったinteractivePopGestureRecognizerdelegateを自分で設定すると、改めて有効になります。

UIGestureRecognizerDelegateの準拠はNoBarNavigationController自身ではなく、別のクラスにしたのは、UINavigationControllerがやっていることとぶつかるリスクを最低限にしたかったからです。この準拠を見てみましょう。

private final class InteractivePopGestureHandler: NSObject, UIGestureRecognizerDelegate {
    // 循環参照を避けるために`weak`
    weak var navigationController: UINavigationController!

    init(controller: UINavigationController) {
        navigationController = controller
    }

    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return navigationController.viewControllers.count > 1
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        // 表示されているビューコントローラーにスクロールビューが入っているとき、
        // スワイプで前の画面に戻ろうとしていると同時に指を上下に動かすと、スクロールビューも上下にスクロールしないために
        return false
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        // ウェブビューが最初の読み込み中、そうしないと読み込みが終わるまでスワイプで前の画面に戻れません
        return otherGestureRecognizer is UIPanGestureRecognizer
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
        return true
    }
}

このinteractivePopGestureRecognizerの扱いが壊れやすいなら、なぜ自分で作成した新しいジェスチャーレコグナイザーを使わなかったのでしょうか。残念ながら、そうしようとすると、複雑そうな遷移のアニメーションの扱い全部を再実装しなければいけません。難易度と大変さがグンと上がります。その上で、調べてみた時、子ビューコントローラー間の遷移のアニメーションに関するドキュメントが少なくて、本当に自分で完全に実装できるか疑問だった部分もありました。既存のinteractivePopGestureRecognizerを使った方が良いという結論に至りました。

ラップ

ナビゲーションコントローラー自体に戻って、NoBarNavigationController.initの話をした時に飛ばしたナビゲーションスタックに入るビューコントローラーのラップの仕組みの話をしましょう。

initに渡されたビューコントローラーはSelf.wrapIfNeeded(_:)を使ってラップしていましたが、他の方法で挿入されるビューコントローラーもラップされます。

    override public func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
        let wrappedViewControllers = viewControllers.map { Self.wrapIfNeeded($0) }
        super.setViewControllers(wrappedViewControllers, animated: animated)
    }

    override public func pushViewController(_ viewController: UIViewController, animated: Bool) {
        let wrappedViewController = Self.wrapIfNeeded(viewController)
        super.pushViewController(wrappedViewController, animated: animated)
    }

関心の処理をしているwrapIfNeeded(_:)を見てみましょう。気をつけるべき点はwrapIfNeeded(wrapIfNeeded(viewController))wrapIfNeeded(viewController)と同じ値を返すべきところです。そうでないと、viewControllers配列に変更を加えるとき、変えていないビューが二重にラップされる可能性が出てきます。

    private static func wrapIfNeeded(_ originalViewController: UIViewController) -> UIViewController {
        let viewController: UIViewController
        if originalViewController is AdditionalToolbarNotNeeded {
            assert(!(originalViewController is AdditionalToolbarNeeded), "AdditionalToolbarNeededとAdditionalToolbarNotNeeded両方に準拠していて矛盾がある")
            // ラップする必要がありません
            viewController = originalViewController
        } else if let toolbarNeedingViewController = originalViewController as? AdditionalToolbarNeeded {
            viewController = toolbarNeedingViewController.wrapInContainer()
        } else {
            // ビューコントローラーに特別な指定がないので、ツールバーをつけておきます
            viewController = FixedHeightToolbarProvidingContainerViewController(
                embedded: originalViewController,
                toolbarViewController: EmbeddedNavigationToolbarViewController(viewController: originalViewController)
            )
        }

        // wrapIfNeeded(wrapIfNeeded(viewController)) == wrapIfNeeded(viewController)を保証
        assert(viewController is AdditionalToolbarNotNeeded, "戻り値がAdditionalToolbarNotNeededに準拠していないとwrapIfNeeded(wrapIfNeeded(viewController))で二重ラップが起きる恐れがある")
        return viewController
    }

まだ話していないプロトコルが2つ登場しています:AdditionalToolbarNotNeededAdditionalToolbarNeeded。ここで「ツールバー」はナビゲーションバー相当のものです。命名は「NavigationBar」ではなく「Toolbar」にしたのは本物のナビゲーションバー(UINavigationBar)と区別をつけるためです。

AdditionalToolbarNotNeeded

    private static func wrapIfNeeded(_ originalViewController: UIViewController) -> UIViewController {
        // (中略)
        if originalViewController is AdditionalToolbarNotNeeded {

ビューコントローラーがAdditionalToolbarNotNeededに準拠していると、ツールバーをつけるべきではないと意味します。画面全体で表示したいビューコントローラーでも使えますが、一番のユースケースはツールバーをつけてくれるラッパービューコントローラーです。そのラッパービューコントローラーはAdditionalToolbarNotNeededに準拠することで二重ラップされるのを防ぎます。

定義がとてもシンプルで、メソッドがありません。

public protocol AdditionalToolbarNotNeeded: UIViewController {}

AdditionalToolbarNeeded

    private static func wrapIfNeeded(_ originalViewController: UIViewController) -> UIViewController {
        let viewController: UIViewController
        if originalViewController is AdditionalToolbarNotNeeded {
            // (中略)
        } else if let toolbarNeedingViewController = originalViewController as? AdditionalToolbarNeeded {
            viewController = toolbarNeedingViewController.wrapInContainer()

AdditionalToolbarNeededはどのビューコントローラーにラップされてほしいのか明示的に指定するためのプロトコルです。メソッドはwrapInContainer()1つだけです。wrapInContainer()の中で自分をラップしているラッパービューコントローラーを作成して返すだけです。メソッドが1つだけだけど、その戻り値にまだ登場していなかったプロトコルも使われています。

public protocol AdditionalToolbarNeeded: UIViewController {
    func wrapInContainer() -> AdditionalToolbarProvidingContainer
}

public protocol AdditionalToolbarProvidingContainer: AdditionalToolbarNotNeeded {
    var providedToolbarViewController: UIViewController { get }
    var embeddedViewController: UIViewController { get }
}

AdditionalToolbarProvidingContainerAdditionalToolbarNotNeededを必要にしているのはAdditionalToolbarNotNeededの話をした時に話した通り二重ラップを防ぐためです。

AdditionalToolbarProvidingContainerにあるprovidedToolbarViewControllerembeddedViewControllerはラッパー(別名コンテナー)に入った2つのビューコントローラーを直接取り出すためです:providedToolbarViewControllerはツールバーを表示してくれるビューコントローラーであって、embeddedViewControllerはラップされている画面の本来のビューコントローラーです。

AdditionalToolbarNotNeededにもAdditionalToolbarNeededにも準拠していない場合

    private static func wrapIfNeeded(_ originalViewController: UIViewController) -> UIViewController {
        let viewController: UIViewController
        if originalViewController is AdditionalToolbarNotNeeded {
            // (中略)
        } else if let toolbarNeedingViewController = originalViewController as? AdditionalToolbarNeeded {
            // (中略)
        } else {
            viewController = FixedHeightToolbarProvidingContainerViewController(
                embedded: originalViewController,
                toolbarViewController: EmbeddedNavigationToolbarViewController(viewController: originalViewController)
            )
        }

ラップされるビューコントローラーがAdditionalToolbarNeededにもAdditionalToolbarNotNeededにも準拠していないときに使われるFixedHeightToolbarProvidingContainerViewControllerはもちろんAdditionalToolbarProvidingContainerに準拠しています。

AdditionalToolbarNeededにもAdditionalToolbarNotNeededにも準拠していないのは以下のようにAdditionalToolbarNeededに準拠している場合と同じ挙動にです。

extension MyScreenViewController: AdditionalToolbarNeeded {
    func wrapInContainer() -> AdditionalToolbarProvidingContainer {
        return FixedHeightToolbarProvidingContainerViewController(
            embedded: self,
            toolbarViewController: EmbeddedNavigationToolbarViewController(viewController: self)
        )
    }
}

UINavigationControllerDelegate

ナビゲーションコントローラーに関してまだ残っているのはあとUINavigationControllerDelegateの準拠だけです。

extension NoBarNavigationController: UINavigationControllerDelegate {
    public func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        assert(self == navigationController)
        additionalDelegate?.noBarNavigationController(self, didShow: viewController, animated: animated)
    }

navigationController(_:didShow:animated:)additionalDelegateの同じメソッドを呼んでいるだけです。

    public func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
        assert(self == navigationController)

        // 遷移の前後が`FixedHeightToolbarProvidingContainerViewController`のインスタンスの場合のみ、カスタムなトランジションを使います
        if animated,
           let transitionCoordinator = self.transitionCoordinator,
           let source = transitionCoordinator.viewController(forKey: .from) as? FixedHeightToolbarProvidingContainerViewController,
           let destination = transitionCoordinator.viewController(forKey: .to) as? FixedHeightToolbarProvidingContainerViewController {
            FixedHeightToolbarProvidingContainerViewController.animateAlongsideTransition(
                from: source,
                to: destination,
                inside: self,
                coordinatedBy: transitionCoordinator
            )
        }

        additionalDelegate?.noBarNavigationController(self, willShow: viewController, animated: animated)
    }
}

navigationController(_:willShow:animated:)も匹敵するadditionalDelegateのメソッドを呼んでいますが、その前に遷移がFixedHeightToolbarProvidingContainerViewControllerからFixedHeightToolbarProvidingContainerViewControllerへの場合のみ、特別なアニメーションの準備をします。

iPhoneを手にとってください。Apple標準のアプリ(例えば設定アプリ)でも、サードパーティーのいくつかのアプリでも、ナビゲーションコントローラーで遊んでみてください。アニメーションに気をつけながら、ビューコントローラーをプッシュして、スワイプで前の画面をゆっくり戻って、改めてプッシュして、を繰り返してみましょう。よく見ると画面間のトランジションが意外と複雑です。設定アプリのトップ画面のようにナビゲーションバーのタイトルが画面のビューコントローラー自体に溶け込んでいる場合は普通のナビゲーションバーとまた少し違います。ナビゲーションバーをカスタマイズしている一部の第三者アプリでトランジションがスムーズでない時もあります。

この記事のようなナビゲーションバーのないナビゲーションコントローラーの場合、特別なことをしない限り、ツールバーがシステムにとって表示されているビューコントローラーの一部でしかないので、トランジションはビューコントローラー全体が滑り込むだけです。そこまで悪くもないのですが、もう少しこだわれると思います。標準のナビゲーションバーのトランジションが複雑なので、結局iOSクックパッドではフェードイン・フェードアウトだけにしました。ナビゲーションバーの高さが変わる場合がさらに複雑なので標準の全体滑り込むアニメーションだけになります。詳細は後ほどFixedHeightToolbarProvidingContainerViewControllerの話をする時にしましょう。

NoBarNavigationControllerはこれですべてのコードに目を通したので、次はデフォルトで使われるラッパー/コンテナーを見ようと思います。

FixedHeightToolbarProvidingContainerViewController

FixedHeightToolbarProvidingContainerViewControllerはデフォルトで使われるコンテナーです。ラップされているビューコントローラーとその上のツールバーの表示・管理をしているビューコントローラーを束ねているだけです。FixedHeightToolbar命名の通りツールバーの高さが作成時に決まって、その後に変わることがありません。透過のツールバーも対応されていません。iOSクックパッドでは透過しているツールバーは別のコンテナーが使われますが、基礎は同じです。

構成は本当にシンプルです。

public final class FixedHeightToolbarProvidingContainerViewController: UIViewController {
    private let embedded: UIViewController
    private let toolbarViewController: FixedHeightToolbarViewController

    public init(
        embedded: UIViewController,
        toolbarViewController: FixedHeightToolbarViewController
    ) {
        self.embedded = embedded
        self.toolbarViewController = toolbarViewController

        super.init(nibName: nil, bundle: nil)

        addChild(toolbarViewController)
        toolbarViewController.didMove(toParent: self)

        addChild(embedded)
        embedded.didMove(toParent: self)
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

toolbarViewControllerの定義をよく見ると、FixedHeightToolbarViewControllerというプロトコルが使われているのですが、このプロトコルには複雑なところが特にないと思います(命名が似ていて少し分かりにくいかもしれませんが、このツールバービューコントローラーのプロトコル名はコンテナーのクラス名からProvidingContainerを外したものです)

public protocol FixedHeightToolbarViewController: UIViewController {
    // このビューコントローラーが表示されている間にナビゲーションコントローラーの`popViewController()`を呼べるかどうか
    // (基本的に戻るボタンを表示すべきか)
    var canPop: Bool { get set }
    // ツールバーの高さ(決まってから変わるべきでない)
    var toolbarHeight: CGFloat { get }
    // ツールバーの背景色
    var toolbarBackgroundColor: UIColor { get }
}

コンテナーはもちろんAdditionalToolbarNeededの話をした時に説明したAdditionalToolbarProvidingContainerには準拠しています。

extension FixedHeightToolbarProvidingContainerViewController: AdditionalToolbarProvidingContainer {
    public var providedToolbarViewController: UIViewController { return toolbarViewController }
    public var embeddedViewController: UIViewController { return embedded }
}

戻るボタンを表示すべきかどうかはツールバーのビューコントローラーが自分で判断するのが難しいので、コンテナーがviewWillAppearviewDidAppearのタイミングで伝えます。

    private var canPop: Bool {
        // 自分がナビゲーションスタックの一番最初のビューコントローラーの場合だけポップできません
        return navigationController?.viewControllers.first != self
    }

    override public func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // 表示される度に`toolbarViewController.canPop`を更新します。
        // 以前表示されてからナビゲーションスタックが変わった可能性があります。
        let canPop = self.canPop
        if toolbarViewController.canPop != canPop {
            toolbarViewController.canPop = canPop
        }
    }

    override public func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // `toolbarViewController.canPop`の更新は`viewWillAppear`のタイミングだけで良さそうに感じるかもしれないが、
        // 色々試したら`viewWillAppear`のタイミングで`navigationController?.viewControllers`が最新状態になっていないこともあったので、
        // 念のために`viewDidAppear`でもやります
        let canPop = self.canPop
        if toolbarViewController.canPop != canPop {
            toolbarViewController.canPop = canPop
        }
    }

ビューの配置も複雑なことが特にありません。

    override public func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = toolbarViewController.toolbarBackgroundColor

        embedded.view.translatesAutoresizingMaskIntoConstraints = false
        // ツールバーより後ろになるために`embedded`の`view`を最初に追加します
        // (自分の`bounds`を超えるやんちゃなビューコントローラーがいる)
        view.addSubview(embedded.view)
        embedded.view.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        embedded.view.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        embedded.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true

        // ツールバーはセーフエリア内にとどまるので、その外でツールバーの背景色を出すのは`toolbarBackgroundView`
        let toolbarBackgroundView = UIView()
        toolbarBackgroundView.backgroundColor = toolbarViewController.toolbarBackgroundColor
        toolbarBackgroundView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(toolbarBackgroundView)
        toolbarBackgroundView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        toolbarBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        toolbarBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        toolbarBackgroundView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: toolbarViewController.toolbarHeight).isActive = true
        embedded.view.topAnchor.constraint(equalTo: toolbarBackgroundView.bottomAnchor).isActive = true

        toolbarViewController.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(toolbarViewController.view)
        toolbarViewController.view.heightAnchor.constraint(equalToConstant: toolbarViewController.toolbarHeight).isActive = true
        toolbarViewController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
        toolbarViewController.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
        toolbarViewController.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true
    }

FixedHeightToolbarProvidingContainerViewControllerはあとトランジションの話に出ていたanimateAlongsideTransitionだけです。以前説明した通り、ツールバーの部分だけ、フェードイン・フェードアウトをします。システムが既にやっているトランジションとぶつかりたくないので、既存のビューをできるだけいじらないで、代わりにスナップショットを撮って、独自アニメーションはスナップショットだけを使います。少し心配だった部分あったのでassertを多めです。

    static func animateAlongsideTransition(
        from source: FixedHeightToolbarProvidingContainerViewController,
        to destination: FixedHeightToolbarProvidingContainerViewController,
        inside navigationController: NoBarNavigationController,
        coordinatedBy coordinator: UIViewControllerTransitionCoordinator
    ) {
        // 高さが違っていれば、標準のアニメーションだけにします
        if source.toolbarViewController.toolbarHeight != destination.toolbarViewController.toolbarHeight {
            return
        }

        destination.loadViewIfNeeded()

        guard let sourceSnapshot = source.toolbarViewController.view.snapshotView(afterScreenUpdates: false) else { return }
        let destinationSnapshot: UIView?
        // `destination`のビューがまだ表示されていなくて、ビューのヒエラルキーに入っていないはず
        if destination.view.superview == nil {
            // `destination`の親が`navigationController`でなければ、`destination.view`を`navigationController.view`に追加したらクラッシュしてしまいます
            // (子ビューコントローラーのビューがその親ビューコントローラーのビューのサブビューであるべきなので)
            assert(destination.parent == navigationController, "予期しない状態")
            // `destination.view`がビューのヒエラルキーに入っていないとスナップショットを撮れないので、一時的に`navigationController.view`に追加します
            navigationController.view.addSubview(destination.view)
            destination.view.layoutIfNeeded()
            // `destination.toolbarViewController`がまだ表示されていないので、`afterScreenUpdates`を`true`にしないとスナップショットが撮れません
            destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates: true)
            // `destination`を元の状態に戻します
            destination.view.removeFromSuperview()
        } else {
            assertionFailure("予期しない状態")
            destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates: false)
        }

        // アニメーションの最初の状態
        // `toolbarBackgroundView`が本当のツールバーを隠してくれます
        let toolbarBackgroundView = UIView()
        toolbarBackgroundView.backgroundColor = source.toolbarViewController.toolbarBackgroundColor
        toolbarBackgroundView.frame = CGRect(
            x: 0,
            y: 0,
            width: source.toolbarViewController.view.bounds.width,
            height: source.toolbarViewController.view.frame.maxY
        )
        coordinator.containerView.addSubview(toolbarBackgroundView)

        sourceSnapshot.frame = source.toolbarViewController.view.frame
        toolbarBackgroundView.addSubview(sourceSnapshot)
        sourceSnapshot.alpha = 1

        if let destinationSnapshot = destinationSnapshot {
            destinationSnapshot.frame = destination.toolbarViewController.view.frame
            toolbarBackgroundView.addSubview(destinationSnapshot)
            destinationSnapshot.alpha = 0
        } else {
            assertionFailure("予期しない状態")
        }

        coordinator.animate(alongsideTransition: { context in
            context.containerView.bringSubviewToFront(toolbarBackgroundView)

            // アニメーションの最後の状態
            destinationSnapshot?.alpha = 1
            sourceSnapshot.alpha = 0
            toolbarBackgroundView.backgroundColor = destination.toolbarViewController.toolbarBackgroundColor
        }, completion: { _ in
            // アニメーションのために追加していた`toolbarBackgroundView`とそのサブビューであるスナップショットを外します
            toolbarBackgroundView.removeFromSuperview()
        })
    }

ツールバー

あと残るのはツールバー自体だけです。iOSクックパッドは本来多くの画面で使われるツールバーに機能が豊富です。真ん中に表示されるのは画面によってタイトルだけ、タイトルとサブタイトル、検索ボックス。検索ボックスをタップすると表示させる検索ビューコントローラーの扱いもツールバーのビューコントローラーに入っています。この記事が既に複雑で長いので、ここでシンプルなタイトルを表示するだけにしようと思います。

f:id:vincentisambart:20211027160223p:plain:w300 f:id:vincentisambart:20211027160305p:plain:w300

ツールバーといっても、ビューコントローラー(EmbeddedNavigationToolbarViewController)とビュー(EmbeddedNavigationToolbar)に分かれています。

EmbeddedNavigationToolbarViewController

EmbeddedNavigationToolbarViewControllerは単にEmbeddedNavigationToolbarを表示して、FixedHeightToolbarProvidingContainerViewControllerEmbeddedNavigationToolbarの仲介をしているだけです。

final class EmbeddedNavigationToolbarViewController: UIViewController, FixedHeightToolbarViewController {
    private let viewController: UIViewController

    init(viewController: UIViewController) {
        self.viewController = viewController
        super.init(nibName: nil, bundle: nil)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func loadView() {
        let navigationToolbar = EmbeddedNavigationToolbar(
            viewController: viewController,
            canPop: canPop
        )
        navigationToolbar.delegate = self
        view = navigationToolbar
    }

    private var toolbar: EmbeddedNavigationToolbar {
        guard let toolbar = view as? EmbeddedNavigationToolbar else {
            fatalError("ビューがEmbeddedNavigationToolbarのインスタンスのはず")
        }
        return toolbar
    }

    // MARK: FixedHeightToolbarViewController

    var canPop: Bool = false {
        didSet {
            if isViewLoaded {
                toolbar.canPop = canPop
            }
        }
    }

    let toolbarHeight = EmbeddedNavigationToolbar.height
    let toolbarBackgroundColor = EmbeddedNavigationToolbar.backgroundColor
}

やっている一番ビューコントローラーらしいことは戻るボタンのタップの扱いかもしれません。

extension EmbeddedNavigationToolbarViewController: EmbeddedNavigationToolbarDelegate {
    func navigationToolbar(_ navigationToolbar: EmbeddedNavigationToolbar, didTapBackButton backButton: UIButton) {
        navigationController?.popViewController(animated: true)
    }
}

EmbeddedNavigationToolbar

あとはEmbeddedNavigationToolbarViewControllerviewであるEmbeddedNavigationToolbarだけです。EmbeddedNavigationToolbarは普通のビューですが、一番重要なのが左右のボタンと真ん中のtitleViewの扱いです。

でもサブビューの話の前には定数の定義を見ましょう。

final class EmbeddedNavigationToolbar: UIView {
    private static let horizontalMargin: CGFloat = 7.0
    private static let titleViewVerticalMargin: CGFloat = 7.0
    private static let horizontalSpacing: CGFloat = 3.0
    private static let titleViewHorizontalMargin: CGFloat = 7.0
    private static let itemsStackViewMinimumWidth: CGFloat = 2.0
    static let height: CGFloat = {
        // 実は標準のナビゲーションバーの高さはそんなにシンプルではありません。
        // 基本的にiPadでは50ptであって、iPhoneでは44ptですが、iPhoneのモーダルの場合は56ptのようです。
        // それをこの仕組みで実現できないか検証する予定ではありますが、まだやっていません。
        if UIDevice.current.userInterfaceIdiom == .pad {
            return 50.0
        } else {
            return 44.0
        }
    }()
    static let backgroundColor: UIColor = .lightGray

高さの問題を除いて、特に目立つことはなかったと思います。左右のボタンの配置はスタックビューを使用します。

    private let leftItemsStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.spacing = horizontalSpacing
        stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return stackView
    }()

    private let rightItemsStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.spacing = horizontalSpacing
        stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return stackView
    }()

initはインスタンス変数の初期化やAuto Layoutの制約が特別なことをやっていないのですが、気になりそうなのはobserveの使い方だと思います。コメントで経緯を説明します。

    private let navigationItem: UINavigationItem
    private var titleView: UIView?
    var canPop: Bool {
        didSet {
            if canPop != oldValue {
                recreateButtons()
            }
        }
    }
    private var sideButtonItemsObservations: [NSKeyValueObservation] = []
    private var titleViewObservation: NSKeyValueObservation?
    weak var delegate: EmbeddedNavigationToolbarDelegate?

    // `viewController`がこのツールバーの下に表示されるビューコントローラーです。ツールバーに表示される情報がこのビューコントローラーが元です。
    // `canPop`は上記に説明した通り、`viewController`を自分のナビゲーションコントローラーからポップできるのか、
    // すなわちナビゲーションコントローラーのナビゲーションスタックの最初のビューコントローラーじゃないことを示します。
    init(
        viewController: UIViewController,
        canPop: Bool
    ) {
        navigationItem = viewController.navigationItem
        self.canPop = canPop

        super.init(frame: .zero)

        backgroundColor = Self.backgroundColor
        clipsToBounds = true

        leftItemsStackView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(leftItemsStackView)
        leftItemsStackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
        leftItemsStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.horizontalMargin).isActive = true

        rightItemsStackView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(rightItemsStackView)
        rightItemsStackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
        rightItemsStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.horizontalMargin).isActive = true

        // ツールバーの真ん中らに入る`titleView`の指定が変わったら、すぐ反映されるためにKVOを使います。
        // `options: .initial`が指定されているので、変更の時だけではなく、クロージャーが最初の状態でも呼ばれます。
        titleViewObservation = navigationItem.observe(\.titleView, options: .initial) { [weak self, weak viewController] navigationItem, _ in
            guard let self = self, let viewController = viewController else { return }
            self.setUpTitleView(navigationItem.titleView ?? self.makeDefaultTitleView(for: viewController))
        }

        // ツールバーに影響のある`UINavigationItem`のプロパティもKVOで監視します。
        // すべてのクロージャーが同じことをやっていますが、値の型がいくつかあるので、全部を1つのクロージャーにまとめられません。
        sideButtonItemsObservations = [
            navigationItem.observe(\.leftBarButtonItem) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.leftBarButtonItems) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.rightBarButtonItem) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.rightBarButtonItems) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.hidesBackButton) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.leftItemsSupplementBackButton) { [weak self] _, _ in self?.recreateButtons() },
        ]

        // 左右のボタンを最初の状態で作成しておきます。
        recreateButtons()
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // 高さが固定なので、AutoLayoutにその高さを教えておきましょう。
    override var intrinsicContentSize: CGSize {
        return CGSize(width: UIView.noIntrinsicMetric, height: Self.height)
    }

KVOはSwift時代でユースケースが限られていますが、ここでは活用する必要があります。

delegateは戻るボタンの扱いだけに使われています。

protocol EmbeddedNavigationToolbarDelegate: AnyObject {
    func navigationToolbar(_ navigationToolbar: EmbeddedNavigationToolbar, didTapBackButton backButton: UIButton)
}

その戻るボタンを表示すべきかどうかはUINavigationItemの標準の仕様に合わせています。

    private var shouldDisplayBackButton: Bool {
        if !canPop {
            return false
        }
        if navigationItem.hidesBackButton {
            return false
        }
        return (navigationItem.leftBarButtonItem == nil || navigationItem.leftItemsSupplementBackButton)
    }

その戻る含む左右のボタンの作成はrecreateButtons()が担当しています。

    private func recreateButtons() {
        // ボタンを作り直すので、以前のボタンをまず排除する必要があります。
        (leftItemsStackView.arrangedSubviews + rightItemsStackView.arrangedSubviews).forEach { $0.removeFromSuperview() }

        var leftBarButtonItems = navigationItem.leftBarButtonItems ?? []
        if shouldDisplayBackButton {
            leftBarButtonItems.insert(makeBackButtonItem(), at: 0)
        }

        Self.makeButtons(from: leftBarButtonItems).forEach { leftItemsStackView.addArrangedSubview($0) }
        // `rightBarButtonItems`には一番右のボタンが最初に入っているので、スタックビューにボタンを入れる際は並び順を逆にする必要があります。
        Self.makeButtons(from: navigationItem.rightBarButtonItems).reversed().forEach { rightItemsStackView.addArrangedSubview($0) }

        Self.preventFromExpandingHorizontally(leftItemsStackView)
        Self.preventFromExpandingHorizontally(rightItemsStackView)
    }

recreateButtons()は長くないのですが、ツールバーの他のメソッドをいくつか呼んでいるのでそのメソッドを見てみましょう。まず本当のボタンをUIBarButtonItemから作成するmakeButtons(from:)があります。

    private static func makeButtons(from barButtonItems: [UIBarButtonItem]?) -> [NavigationToolbarButton] {
        return (barButtonItems ?? []).map { item in
            let button = NavigationToolbarButton(barButtonItem: item)
            button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
            return button
        }
    }

NavigationToolbarButtonはあとで見ましょう。setContentHuggingPriority(_:for:)はボタンが必要以上に大きくならないためです。

スタックビューが空っぽの場合、幅の定義がないのでシンプルのUIView同様制約によってどの幅にもなれます。特に真ん中のtitleViewの左右が左右のスタックビューに結びついている場合、titleViewが取ってほしいスペースを取ってくれないので、それを避けるために、スタックビューが空の場合、幅固定(2pt)のシンプルなビューを入れておきます。

    private static func preventFromExpandingHorizontally(_ stackView: UIStackView) {
        assert(stackView.axis == .horizontal, "垂直のスタックビューに対応していない")
        if !stackView.arrangedSubviews.isEmpty {
            return
        }
        let view = UIView()
        view.widthAnchor.constraint(equalToConstant: itemsStackViewMinimumWidth).isActive = true
        stackView.addArrangedSubview(view)
    }

戻るボタンは見た目が普通のleftBarButtonItemsと同じなので、直接作るのではなく、UIBarButtonItemを作って、ボタンが普通のleftBarButtonItemsと一緒に作成されるようにしました。

    private func makeBackButtonItem() -> UIBarButtonItem {
        // `UIBarButtonItem(systemItem:)`に渡される`systemItem`は作成後に知るすべがありません。
        // `NavigationToolbarButton`が`UIBarButtonItem`を元に作成される時、表示されてほしい画像を入手する必要があるので、`UIBarButtonItem(systemItem:)`を使えません。
        // 代わりにSF Symbolを活用して、シンボルから普通の画像を生成します。
        let backButtonImage = UIImage(
            systemName: "chevron.backward",
            withConfiguration: UIImage.SymbolConfiguration(pointSize: 23)
        )?.withTintColor(.orange, renderingMode: .alwaysOriginal)
        let backButtonItem = UIBarButtonItem(
            image: backButtonImage,
            style: .plain,
            target: self,
            action: #selector(didTapBackButton)
        )
        backButtonItem.accessibilityLabel = "戻る"
        return backButtonItem
    }

    @objc private func didTapBackButton(_ sender: UIButton) {
        // 自分の`delegate`を呼ぶだけです。
        delegate?.navigationToolbar(self, didTapBackButton: sender)
    }

titleViewの作成と配置はシンプルでです。余談ですが、実は、iOSクックパッドはtitleView配置にモードが2つあります(centerfill)。全体のコードが既に十分複雑なので、ここはtitleViewに画面の全ての幅を取らせるfillだけにしました。

    private func setUpTitleView(_ titleView: UIView) {
        self.titleView?.removeFromSuperview()
        self.titleView = titleView

        titleView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(titleView)
        titleView.topAnchor.constraint(equalTo: topAnchor, constant: Self.titleViewVerticalMargin).isActive = true
        titleView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.titleViewVerticalMargin).isActive = true
        titleView.leadingAnchor.constraint(
            equalTo: leftItemsStackView.trailingAnchor,
            constant: Self.titleViewHorizontalMargin
        ).isActive = true
        titleView.trailingAnchor.constraint(
            equalTo: rightItemsStackView.leadingAnchor,
            constant: -Self.titleViewHorizontalMargin
        ).isActive = true
    }

    private func makeDefaultTitleView(for viewController: UIViewController) -> UIView {
        let titleView = EmbeddedNavigationToolbarTitleOnlyTitleView()
        titleView.observe(viewController: viewController)
        return titleView
    }
}

デフォルトのtitleViewであるEmbeddedNavigationToolbarTitleOnlyTitleViewでやっている時別なことはviewControllernavigationItemtitleを監視しているところくらいです。

final class EmbeddedNavigationToolbarTitleOnlyTitleView: UIView {
    private let titleLabel = UILabel()

    init() {
        super.init(frame: .zero)

        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(titleLabel)
        titleLabel.topAnchor.constraint(equalTo: topAnchor).isActive = true
        titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
        titleLabel.textAlignment = .center
        titleLabel.numberOfLines = 2
        titleLabel.adjustsFontSizeToFitWidth = true
        titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    }

    private var titleObservation: NSKeyValueObservation?

    func observe(viewController: UIViewController) {
        titleObservation = viewController.navigationItem.observe(\.title, options: [.initial]) { [weak self] navigationItem, _ in
            self?.titleLabel.text = navigationItem.title
        }
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

最後は左右のボタンに使われるNavigationToolbarButtonです。UIBarButtonItemを元に普通のボタンを作成しています。戻るボタンの話をした時も書きましたが、残念ながらUIBarButtonItem(systemItem:)で作成されたUIBarButtonItemは渡されたsystemItemをあとで分かるAPIがないので、対応できません。makeBackButtonItem()同様SF Symbolsを活用するのが一番無難かと思います。

public final class NavigationToolbarButton: UIButton {
    private static let buttonHorizontalPadding: CGFloat = 2.0
    private let barButtonItem: UIBarButtonItem
    private var enabledObservation: NSKeyValueObservation?

    public init(barButtonItem: UIBarButtonItem) {
        self.barButtonItem = barButtonItem

        super.init(frame: .zero)

        if let image = barButtonItem.image {
            setImage(image, for: .normal)
        } else if let title = barButtonItem.title {
            setTitle(title, for: .normal)
            if let tintColor = barButtonItem.tintColor {
                setTitleColor(tintColor, for: .normal)
                setTitleColor(tintColor.heavilyHighlighted, for: .highlighted)
            } else {
                setTitleColor(.black, for: .normal)
            }
            setTitleColor(.gray, for: .disabled)

            if let titleTextAttributes = barButtonItem.titleTextAttributes(for: .normal),
               let font = titleTextAttributes[.font] as? UIFont {
                titleLabel?.font = font
            } else {
                titleLabel?.font = UIFont.systemFont(ofSize: 16)
            }
        } else {
            // ここがこの仕組みの制限の1つです。
            // なぜか`UIBarButtonItem`作成時に渡された`systemItem`をあとで取り出す方法がないので、ボタンを作れません。
            // また、`UIBarButtonItem`作成時に渡された`customView`に関しては取り出せるようですが、需要がなかったのでここで対応していません。
            fatalError("このボタンアイテムの種類に対応していない:\(barButtonItem)")
        }
        contentEdgeInsets = UIEdgeInsets(
            top: 0,
            left: Self.buttonHorizontalPadding,
            bottom: 0,
            right: Self.buttonHorizontalPadding
        )
        accessibilityLabel = barButtonItem.accessibilityLabel
        if let target = barButtonItem.target, let action = barButtonItem.action {
            addTarget(target, action: action, for: .touchUpInside)
        }

        enabledObservation = barButtonItem.observe(\.isEnabled, options: [.initial]) { [weak self] item, _ in
            self?.isEnabled = item.isEnabled
        }

        sizeToFit()

        if #available(iOS 13.4, *) {
            isPointerInteractionEnabled = true
        }
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extension UIColor {
    fileprivate var rgbaComponents: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
        var red: CGFloat = 0.0
        var green: CGFloat = 0.0
        var blue: CGFloat = 0.0
        var alpha: CGFloat = 0.0

        getRed(&red, green: &green, blue: &blue, alpha: &alpha)

        return (red, green, blue, alpha)
    }

    fileprivate var heavilyHighlighted: UIColor {
        let ratio: CGFloat = 0.85
        let (red, green, blue, alpha) = rgbaComponents

        return UIColor(
            red: red * ratio,
            green: green * ratio,
            blue: blue * ratio,
            alpha: alpha
        )
    }
}

最後に

ナビゲーションコントローラーのツールバーを自由に定義できる仕組みは結局必要だったコードの量がそれなりにありました。

iOSクックパッドはどの画面でもこのナビゲーションコントローラーを使っています。透過しているツールバーはEmbeddedNavigationToolbarViewController/EmbeddedNavigationToolbarを改造して作られています。

実装はできたけど、懸念点は少なくありません。

  • 標準のナビゲーションバーの高さがモーダルの時に変わるのは現時点で対応していません。
  • スワイプで戻れる動作が動くために、ドキュメントされていない挙動に頼っています。
  • ツールバーの左右のボタンの定義はUIBarButtonItem(systemItem:)を使えません。
  • ツールバーの遷移アニメーションが標準のと違います。
  • SwiftUIが勝手にナビゲーションバーを表示しようとしているので、それを無視していることでいずれ不具合が発生する可能性があります。

懸念点の一部はもっと頑張れば対応できると思いますが、一部はApple側で変更が必要です。最近Apple側でナビゲーションバーをもっと柔軟にカスタマイズできる動きがあるように見えないので、システムを自分のデザインに合わせるのではなく、自分のデザインをシステム標準のものに合わせた方がおすすめです。

頻繁にこんな細かいコードを書いているわけではありませんが、iOSエンジニアの仲間は募集しているので、興味ある方はぜひご応募ください https://info.cookpad.com/careers/

// This project is licensed under the MIT license.
//
// Copyright (c) 2021 Cookpad Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

import UIKit

public protocol AdditionalToolbarNotNeeded: UIViewController {}

// If a view controller does conform to neither `AdditionalToolbarNeeded` nor `AdditionalToolbarNotNeeded`,
// the behavior is the same as if it conformed to `AdditionalToolbarNeeded` and defined `wrapInContainer()` as follows:
// func wrapInContainer() -> AdditionalToolbarProvidingContainer {
//     return FixedHeightToolbarProvidingContainerViewController(
//         embedded: self,
//         toolbarViewController: EmbeddedNavigationToolbarViewController(viewController: self)
//     )
// }
public protocol AdditionalToolbarNeeded: UIViewController {
    func wrapInContainer() -> AdditionalToolbarProvidingContainer
}

public protocol AdditionalToolbarProvidingContainer: AdditionalToolbarNotNeeded {
    var providedToolbarViewController: UIViewController { get }
    var embeddedViewController: UIViewController { get }
}

public protocol NoBarNavigationControllerDelegate: AnyObject {
    func noBarNavigationController(_ navigationController: NoBarNavigationController, willShow viewController: UIViewController, animated: Bool)
    func noBarNavigationController(_ navigationController: NoBarNavigationController, didShow viewController: UIViewController, animated: Bool)
}

public final class NoBarNavigationController: UINavigationController {
    override public init(rootViewController: UIViewController) {
        let wrappedRootViewController = Self.wrapIfNeeded(rootViewController)
        super.init(nibName: nil, bundle: nil)
        viewControllers = [wrappedRootViewController]
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private var interactivePopGestureHandler: InteractivePopGestureHandler?
    override public func viewDidLoad() {
        super.viewDidLoad()
        delegate = self

        // This navigation controller does not have its own navigation bar.
        // If needed, a view controller pushed will get wrapped by an other view controller
        // that will provide an equivalent to the navigation bar.
        isNavigationBarHidden = true

        interactivePopGestureHandler = InteractivePopGestureHandler(controller: self)

        if let interactivePopGestureRecognizer = self.interactivePopGestureRecognizer {
            // If the back button is hidden (that includes the navigation bar being hidden),
            // `UINavigationController` will disable its `interactivePopGestureRecognizer`.
            // To reenable it, we assign the recognizer's delegate to be own custom handler.
            interactivePopGestureRecognizer.delegate = interactivePopGestureHandler
        } else {
            assertionFailure("interactivePopGestureRecognizerが作成されてあると期待されています")
        }
    }

    public weak var additionalDelegate: NoBarNavigationControllerDelegate?
    override public var delegate: UINavigationControllerDelegate? {
        didSet {
            assert(delegate === self, "If you need to use a delegate, use additionalDelegate instead")
        }
    }

    // Make sure the navigation bar is always hidden.
    // (SwiftUI tends to set it to false)
    override public func setNavigationBarHidden(_ hidden: Bool, animated: Bool) {
        if hidden {
            super.setNavigationBarHidden(hidden, animated: animated)
        }
    }

    override public func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
        let wrappedViewControllers = viewControllers.map { Self.wrapIfNeeded($0) }
        super.setViewControllers(wrappedViewControllers, animated: animated)
    }

    override public func pushViewController(_ viewController: UIViewController, animated: Bool) {
        let wrappedViewController = Self.wrapIfNeeded(viewController)
        super.pushViewController(wrappedViewController, animated: animated)
    }

    private static func wrapIfNeeded(_ originalViewController: UIViewController) -> UIViewController {
        let viewController: UIViewController
        if originalViewController is AdditionalToolbarNotNeeded {
            assert(!(originalViewController is AdditionalToolbarNeeded), "A view controller cannot at the same time want a navigation controller and not want one")
            // No need to wrap.
            viewController = originalViewController
        } else if let toolbarNeedingViewController = originalViewController as? AdditionalToolbarNeeded {
            viewController = toolbarNeedingViewController.wrapInContainer()
        } else {
            // The view controller does not specify anything special, so we create a simple toolbar.
            viewController = FixedHeightToolbarProvidingContainerViewController(
                embedded: originalViewController,
                toolbarViewController: EmbeddedNavigationToolbarViewController(viewController: originalViewController)
            )
        }

        // Ensure that wrapIfNeeded(wrapIfNeeded(viewController)) == wrapIfNeeded(viewController)
        assert(viewController is AdditionalToolbarNotNeeded, "A return value not conforming to AdditionalToolbarNotNeeded risks being doubly wrapped when wrapIfNeeded is called once again on it")
        return viewController
    }
}

private final class InteractivePopGestureHandler: NSObject, UIGestureRecognizerDelegate {
    // `weak` to prevent circular references.
    weak var navigationController: UINavigationController!

    init(controller: UINavigationController) {
        navigationController = controller
    }

    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return navigationController.viewControllers.count > 1
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        // When the view controller displayed contains a scroll view,
        // so that when swiping to get back to the previous view controller,
        // swiping up/down does not also scroll the scroll view.
        return false
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        // When a web view is doing its first loading, so that we can swipe back to the previous view controller.
        return otherGestureRecognizer is UIPanGestureRecognizer
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
        return true
    }
}

extension NoBarNavigationController: UINavigationControllerDelegate {
    public func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        assert(self == navigationController)
        additionalDelegate?.noBarNavigationController(self, didShow: viewController, animated: animated)
    }

    public func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
        assert(self == navigationController)

        // Use a custom transition only when transitioning between 2 instances of `FixedHeightToolbarProvidingContainerViewController`.
        if animated,
           let transitionCoordinator = self.transitionCoordinator,
           let source = transitionCoordinator.viewController(forKey: .from) as? FixedHeightToolbarProvidingContainerViewController,
           let destination = transitionCoordinator.viewController(forKey: .to) as? FixedHeightToolbarProvidingContainerViewController {
            FixedHeightToolbarProvidingContainerViewController.animateAlongsideTransition(
                from: source,
                to: destination,
                inside: self,
                coordinatedBy: transitionCoordinator
            )
        }

        additionalDelegate?.noBarNavigationController(self, willShow: viewController, animated: animated)
    }
}

public final class FixedHeightToolbarProvidingContainerViewController: UIViewController {
    private let embedded: UIViewController
    private let toolbarViewController: FixedHeightToolbarViewController

    public init(
        embedded: UIViewController,
        toolbarViewController: FixedHeightToolbarViewController
    ) {
        self.embedded = embedded
        self.toolbarViewController = toolbarViewController

        super.init(nibName: nil, bundle: nil)

        addChild(toolbarViewController)
        toolbarViewController.didMove(toParent: self)

        addChild(embedded)
        embedded.didMove(toParent: self)
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private var canPop: Bool {
        // Cannot pop only if you are at the start of the navigation stack.
        return navigationController?.viewControllers.first != self
    }

    override public func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // Update `toolbarViewController.canPop` every time this view appears,
        // as it's possible that the navigation stack changed since last time.
        let canPop = self.canPop
        if toolbarViewController.canPop != canPop {
            toolbarViewController.canPop = canPop
        }
    }

    override public func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // It seems like updating `toolbarViewController.canPop` in `viewWillAppear` should be enough,
        // but it turns out that in some cases `navigationController?.viewControllers` is not in
        // its final state when `viewWillAppear` is called, so just in case also do it here.
        // We are not doing it only in `viewDidAppear` because you can have an incorrect appearance for a split second.
        let canPop = self.canPop
        if toolbarViewController.canPop != canPop {
            toolbarViewController.canPop = canPop
        }
    }

    override public func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = toolbarViewController.toolbarBackgroundColor

        embedded.view.translatesAutoresizingMaskIntoConstraints = false
        // Add the embedded view controller before the toolbar so that the toolbar is above.
        // (some badly behaving view controllers go beyond their bounds)
        view.addSubview(embedded.view)
        embedded.view.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        embedded.view.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        embedded.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true

        // The toolbar stays in the safe area, so create a `toolbarBackgroundView` to get our background color outside of it.
        let toolbarBackgroundView = UIView()
        toolbarBackgroundView.backgroundColor = toolbarViewController.toolbarBackgroundColor
        toolbarBackgroundView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(toolbarBackgroundView)
        toolbarBackgroundView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        toolbarBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        toolbarBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        toolbarBackgroundView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: toolbarViewController.toolbarHeight).isActive = true
        embedded.view.topAnchor.constraint(equalTo: toolbarBackgroundView.bottomAnchor).isActive = true

        toolbarViewController.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(toolbarViewController.view)
        toolbarViewController.view.heightAnchor.constraint(equalToConstant: toolbarViewController.toolbarHeight).isActive = true
        toolbarViewController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
        toolbarViewController.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
        toolbarViewController.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true
    }

    static func animateAlongsideTransition(
        from source: FixedHeightToolbarProvidingContainerViewController,
        to destination: FixedHeightToolbarProvidingContainerViewController,
        inside navigationController: NoBarNavigationController,
        coordinatedBy coordinator: UIViewControllerTransitionCoordinator
    ) {
        // If the height is different, use the default animation.
        if source.toolbarViewController.toolbarHeight != destination.toolbarViewController.toolbarHeight {
            return
        }

        destination.loadViewIfNeeded()

        guard let sourceSnapshot = source.toolbarViewController.view.snapshotView(afterScreenUpdates: false) else { return }
        let destinationSnapshot: UIView?
        // Expecting `destination`'s view to not be in the view hierarchy yet.
        if destination.view.superview == nil {
            // If the parent of `destination` is not `navigationController` adding `destination.view` to `navigationController.view` would make the app crash.
            // (as the view of a child view controller should be a subview of its parent's view)
            assert(destination.parent == navigationController, "Unexpected state")
            // If `destination.view` is not in the view hierarchy, we cannot take a snapshot
            // of it or of its subviews, so temporarily add it to `navigationController.view`.
            navigationController.view.addSubview(destination.view)
            destination.view.layoutIfNeeded()
            // `destination.toolbarViewController` has not been displayed yet,
            // so `afterScreenUpdates` has to be `true` to be able to get a snapshot.
            destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates: true)
            // `destination`を元の状態に戻す
            destination.view.removeFromSuperview()
        } else {
            assertionFailure("Unexpected state")
            destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates: false)
        }

        // Animation start state
        // `toolbarBackgroundView` hides the real toolbar.
        let toolbarBackgroundView = UIView()
        toolbarBackgroundView.backgroundColor = source.toolbarViewController.toolbarBackgroundColor
        toolbarBackgroundView.frame = CGRect(
            x: 0,
            y: 0,
            width: source.toolbarViewController.view.bounds.width,
            height: source.toolbarViewController.view.frame.maxY
        )
        coordinator.containerView.addSubview(toolbarBackgroundView)

        sourceSnapshot.frame = source.toolbarViewController.view.frame
        toolbarBackgroundView.addSubview(sourceSnapshot)
        sourceSnapshot.alpha = 1

        if let destinationSnapshot = destinationSnapshot {
            destinationSnapshot.frame = destination.toolbarViewController.view.frame
            toolbarBackgroundView.addSubview(destinationSnapshot)
            destinationSnapshot.alpha = 0
        } else {
            assertionFailure("Unexpected state")
        }

        coordinator.animate(alongsideTransition: { context in
            context.containerView.bringSubviewToFront(toolbarBackgroundView)

            // Animation end state
            destinationSnapshot?.alpha = 1
            sourceSnapshot.alpha = 0
            toolbarBackgroundView.backgroundColor = destination.toolbarViewController.toolbarBackgroundColor
        }, completion: { _ in
            // Remove `toolbarBackgroundView` and its subviews as they were just for the animation.
            toolbarBackgroundView.removeFromSuperview()
        })
    }
}

extension FixedHeightToolbarProvidingContainerViewController: AdditionalToolbarProvidingContainer {
    public var providedToolbarViewController: UIViewController { return toolbarViewController }
    public var embeddedViewController: UIViewController { return embedded }
}

public protocol FixedHeightToolbarViewController: UIViewController {
    // Indicates if the navigation controller's `popViewController()` can be called while this view controller is displayed.
    // (basically should be back button be displayed or not)
    var canPop: Bool { get set }
    // Height of the tool bar (once decided it should not change)
    var toolbarHeight: CGFloat { get }
    // Background color of the toolbar
    var toolbarBackgroundColor: UIColor { get }
}

final class EmbeddedNavigationToolbarViewController: UIViewController, FixedHeightToolbarViewController {
    private let viewController: UIViewController

    init(viewController: UIViewController) {
        self.viewController = viewController
        super.init(nibName: nil, bundle: nil)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func loadView() {
        let navigationToolbar = EmbeddedNavigationToolbar(
            viewController: viewController,
            canPop: canPop
        )
        navigationToolbar.delegate = self
        view = navigationToolbar
    }

    private var toolbar: EmbeddedNavigationToolbar {
        guard let toolbar = view as? EmbeddedNavigationToolbar else {
            fatalError("The view should be an instance of EmbeddedNavigationToolbar")
        }
        return toolbar
    }

    // MARK: FixedHeightToolbarViewController

    var canPop: Bool = false {
        didSet {
            if isViewLoaded {
                toolbar.canPop = canPop
            }
        }
    }

    let toolbarHeight = EmbeddedNavigationToolbar.height
    let toolbarBackgroundColor = EmbeddedNavigationToolbar.backgroundColor
}

extension EmbeddedNavigationToolbarViewController: EmbeddedNavigationToolbarDelegate {
    func navigationToolbar(_ navigationToolbar: EmbeddedNavigationToolbar, didTapBackButton backButton: UIButton) {
        navigationController?.popViewController(animated: true)
    }
}

protocol EmbeddedNavigationToolbarDelegate: AnyObject {
    func navigationToolbar(_ navigationToolbar: EmbeddedNavigationToolbar, didTapBackButton backButton: UIButton)
}

final class EmbeddedNavigationToolbar: UIView {
    private static let horizontalMargin: CGFloat = 7.0
    private static let titleViewVerticalMargin: CGFloat = 7.0
    private static let horizontalSpacing: CGFloat = 3.0
    private static let titleViewHorizontalMargin: CGFloat = 7.0
    private static let itemsStackViewMinimumWidth: CGFloat = 2.0
    static let height: CGFloat = {
        // In fact, the height of the OS's navigation bar is not that simple.
        // It's generally 50pt on iPad and 44pt on iPhone, but when displayed in a modal, it seems to be 56pt.
        // I do plan to check if the system presented here would allow this, but have not started yet.
        if UIDevice.current.userInterfaceIdiom == .pad {
            return 50.0
        } else {
            return 44.0
        }
    }()
    static let backgroundColor: UIColor = .lightGray

    private let leftItemsStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.spacing = horizontalSpacing
        stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return stackView
    }()

    private let rightItemsStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.spacing = horizontalSpacing
        stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return stackView
    }()

    private let navigationItem: UINavigationItem
    private var titleView: UIView?
    var canPop: Bool {
        didSet {
            if canPop != oldValue {
                recreateButtons()
            }
        }
    }
    private var sideButtonItemsObservations: [NSKeyValueObservation] = []
    private var titleViewObservation: NSKeyValueObservation?
    weak var delegate: EmbeddedNavigationToolbarDelegate?

    // `viewController` is the view controller that is displayed below this toolbar.
    // The information to display in this toolbar come from it.
    // `canPop` indicates if `viewController` can be popped out of its navigation controller,
    // in other words it indicates that `viewController` is not the first in the navigation controller's navigation stack.
    init(
        viewController: UIViewController,
        canPop: Bool
    ) {
        navigationItem = viewController.navigationItem
        self.canPop = canPop

        super.init(frame: .zero)

        backgroundColor = Self.backgroundColor
        clipsToBounds = true

        leftItemsStackView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(leftItemsStackView)
        leftItemsStackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
        leftItemsStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.horizontalMargin).isActive = true

        rightItemsStackView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(rightItemsStackView)
        rightItemsStackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
        rightItemsStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.horizontalMargin).isActive = true

        // Using KVO so that changes to `titleView`, the view to display in the middle of the toolbar,
        // are reflected as soon as they happen.
        // Using `options: .initial` so that the closure is called not only on changes but also with the current value.
        titleViewObservation = navigationItem.observe(\.titleView, options: .initial) { [weak self, weak viewController] navigationItem, _ in
            guard let self = self, let viewController = viewController else { return }
            self.setUpTitleView(navigationItem.titleView ?? self.makeDefaultTitleView(for: viewController))
        }

        // Observing with KVO `UINavigationItem` properties that have an effect on the toolbar.
        // All the closures look the same, but their parameters have different types so we cannot just use one closure.
        sideButtonItemsObservations = [
            navigationItem.observe(\.leftBarButtonItem) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.leftBarButtonItems) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.rightBarButtonItem) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.rightBarButtonItems) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.hidesBackButton) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.leftItemsSupplementBackButton) { [weak self] _, _ in self?.recreateButtons() },
        ]

        // Create the buttons on both sides from the starting state.
        recreateButtons()
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // The height is fixed so give it to AutoLayout.
    override var intrinsicContentSize: CGSize {
        return CGSize(width: UIView.noIntrinsicMetric, height: Self.height)
    }

    private var shouldDisplayBackButton: Bool {
        if !canPop {
            return false
        }
        if navigationItem.hidesBackButton {
            return false
        }
        return (navigationItem.leftBarButtonItem == nil || navigationItem.leftItemsSupplementBackButton)
    }

    private func recreateButtons() {
        // As we are recreating the buttons, first remove the previous ones.
        (leftItemsStackView.arrangedSubviews + rightItemsStackView.arrangedSubviews).forEach { $0.removeFromSuperview() }

        var leftBarButtonItems = navigationItem.leftBarButtonItems ?? []
        if shouldDisplayBackButton {
            leftBarButtonItems.insert(makeBackButtonItem(), at: 0)
        }

        Self.makeButtons(from: leftBarButtonItems).forEach { leftItemsStackView.addArrangedSubview($0) }
        // Buttons specified with `rightBarButtonItems` starts from the right, so we have to reverse the order
        // before adding corresponding buttons to the stack view.
        Self.makeButtons(from: navigationItem.rightBarButtonItems).reversed().forEach { rightItemsStackView.addArrangedSubview($0) }

        Self.preventFromExpandingHorizontally(leftItemsStackView)
        Self.preventFromExpandingHorizontally(rightItemsStackView)
    }

    private static func makeButtons(from barButtonItems: [UIBarButtonItem]?) -> [NavigationToolbarButton] {
        return (barButtonItems ?? []).map { item in
            let button = NavigationToolbarButton(barButtonItem: item)
            button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
            return button
        }
    }

    private static func preventFromExpandingHorizontally(_ stackView: UIStackView) {
        assert(stackView.axis == .horizontal, "Vertical stack view are not supported")
        if !stackView.arrangedSubviews.isEmpty {
            return
        }
        let view = UIView()
        view.widthAnchor.constraint(equalToConstant: itemsStackViewMinimumWidth).isActive = true
        stackView.addArrangedSubview(view)
    }

    private func makeBackButtonItem() -> UIBarButtonItem {
        // The `systemItem` passed to `UIBarButtonItem(systemItem:)` cannot be read back after creation.
        // To be able to create a button from a `UIBarButtonItem` we have to be able to get its image,
        // so we cannot use `UIBarButtonItem(systemItem:)`.
        // Instead, making use of SF Symbols, we create a standard image from the symbol.
        let backButtonImage = UIImage(
            systemName: "chevron.backward",
            withConfiguration: UIImage.SymbolConfiguration(pointSize: 23)
        )?.withTintColor(.orange, renderingMode: .alwaysOriginal)
        let backButtonItem = UIBarButtonItem(
            image: backButtonImage,
            style: .plain,
            target: self,
            action: #selector(didTapBackButton)
        )
        backButtonItem.accessibilityLabel = "戻る"
        return backButtonItem
    }

    @objc private func didTapBackButton(_ sender: UIButton) {
        // Just calling the delegate.
        delegate?.navigationToolbar(self, didTapBackButton: sender)
    }

    private func setUpTitleView(_ titleView: UIView) {
        self.titleView?.removeFromSuperview()
        self.titleView = titleView

        titleView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(titleView)
        titleView.topAnchor.constraint(equalTo: topAnchor, constant: Self.titleViewVerticalMargin).isActive = true
        titleView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.titleViewVerticalMargin).isActive = true
        titleView.leadingAnchor.constraint(
            equalTo: leftItemsStackView.trailingAnchor,
            constant: Self.titleViewHorizontalMargin
        ).isActive = true
        titleView.trailingAnchor.constraint(
            equalTo: rightItemsStackView.leadingAnchor,
            constant: -Self.titleViewHorizontalMargin
        ).isActive = true
    }

    private func makeDefaultTitleView(for viewController: UIViewController) -> UIView {
        let titleView = EmbeddedNavigationToolbarTitleOnlyTitleView()
        titleView.observe(viewController: viewController)
        return titleView
    }
}

final class EmbeddedNavigationToolbarTitleOnlyTitleView: UIView {
    private let titleLabel = UILabel()

    init() {
        super.init(frame: .zero)

        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(titleLabel)
        titleLabel.topAnchor.constraint(equalTo: topAnchor).isActive = true
        titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
        titleLabel.textAlignment = .center
        titleLabel.numberOfLines = 2
        titleLabel.adjustsFontSizeToFitWidth = true
        titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    }

    private var titleObservation: NSKeyValueObservation?

    func observe(viewController: UIViewController) {
        titleObservation = viewController.navigationItem.observe(\.title, options: [.initial]) { [weak self] navigationItem, _ in
            self?.titleLabel.text = navigationItem.title
        }
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

public final class NavigationToolbarButton: UIButton {
    private static let buttonHorizontalPadding: CGFloat = 2.0
    private let barButtonItem: UIBarButtonItem
    private var enabledObservation: NSKeyValueObservation?

    public init(barButtonItem: UIBarButtonItem) {
        self.barButtonItem = barButtonItem

        super.init(frame: .zero)

        if let image = barButtonItem.image {
            setImage(image, for: .normal)
        } else if let title = barButtonItem.title {
            setTitle(title, for: .normal)
            if let tintColor = barButtonItem.tintColor {
                setTitleColor(tintColor, for: .normal)
                setTitleColor(tintColor.heavilyHighlighted, for: .highlighted)
            } else {
                setTitleColor(.black, for: .normal)
            }
            setTitleColor(.gray, for: .disabled)

            if let titleTextAttributes = barButtonItem.titleTextAttributes(for: .normal),
               let font = titleTextAttributes[.font] as? UIFont {
                titleLabel?.font = font
            } else {
                titleLabel?.font = UIFont.systemFont(ofSize: 16)
            }
        } else {
            // Here is one limitation of this system.
            // For some reason, a `systemItem` passed to `UIBarButtonItem(systemItem:)` cannot be read back after creation, so we cannot create a button for it.
            // Also, we should be able to support a `UIBarButtonItem`using a `customView` but we did not have any need for it.
            fatalError("Unsupported button item type \(barButtonItem)")
        }
        contentEdgeInsets = UIEdgeInsets(
            top: 0,
            left: Self.buttonHorizontalPadding,
            bottom: 0,
            right: Self.buttonHorizontalPadding
        )
        accessibilityLabel = barButtonItem.accessibilityLabel
        if let target = barButtonItem.target, let action = barButtonItem.action {
            addTarget(target, action: action, for: .touchUpInside)
        }

        enabledObservation = barButtonItem.observe(\.isEnabled, options: [.initial]) { [weak self] item, _ in
            self?.isEnabled = item.isEnabled
        }

        sizeToFit()

        if #available(iOS 13.4, *) {
            isPointerInteractionEnabled = true
        }
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extension UIColor {
    fileprivate var rgbaComponents: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
        var red: CGFloat = 0.0
        var green: CGFloat = 0.0
        var blue: CGFloat = 0.0
        var alpha: CGFloat = 0.0

        getRed(&red, green: &green, blue: &blue, alpha: &alpha)

        return (red, green, blue, alpha)
    }

    fileprivate var heavilyHighlighted: UIColor {
        let ratio: CGFloat = 0.85
        let (red, green, blue, alpha) = rgbaComponents

        return UIColor(
            red: red * ratio,
            green: green * ratio,
            blue: blue * ratio,
            alpha: alpha
        )
    }
}