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
        )
    }
}

クックパッドマートの多種多様な商品名から、扱いやすい「食材キーワード」を予測する

研究開発部の山口 (@altescy) です.今回は最近開発したクックパッドマートの商品の「食材キーワード」を予測する機械学習モデルを紹介します.

商品の食材キーワード予測とは?

クックパッドマートでは日々様々な食材が多くの販売者から出品されています.出品される商品の情報は販売者によって登録されるため,多様な表記が存在します.「じゃがいも」の商品名を例に挙げると,「ジャガイモ」「じゃが芋」といった表記の揺れや,「メークイン」「インカのめざめ」といった品種名が書かれているもの,「農家直送」や「お徳用」のようなキャッチコピーがついたもの,など様々です.一方で,商品の検索や推薦を行う際にはその商品がいったい何なのかを簡潔に表す情報が欲しくなります.

そこで登場するのが「食材キーワード」です.商品名や商品説明とは別に,その商品がどんな食材なのかを表すキーワードを設定しておくことで,商品名の表記揺れによる検索精度の低下を抑えたり,その食材を利用したレシピの提案をしたりするなどの応用が可能になります.

設定すると便利な食材キーワードですが,出品されている商品は非常に多く種類も多様であるため,これまで出品されてた商品に対して手動でひとつずつ設定していく作業は大きな負担となります.そこで今回は商品の食材キーワードの設定作業を支援するために,商品名から食材キーワードの候補を予測する機械学習モデルの開発を行いました.

データセットの準備

機械学習モデルを学習・評価するために約5,000件の商品に対して食材キーワードのアノテーションを行いました.その結果,作成したデータセットに含まれるユニークな食材キーワードの数は1,300以上あり,その分布もロングテールであることがわかりました.データセットのサイズに対してキーワードの数が多く,事例が2〜3個しかないキーワードも多いため,通常の分類モデルを用いた手法で十分な精度を出すことは難しそうです.

一見難しそうな分類問題に見えますが,実際にデータを見てみると商品名の中には食材キーワードに対応する言葉が入っている場合がほとんどです.例えば,食材キーワードとして「にんじん」が設定された商品名をみると「にんじん」「人参」「キャロット」などの単語が入っていたり,「えび」がキーワードに設定されている商品名なら「エビ」「海老」あるいは「ブラックタイガー」のような単語が含まれています.

このような性質から,食材キーワードを単なるラベルとして予測するよりも,商品名と食材キーワードの類似性や対応関係を考慮して予測を行った方が良い結果が得られそうです.そこで,今回は商品名とキーワードの類似性に基づいて 距離 *1 を学習するようなモデルを試すことにしました.

食材キーワード予測モデル

モデルの仕組み

作成したモデルは以下の図のような構造です.商品名・キーワードをそれぞれニューラルネットワークを用いてベクトルに変換して,ベクトル同士の距離を商品名・キーワード間の距離として利用します.

モデルの構造
モデルの構造

処理の流れ

  1. 商品名の前処理 (全角・半角を揃える,数値やストップワードの削除など)
  2. 商品名・キーワードを単語に分割
  3. 2で分割した単語を fastText を用いて対応する単語ベクトルに変換
  4. 3で作った商品名・キーワードの単語ベクトル系列をそれぞれ LSTM を用いて変換
  5. 4で変換した商品名の単語ベクトルを,キーワードの単語ベクトル列を使って重み付け (後述)
  6. 商品名・キーワードの単語ベクトル系列をそれぞれ平均して商品名・キーワードのベクトルを作る
  7. 6で作った商品名・キーワードのベクトル同士のコサイン距離を商品名とキーワードの距離とする

fastText はクックパッドで公開されている350万レシピを用いて事前に学習したものを利用しました.

学習手法

ペアになる商品名・キーワード同士の距離が近くなるようにモデルの学習を行います.ただし,単純にペア同士の距離が近づくように学習するとすべての商品名・キーワードを同じ1点に集めれば距離を0にできてしまうため,負例サンプリングを行ってペアにならない商品・キーワード同士は距離がなるべく離れるように学習します.

商品名の単語の重み付け(注意機構)について

商品名の単語の重み付け(注意機構)について詳しく説明します.

