こんにちは、モバイル基盤部のヴァンサン(@vincentisambart)です。
iOS 15とXcode 13がリリースされました。最新のiOS SDKでビルドしてみたら、カスタマイズされたナビゲーションバーに修正が必要だったアプリが少なくなかったようです。しかし、iOS版のクックパッドアプリでは大きくカスタマイズされているナビゲーションバーを使ってはいるものの、iOS 15に合わせてナビゲーションバーに手を入れる必要は特になかったです。
iOS版のクックパッドアプリは最近様々な形のナビゲーションバーを使っています。例えばおすすめタブはスクロールするとナビゲーションバーの高さが変わります。
また、さがすタブは画面によってナビゲーションバーの中身や高さが違いますし、レシピ詳細ではスクロールするとレシピ名がナビゲーションバーに入ります。
なぜ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
を自由にカスタマイズできないなら、使わなければ良いだけです。UINavigationController
のsetNavigationBarHidden(_: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
にしています。delegate
のnavigationController(_: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)
を読んでいるだけみたいなので、override
はsetNavigationBarHidden(_: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
です。
ナビゲーションバーが隠れて、無効になったinteractivePopGestureRecognizer
のdelegate
を自分で設定すると、改めて有効になります。
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つ登場しています:AdditionalToolbarNotNeeded
とAdditionalToolbarNeeded
。ここで「ツールバー」はナビゲーションバー相当のものです。命名は「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 } }
AdditionalToolbarProvidingContainer
がAdditionalToolbarNotNeeded
を必要にしているのはAdditionalToolbarNotNeeded
の話をした時に話した通り二重ラップを防ぐためです。
AdditionalToolbarProvidingContainer
にあるprovidedToolbarViewController
とembeddedViewController
はラッパー(別名コンテナー)に入った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 } }
戻るボタンを表示すべきかどうかはツールバーのビューコントローラーが自分で判断するのが難しいので、コンテナーがviewWillAppear
とviewDidAppear
のタイミングで伝えます。
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クックパッドは本来多くの画面で使われるツールバーに機能が豊富です。真ん中に表示されるのは画面によってタイトルだけ、タイトルとサブタイトル、検索ボックス。検索ボックスをタップすると表示させる検索ビューコントローラーの扱いもツールバーのビューコントローラーに入っています。この記事が既に複雑で長いので、ここでシンプルなタイトルを表示するだけにしようと思います。
ツールバーといっても、ビューコントローラー(EmbeddedNavigationToolbarViewController
)とビュー(EmbeddedNavigationToolbar
)に分かれています。
EmbeddedNavigationToolbarViewController
EmbeddedNavigationToolbarViewController
は単にEmbeddedNavigationToolbar
を表示して、FixedHeightToolbarProvidingContainerViewController
とEmbeddedNavigationToolbar
の仲介をしているだけです。
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
あとはEmbeddedNavigationToolbarViewController
のview
である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つあります(center
とfill
)。全体のコードが既に十分複雑なので、ここは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
でやっている時別なことはviewController
のnavigationItem
のtitle
を監視しているところくらいです。
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 ) } }