商品名の単語に対して重み付けをする動機は,商品名に含まれている「◯◯県産」や「農家直送」など,キーワード予測においては本質的でない単語の影響をなるべく取り除きたいからです.簡単なものであれば前処理の段階でルールに基づい て取り除くことができますが,「昔ながらの...」や「肉の日限定10%オフ!」など,商品名に含まれるパターンは多様であり,事前にルールのみで対処することはなかなか困難です.

そこで,今回のモデルではキーワードとの類似度を利用して商品名の単語に対する重みを計算し,キーワードに関連のある単語の影響が強くなるようにしました.下の図が商品名の単語の重みを計算する様子です.

単語の重み付けの仕組み
単語の重み付けの仕組み

商品名とキーワードの単語同士で類似度を計算したあと,キーワードの単語方向に平均した結果が商品名の単語の重みになります.こうすることでキーワードに含まれる単語に似た単語の重みは大きくなり,関連性の低い単語は相対的に重みが小さくなります.例えば,この図では「カラー」や「にんじん」といった単語の重みが大きくなり,「淡路島」などキーワードと関連性の低い単語の影響を小さくできそうです.

モデルの精度

アノテーションしたデータのうち,約1,500件をテストデータとしてモデルを評価した結果が以下の表になります.LSTM分類器はベースラインとして fastText + LSTM で商品名から直接食材キーワードを分類したモデル,距離学習モデルが上で述べた手法,距離学習モデル(注意機構なし)が距離学習モデルから注意機構による単語の重み付けをなくしたモデルになります.

Accuracy@1 Accuracy@5 MRR
LSTM分類器 62.5 72.5 67.1
距離学習モデル (注意機構なし) 75.2 91.3 82.3
距離学習モデル 78.5 95.5 85.7

最も精度の高い距離学習モデルの上位5件の正解率 ( Accuracy@5 ) は 95.5% でした.この結果から,最も関連があると予測されたキーワード数個を選んだ場合に高い割合で正解のキーワードを含むモデルになっていることがわかります.

距離学習モデルの結果をLSTM分類器のものと比較すると Accuracy@5 では +20% 以上の改善がみられました.これは商品名と食材キーワードの類似性を捉える距離学習の仕組みが役に立ったと言えそうです.

また,注意機構ありとなしの場合で比較してみるとそれぞれの指標で数ポイントずつですが注意機構を導入した場合の方が良い結果が得られました.

考察

今回作成したモデルの性質についてわかったことをまとめます.

まず、良いと感じた性質について述べます:

  • 表記揺れの吸収: レシピデータで学習したfastTextは,その単語が使われる文脈に含まれる他の単語との共起性に基づいて作られるため,「人参/にんじん」や「鯵/アジ/あじ」といった表記の揺れを吸収することができます.また「サンふじ/りんご」や「五郎島金時/さつまいも」のような品種名と一般名詞との関係もある程度考慮できているようです.
  • キーワードの拡張性: このモデルはキーワードを直接予測する分類モデルと異なり商品名とキーワードをそれぞれ与えて距離を測るため,再学習することなくキーワードを拡張することができます.もちろん予測したいキーワードが学習データに含まれていた方が好ましい結果が得られやすいとは思いますが,学習データに含まれていないキーワードであってもある程度妥当な距離が計算できそうです.

一方で,このモデルにはいくつかの課題があると感じています:

  • 推論速度: このモデルは商品名が与えられたときにキーワード群の全てのキーワードとペアを作って距離を計算する必要があります .そのため,キーワード数や推論対象の商品が多くなった場合には計算量が多くなり推論速度の点でボトルネックになりそうです.
  • ハブネス問題: fastTextでは多くの文脈で登場する単語ベクトル同士は互いに近くなります.そのため高頻度で現れる単語が商品名 ・キーワードに含まれていると,商品の性質に関わらず互いに近くなる傾向があります.例として「白」が含まれる単語同士は互いに近くにあるらしく「白ワイン」に対する推薦結果に「白なす」や「白玉ねぎ」が含まれたりします.このように区別したい性質を超え て多くのデータ点と近いベクトルが現れることは「ハブネス問題」と呼ばれていて,特に高次元ベクトルの近傍を利用するモデルにおいて課題になることが知られています(Radovanović+, 2010)

食材キーワード予測モデルの活用

今回作成した食材キーワード予測モデルは,現在社内向けサービスとして食材キーワードの設定を支援するために利用しています.予測結果の上位5件をキーワード設定フォームの上部にボタンとして表示し,これをクリックすると選択したキーワードが入力される,という感じです.設定者が適切な候補を考える手間が省けるため,効率的に食材キーワードの設定ができるようになったと思います.

食材キーワードの設定画面
食材キーワードの設定画面

まとめ

クックパッドマートの商品の食材キーワードを予測するタスクについて紹介しました.大量のレシピデータで学習した fastText と注意機構を活用することで,比較的小規模なデータセットであっても1,000クラス以上の分類問題をある程度の精度で解くことができるようになりました.

今回作成したモデルは現在社内向けのサービスとしてのみ利用していますが,今後は販売者向けの管理画面などにも利用できるようにモデルの計算量の削減や予測性能性能の向上を模索したいと思います.

*1:注意機構が商品名と食材キーワードで非対称なので厳密には距離ではないですね

DroidKaigi 2021にクックパッド社員が登壇します & アフターイベントを開催します!

こんにちは、モバイル基盤部の茂呂(@slightair)です。

エンジニアが主役のAndroidカンファレンス、DroidKaigi 2021 の開催がいよいよ来週にせまってきました。 今年は10/19(火)から3日間、オンラインで開催されます。

droidkaigi.jp

クックパッドは、本カンファレンスにゴールドスポンサーとして協賛しています。 そして、クックパッドに所属するこやまカニ大好きが登壇します。登壇スケジュールと内容を紹介させてください。

登壇情報

Day2

10/20(水) 14:40〜 (25分)

2020年代の WebView 実装 / こやまカニ大好き

Jetpack Compose 時代になってもおそらく無くならない、古くて最新の View の話をします。

歴史あるアプリには大抵、 WebView でしかアクセスできない画面や機能があると思います。 WebView で開くページにセッション情報を引き継ぐための認証の仕組みや、カスタム JavaScript を動かすための仕組みもあるかもしれません。 WebView の履歴を Fragment 内で goBack() するための特殊な実装や、ドキュメントには存在しない謎の openFileChooser() メソッドを見かけた開発者もいるかもしれません。

本セッションでは8年の歴史を持つクックパッドアプリの秘伝の WebView 実装を最新化した経験を元に、現在の WebView 実装のベストプラクティスと古い WebView の改善方法について説明します。

発表者のコメント

こやまカニ大好きです。好きなカニはトラフカラッパです。 本セッションでは歴史あるクックパッドアプリの経験を元に、WebViewとうまく付き合っていくための工夫についてお話します。 WebViewが好きな方も嫌いな方も興味がない方もWebViewとの付き合いは止められないと思うので、この機会にぜひWebViewの話を聞いてみて下さい。

After Party DroidKaigi 2021 を開催します!

また DroidKaigi の後夜祭として、「After Party DroidKaigi 2021」というイベントをクックパッド主催で10/28(木) 19:00 からオンラインにて開催します!

cookpad.connpass.com

本イベントは YouTube Live にて配信し、アーカイブ視聴も可能にする予定です。ご都合のつく形での参加をぜひご検討ください。

イベントではクックパッド社員がカンファレンスでは採択されなかったトークを発表したり、DroidKaigi に参加してどんなことを感じたか皆さんのコメントや質問を拾いながらお話しする予定です。 DroidKaigi の余韻に浸りたい、発表を見てクックパッドのAndroid開発についてもっと知りたいという方がいましたら、ぜひこちらのイベントにもご参加ください。お待ちしております。

※ 本イベントはDroidKaigi実行委員会より名称利用の許可を受け、DroidKaigi 2021に協賛しているクックパッド株式会社が主催するものです。

おわりに

カンファレンスには、多くの社員が参加する予定です。トークに関すること、After Party などに関してご質問やご感想などございましたら、お気軽にお声がけください!

クックパッドでは、Android のサービス開発に一緒に取り組んでくれる仲間を募集しています。トークを見て少しでも興味を持っていただいた方にはこちらをご参照いただけましたら幸いです。

info.cookpad.com

カジュアル面談も行っていますので、クックパッド社員と話をしてみたいという方はお気軽にお申し込みください。

docs.google.com

それでは、カンファレンスでお会いしましょう!