こんにちは、モバイル基盤部のヴァンサン(@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
を自由にカスタマイズできないなら、使わなければ良いだけです。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:)
は需要が特になかったので実装されていません。
publicfinalclassNoBarNavigationController:UINavigationController {
overridepublicinit(rootViewController:UIViewController) {
letwrappedRootViewController=Self.wrapIfNeeded(rootViewController)
super.init(nibName:nil, bundle:nil)
viewControllers = [wrappedRootViewController]
}
@available(*, unavailable)requiredinit?(coder aDecoder:NSCoder) {
fatalError("init(coder:) has not been implemented")
}
NoBarNavigationController.viewDidLoad
もっと興味深いところ、viewDidLoad
の実装を見てみましょう
privatevarinteractivePopGestureHandler:InteractivePopGestureHandler?overridepublicfuncviewDidLoad() {
super.viewDidLoad()
delegate =self
isNavigationBarHidden =true
interactivePopGestureHandler = InteractivePopGestureHandler(controller:self)
ifletinteractivePopGestureRecognizer=self.interactivePopGestureRecognizer {
interactivePopGestureRecognizer.delegate = interactivePopGestureHandler
} else {
assertionFailure("interactivePopGestureRecognizerが作成されてあると期待されています")
}
}
コードが長いわけでもないのですが、だいぶ複雑なので、細かく見てみましょう。
delegate
overridepublicfuncviewDidLoad() {
super.viewDidLoad()
delegate =self
まず自分を自分のdelegate
にしています。delegate
のnavigationController(_:willShow:animated:)
のタイミングでやりたい処理があるので、こうするしかありませんでした。delegate
のメソッドでやることはあとで説明します。
delegate
を自分で使っているけど、アプリが別の用途でdelegate
を使いたい時もあるので、delegate
が間違って上書きされないようにassert
を入れておきましたし、別のdelegate
(additionalDelegate
)を設定できるようにしました。
publicweakvaradditionalDelegate:NoBarNavigationControllerDelegate?overridepublicvardelegate:UINavigationControllerDelegate? {
didSet {
assert(delegate ===self, "delegateが必要であれば、additionalDelegateをご利用ください")
}
}
additionalDelegate
の使っているNoBarNavigationControllerDelegate
にはこのナビゲーションコントローラーが対応しているUINavigationControllerDelegate
からとったメソッドが入っているだけです。
publicprotocolNoBarNavigationControllerDelegate:AnyObject {
funcnoBarNavigationController(_ navigationController:NoBarNavigationController, willShow viewController:UIViewController, animated:Bool)
funcnoBarNavigationController(_ navigationController:NoBarNavigationController, didShow viewController:UIViewController, animated:Bool)
}
viewDidLoad()
の中で、delegate
代入の次はナビゲーションバーを隠します。
isNavigationBarHidden
privatevarinteractivePopGestureHandler:InteractivePopGestureHandler?overridepublicfuncviewDidLoad() {
isNavigationBarHidden =true
UINavigationBar
を使わないと既に説明したので、isNavigationBarHidden = true
は自然だと思います。ただし、isNavigationBarHidden
が何かの理由でfalse
に戻されたら、変な表示になりそうですね。もともと間違った変更を防ぐためにassert()
を入れてありましたが、SwiftUIのビューが入ったUIHostingController
をプッシュしてみたら、そのassert()
が引っかかっていました。SwiftUIで明示的にナビゲーションバーを隠すようにしても、SwiftUIが一瞬表示したがっているので、強引ではありますが、有効にできないようにするしかありませんでした。
overridepublicfuncsetNavigationBarHidden(_ hidden:Bool, animated:Bool) {
if hidden {
super.setNavigationBarHidden(hidden, animated:animated)
}
}
var isNavigationBarHidden: Bool
は裏でsetNavigationBarHidden(newValue, animated: false)
を読んでいるだけみたいなので、override
はsetNavigationBarHidden(_:animated:)
だけで良さそうです。
viewDidLoad()
の中で、ナビゲーションバーを隠したあとにinteractivePopGestureRecognizer
に手をつけます。
interactivePopGestureRecognizer
privatevarinteractivePopGestureHandler:InteractivePopGestureHandler?overridepublicfuncviewDidLoad() {
interactivePopGestureHandler = InteractivePopGestureHandler(controller:self)
ifletinteractivePopGestureRecognizer=self.interactivePopGestureRecognizer {
interactivePopGestureRecognizer.delegate = interactivePopGestureHandler
} else {
assertionFailure("interactivePopGestureRecognizerが作成されてあると期待されています")
}
}
ここのinteractivePopGestureRecognizer
の扱いがUINavigationController
の細かい挙動に依存していて、この実装の一番壊れやすい部分の気がします。とはいえ、試してみたどのiOSバージョンでも問題なさそうでした。
UINavigationController
は戻るボタンが隠れている場合(ナビゲーションバーが隠れている場合も含む)、自分のinteractivePopGestureRecognizer
を無効にしています。このinteractivePopGestureRecognizer
がスワイプで前の画面に戻る動作を扱うUIGestureRecognizer
です。
ナビゲーションバーが隠れて、無効になったinteractivePopGestureRecognizer
のdelegate
を自分で設定すると、改めて有効になります。
UIGestureRecognizerDelegate
の準拠はNoBarNavigationController
自身ではなく、別のクラスにしたのは、UINavigationController
がやっていることとぶつかるリスクを最低限にしたかったからです。この準拠を見てみましょう。
privatefinalclassInteractivePopGestureHandler:NSObject, UIGestureRecognizerDelegate {
weakvarnavigationController:UINavigationController!init(controller:UINavigationController) {
navigationController = controller
}
funcgestureRecognizerShouldBegin(_ gestureRecognizer:UIGestureRecognizer) ->Bool {
return navigationController.viewControllers.count >1
}
funcgestureRecognizer(_ gestureRecognizer:UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer:UIGestureRecognizer) ->Bool {
returnfalse
}
funcgestureRecognizer(_ gestureRecognizer:UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer:UIGestureRecognizer) ->Bool {
return otherGestureRecognizer isUIPanGestureRecognizer
}
funcgestureRecognizer(_ gestureRecognizer:UIGestureRecognizer, shouldReceive touch:UITouch) ->Bool {
returntrue
}
}
このinteractivePopGestureRecognizer
の扱いが壊れやすいなら、なぜ自分で作成した新しいジェスチャーレコグナイザーを使わなかったのでしょうか。残念ながら、そうしようとすると、複雑そうな遷移のアニメーションの扱い全部を再実装しなければいけません。難易度と大変さがグンと上がります。その上で、調べてみた時、子ビューコントローラー間の遷移のアニメーションに関するドキュメントが少なくて、本当に自分で完全に実装できるか疑問だった部分もありました。既存のinteractivePopGestureRecognizer
を使った方が良いという結論に至りました。
ラップ
ナビゲーションコントローラー自体に戻って、NoBarNavigationController.init
の話をした時に飛ばしたナビゲーションスタックに入るビューコントローラーのラップの仕組みの話をしましょう。
init
に渡されたビューコントローラーはSelf.wrapIfNeeded(_:)
を使ってラップしていましたが、他の方法で挿入されるビューコントローラーもラップされます。
overridepublicfuncsetViewControllers(_ viewControllers:[UIViewController], animated:Bool) {
letwrappedViewControllers= viewControllers.map { Self.wrapIfNeeded($0) }
super.setViewControllers(wrappedViewControllers, animated:animated)
}
overridepublicfuncpushViewController(_ viewController:UIViewController, animated:Bool) {
letwrappedViewController=Self.wrapIfNeeded(viewController)
super.pushViewController(wrappedViewController, animated:animated)
}
関心の処理をしているwrapIfNeeded(_:)
を見てみましょう。気をつけるべき点はwrapIfNeeded(wrapIfNeeded(viewController))
がwrapIfNeeded(viewController)
と同じ値を返すべきところです。そうでないと、viewControllers
配列に変更を加えるとき、変えていないビューが二重にラップされる可能性が出てきます。
privatestaticfuncwrapIfNeeded(_ originalViewController:UIViewController) ->UIViewController {
letviewController:UIViewControllerif originalViewController isAdditionalToolbarNotNeeded {
assert(!(originalViewController isAdditionalToolbarNeeded), "AdditionalToolbarNeededとAdditionalToolbarNotNeeded両方に準拠していて矛盾がある")
viewController = originalViewController
} elseiflettoolbarNeedingViewController= originalViewController as?AdditionalToolbarNeeded {
viewController = toolbarNeedingViewController.wrapInContainer()
} else {
viewController = FixedHeightToolbarProvidingContainerViewController(
embedded:originalViewController,
toolbarViewController:EmbeddedNavigationToolbarViewController(viewController:originalViewController)
)
}
assert(viewController isAdditionalToolbarNotNeeded, "戻り値がAdditionalToolbarNotNeededに準拠していないとwrapIfNeeded(wrapIfNeeded(viewController))で二重ラップが起きる恐れがある")
return viewController
}
まだ話していないプロトコルが2つ登場しています:AdditionalToolbarNotNeeded
とAdditionalToolbarNeeded
。ここで「ツールバー」はナビゲーションバー相当のものです。命名は「NavigationBar」ではなく「Toolbar」にしたのは本物のナビゲーションバー(UINavigationBar
)と区別をつけるためです。
AdditionalToolbarNotNeeded
privatestaticfuncwrapIfNeeded(_ originalViewController:UIViewController) ->UIViewController {
if originalViewController isAdditionalToolbarNotNeeded {
ビューコントローラーがAdditionalToolbarNotNeeded
に準拠していると、ツールバーをつけるべきではないと意味します。画面全体で表示したいビューコントローラーでも使えますが、一番のユースケースはツールバーをつけてくれるラッパービューコントローラーです。そのラッパービューコントローラーはAdditionalToolbarNotNeeded
に準拠することで二重ラップされるのを防ぎます。
定義がとてもシンプルで、メソッドがありません。
publicprotocolAdditionalToolbarNotNeeded:UIViewController {}
AdditionalToolbarNeeded
privatestaticfuncwrapIfNeeded(_ originalViewController:UIViewController) ->UIViewController {
letviewController:UIViewControllerif originalViewController isAdditionalToolbarNotNeeded {
} elseiflettoolbarNeedingViewController= originalViewController as?AdditionalToolbarNeeded {
viewController = toolbarNeedingViewController.wrapInContainer()
AdditionalToolbarNeeded
はどのビューコントローラーにラップされてほしいのか明示的に指定するためのプロトコルです。メソッドはwrapInContainer()
1つだけです。wrapInContainer()
の中で自分をラップしているラッパービューコントローラーを作成して返すだけです。メソッドが1つだけだけど、その戻り値にまだ登場していなかったプロトコルも使われています。
publicprotocolAdditionalToolbarNeeded:UIViewController {
funcwrapInContainer() ->AdditionalToolbarProvidingContainer
}
publicprotocolAdditionalToolbarProvidingContainer:AdditionalToolbarNotNeeded {
varprovidedToolbarViewController:UIViewController { get }
varembeddedViewController:UIViewController { get }
}
AdditionalToolbarProvidingContainer
がAdditionalToolbarNotNeeded
を必要にしているのはAdditionalToolbarNotNeeded
の話をした時に話した通り二重ラップを防ぐためです。
AdditionalToolbarProvidingContainer
にあるprovidedToolbarViewController
とembeddedViewController
はラッパー(別名コンテナー)に入った2つのビューコントローラーを直接取り出すためです:providedToolbarViewController
はツールバーを表示してくれるビューコントローラーであって、embeddedViewController
はラップされている画面の本来のビューコントローラーです。
AdditionalToolbarNotNeeded
にもAdditionalToolbarNeeded
にも準拠していない場合
privatestaticfuncwrapIfNeeded(_ originalViewController:UIViewController) ->UIViewController {
letviewController:UIViewControllerif originalViewController isAdditionalToolbarNotNeeded {
} elseiflettoolbarNeedingViewController= originalViewController as?AdditionalToolbarNeeded {
} else {
viewController = FixedHeightToolbarProvidingContainerViewController(
embedded:originalViewController,
toolbarViewController:EmbeddedNavigationToolbarViewController(viewController:originalViewController)
)
}
ラップされるビューコントローラーがAdditionalToolbarNeeded
にもAdditionalToolbarNotNeeded
にも準拠していないときに使われるFixedHeightToolbarProvidingContainerViewController
はもちろんAdditionalToolbarProvidingContainer
に準拠しています。
AdditionalToolbarNeeded
にもAdditionalToolbarNotNeeded
にも準拠していないのは以下のようにAdditionalToolbarNeeded
に準拠している場合と同じ挙動にです。
extensionMyScreenViewController:AdditionalToolbarNeeded {
funcwrapInContainer() ->AdditionalToolbarProvidingContainer {
return FixedHeightToolbarProvidingContainerViewController(
embedded:self,
toolbarViewController:EmbeddedNavigationToolbarViewController(viewController:self)
)
}
}
UINavigationControllerDelegate
ナビゲーションコントローラーに関してまだ残っているのはあとUINavigationControllerDelegate
の準拠だけです。
extensionNoBarNavigationController:UINavigationControllerDelegate {
publicfuncnavigationController(_ navigationController:UINavigationController, didShow viewController:UIViewController, animated:Bool) {
assert(self== navigationController)
additionalDelegate?.noBarNavigationController(self, didShow:viewController, animated:animated)
}
navigationController(_:didShow:animated:)
はadditionalDelegate
の同じメソッドを呼んでいるだけです。
publicfuncnavigationController(_ navigationController:UINavigationController, willShow viewController:UIViewController, animated:Bool) {
assert(self== navigationController)
if animated,
lettransitionCoordinator=self.transitionCoordinator,
letsource= transitionCoordinator.viewController(forKey: .from) as?FixedHeightToolbarProvidingContainerViewController,
letdestination= 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クックパッドでは透過しているツールバーは別のコンテナーが使われますが、基礎は同じです。
構成は本当にシンプルです。
publicfinalclassFixedHeightToolbarProvidingContainerViewController:UIViewController {
privateletembedded:UIViewControllerprivatelettoolbarViewController:FixedHeightToolbarViewControllerpublicinit(
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)requiredinit?(coder aDecoder:NSCoder) {
fatalError("init(coder:) has not been implemented")
}
toolbarViewController
の定義をよく見ると、FixedHeightToolbarViewController
というプロトコルが使われているのですが、このプロトコルには複雑なところが特にないと思います(命名が似ていて少し分かりにくいかもしれませんが、このツールバービューコントローラーのプロトコル名はコンテナーのクラス名からProvidingContainer
を外したものです)
publicprotocolFixedHeightToolbarViewController:UIViewController {
varcanPop:Bool { getset }
vartoolbarHeight:CGFloat { get }
vartoolbarBackgroundColor:UIColor { get }
}
コンテナーはもちろんAdditionalToolbarNeeded
の話をした時に説明したAdditionalToolbarProvidingContainer
には準拠しています。
extensionFixedHeightToolbarProvidingContainerViewController:AdditionalToolbarProvidingContainer {
publicvarprovidedToolbarViewController:UIViewController { return toolbarViewController }
publicvarembeddedViewController:UIViewController { return embedded }
}
戻るボタンを表示すべきかどうかはツールバーのビューコントローラーが自分で判断するのが難しいので、コンテナーがviewWillAppear
とviewDidAppear
のタイミングで伝えます。
privatevarcanPop:Bool {
return navigationController?.viewControllers.first !=self
}
overridepublicfuncviewWillAppear(_ animated:Bool) {
super.viewWillAppear(animated)
letcanPop=self.canPop
if toolbarViewController.canPop != canPop {
toolbarViewController.canPop = canPop
}
}
overridepublicfuncviewDidAppear(_ animated:Bool) {
super.viewDidAppear(animated)
letcanPop=self.canPop
if toolbarViewController.canPop != canPop {
toolbarViewController.canPop = canPop
}
}
ビューの配置も複雑なことが特にありません。
overridepublicfuncviewDidLoad() {
super.viewDidLoad()
view.backgroundColor = toolbarViewController.toolbarBackgroundColor
embedded.view.translatesAutoresizingMaskIntoConstraints =false
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 =truelettoolbarBackgroundView= 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
を多めです。
staticfuncanimateAlongsideTransition(
from source:FixedHeightToolbarProvidingContainerViewController,
to destination:FixedHeightToolbarProvidingContainerViewController,
inside navigationController:NoBarNavigationController,
coordinatedBy coordinator:UIViewControllerTransitionCoordinator
) {
if source.toolbarViewController.toolbarHeight != destination.toolbarViewController.toolbarHeight {
return
}
destination.loadViewIfNeeded()
guardletsourceSnapshot= source.toolbarViewController.view.snapshotView(afterScreenUpdates:false) else { return }
letdestinationSnapshot:UIView?if destination.view.superview ==nil {
assert(destination.parent == navigationController, "予期しない状態")
navigationController.view.addSubview(destination.view)
destination.view.layoutIfNeeded()
destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates:true)
destination.view.removeFromSuperview()
} else {
assertionFailure("予期しない状態")
destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates:false)
}
lettoolbarBackgroundView= 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 =1ifletdestinationSnapshot= 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.removeFromSuperview()
})
}
ツールバー
あと残るのはツールバー自体だけです。iOSクックパッドは本来多くの画面で使われるツールバーに機能が豊富です。真ん中に表示されるのは画面によってタイトルだけ、タイトルとサブタイトル、検索ボックス。検索ボックスをタップすると表示させる検索ビューコントローラーの扱いもツールバーのビューコントローラーに入っています。この記事が既に複雑で長いので、ここでシンプルなタイトルを表示するだけにしようと思います。
![f:id:vincentisambart:20211027160223p:plain:w300]()
![f:id:vincentisambart:20211027160305p:plain:w300]()
ツールバーといっても、ビューコントローラー(EmbeddedNavigationToolbarViewController
)とビュー(EmbeddedNavigationToolbar
)に分かれています。
EmbeddedNavigationToolbarViewController
EmbeddedNavigationToolbarViewController
は単にEmbeddedNavigationToolbar
を表示して、FixedHeightToolbarProvidingContainerViewController
とEmbeddedNavigationToolbar
の仲介をしているだけです。
finalclassEmbeddedNavigationToolbarViewController:UIViewController, FixedHeightToolbarViewController {
privateletviewController:UIViewControllerinit(viewController:UIViewController) {
self.viewController = viewController
super.init(nibName:nil, bundle:nil)
}
@available(*, unavailable)requiredinit?(coder:NSCoder) {
fatalError("init(coder:) has not been implemented")
}
overridefuncloadView() {
letnavigationToolbar= EmbeddedNavigationToolbar(
viewController:viewController,
canPop:canPop
)
navigationToolbar.delegate =self
view = navigationToolbar
}
privatevartoolbar:EmbeddedNavigationToolbar {
guardlettoolbar= view as?EmbeddedNavigationToolbarelse {
fatalError("ビューがEmbeddedNavigationToolbarのインスタンスのはず")
}
return toolbar
}
MARKvarcanPop:Bool=false {
didSet {
if isViewLoaded {
toolbar.canPop = canPop
}
}
}
lettoolbarHeight= EmbeddedNavigationToolbar.height
lettoolbarBackgroundColor= EmbeddedNavigationToolbar.backgroundColor
}
やっている一番ビューコントローラーらしいことは戻るボタンのタップの扱いかもしれません。
extensionEmbeddedNavigationToolbarViewController:EmbeddedNavigationToolbarDelegate {
funcnavigationToolbar(_ navigationToolbar:EmbeddedNavigationToolbar, didTapBackButton backButton:UIButton) {
navigationController?.popViewController(animated:true)
}
}
EmbeddedNavigationToolbar
あとはEmbeddedNavigationToolbarViewController
のview
であるEmbeddedNavigationToolbar
だけです。EmbeddedNavigationToolbar
は普通のビューですが、一番重要なのが左右のボタンと真ん中のtitleView
の扱いです。
でもサブビューの話の前には定数の定義を見ましょう。
finalclassEmbeddedNavigationToolbar:UIView {
privatestaticlethorizontalMargin:CGFloat=7.0privatestaticlettitleViewVerticalMargin:CGFloat=7.0privatestaticlethorizontalSpacing:CGFloat=3.0privatestaticlettitleViewHorizontalMargin:CGFloat=7.0privatestaticletitemsStackViewMinimumWidth:CGFloat=2.0staticletheight:CGFloat= {
if UIDevice.current.userInterfaceIdiom == .pad {
return50.0
} else {
return44.0
}
}()
staticletbackgroundColor:UIColor= .lightGray
高さの問題を除いて、特に目立つことはなかったと思います。左右のボタンの配置はスタックビューを使用します。
privateletleftItemsStackView:UIStackView= {
letstackView= UIStackView()
stackView.spacing = horizontalSpacing
stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
return stackView
}()
privateletrightItemsStackView:UIStackView= {
letstackView= UIStackView()
stackView.spacing = horizontalSpacing
stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
return stackView
}()
init
はインスタンス変数の初期化やAuto Layoutの制約が特別なことをやっていないのですが、気になりそうなのはobserve
の使い方だと思います。コメントで経緯を説明します。
privateletnavigationItem:UINavigationItemprivatevartitleView:UIView?varcanPop:Bool {
didSet {
if canPop != oldValue {
recreateButtons()
}
}
}
privatevarsideButtonItemsObservations:[NSKeyValueObservation]= []
privatevartitleViewObservation:NSKeyValueObservation?weakvardelegate:EmbeddedNavigationToolbarDelegate?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
titleViewObservation = navigationItem.observe(\.titleView, options: .initial) { [weakself, weak viewController] navigationItem, _ inguardletself=self, letviewController= viewController else { return }
self.setUpTitleView(navigationItem.titleView ??self.makeDefaultTitleView(for:viewController))
}
sideButtonItemsObservations = [
navigationItem.observe(\.leftBarButtonItem) { [weakself] _, _ inself?.recreateButtons() },
navigationItem.observe(\.leftBarButtonItems) { [weakself] _, _ inself?.recreateButtons() },
navigationItem.observe(\.rightBarButtonItem) { [weakself] _, _ inself?.recreateButtons() },
navigationItem.observe(\.rightBarButtonItems) { [weakself] _, _ inself?.recreateButtons() },
navigationItem.observe(\.hidesBackButton) { [weakself] _, _ inself?.recreateButtons() },
navigationItem.observe(\.leftItemsSupplementBackButton) { [weakself] _, _ inself?.recreateButtons() },
]
recreateButtons()
}
@available(*, unavailable)requiredinit?(coder aDecoder:NSCoder) {
fatalError("init(coder:) has not been implemented")
}
overridevarintrinsicContentSize:CGSize {
return CGSize(width:UIView.noIntrinsicMetric, height:Self.height)
}
KVOはSwift時代でユースケースが限られていますが、ここでは活用する必要があります。
delegate
は戻るボタンの扱いだけに使われています。
protocolEmbeddedNavigationToolbarDelegate:AnyObject {
funcnavigationToolbar(_ navigationToolbar:EmbeddedNavigationToolbar, didTapBackButton backButton:UIButton)
}
その戻るボタンを表示すべきかどうかはUINavigationItem
の標準の仕様に合わせています。
privatevarshouldDisplayBackButton:Bool {
if!canPop {
returnfalse
}
if navigationItem.hidesBackButton {
returnfalse
}
return (navigationItem.leftBarButtonItem ==nil|| navigationItem.leftItemsSupplementBackButton)
}
その戻る含む左右のボタンの作成はrecreateButtons()
が担当しています。
privatefuncrecreateButtons() {
(leftItemsStackView.arrangedSubviews + rightItemsStackView.arrangedSubviews).forEach { $0.removeFromSuperview() }
varleftBarButtonItems= navigationItem.leftBarButtonItems ?? []
if shouldDisplayBackButton {
leftBarButtonItems.insert(makeBackButtonItem(), at:0)
}
Self.makeButtons(from:leftBarButtonItems).forEach { leftItemsStackView.addArrangedSubview($0) }
Self.makeButtons(from:navigationItem.rightBarButtonItems).reversed().forEach { rightItemsStackView.addArrangedSubview($0) }
Self.preventFromExpandingHorizontally(leftItemsStackView)
Self.preventFromExpandingHorizontally(rightItemsStackView)
}
recreateButtons()
は長くないのですが、ツールバーの他のメソッドをいくつか呼んでいるのでそのメソッドを見てみましょう。まず本当のボタンをUIBarButtonItem
から作成するmakeButtons(from:)
があります。
privatestaticfuncmakeButtons(from barButtonItems:[UIBarButtonItem]?) ->[NavigationToolbarButton] {
return (barButtonItems ?? []).map { item inletbutton= NavigationToolbarButton(barButtonItem:item)
button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
return button
}
}
NavigationToolbarButton
はあとで見ましょう。setContentHuggingPriority(_:for:)
はボタンが必要以上に大きくならないためです。
スタックビューが空っぽの場合、幅の定義がないのでシンプルのUIView
同様制約によってどの幅にもなれます。特に真ん中のtitleView
の左右が左右のスタックビューに結びついている場合、titleView
が取ってほしいスペースを取ってくれないので、それを避けるために、スタックビューが空の場合、幅固定(2pt)のシンプルなビューを入れておきます。
privatestaticfuncpreventFromExpandingHorizontally(_ stackView:UIStackView) {
assert(stackView.axis == .horizontal, "垂直のスタックビューに対応していない")
if!stackView.arrangedSubviews.isEmpty {
return
}
letview= UIView()
view.widthAnchor.constraint(equalToConstant:itemsStackViewMinimumWidth).isActive =true
stackView.addArrangedSubview(view)
}
戻るボタンは見た目が普通のleftBarButtonItems
と同じなので、直接作るのではなく、UIBarButtonItem
を作って、ボタンが普通のleftBarButtonItems
と一緒に作成されるようにしました。
privatefuncmakeBackButtonItem() ->UIBarButtonItem {
letbackButtonImage= UIImage(
systemName:"chevron.backward",
withConfiguration:UIImage.SymbolConfiguration(pointSize:23)
)?.withTintColor(.orange, renderingMode: .alwaysOriginal)
letbackButtonItem= UIBarButtonItem(
image:backButtonImage,
style: .plain,
target:self,
action: #selector(didTapBackButton)
)
backButtonItem.accessibilityLabel ="戻る"return backButtonItem
}
@objcprivatefuncdidTapBackButton(_ sender:UIButton) {
delegate?.navigationToolbar(self, didTapBackButton:sender)
}
titleView
の作成と配置はシンプルでです。余談ですが、実は、iOSクックパッドはtitleView
配置にモードが2つあります(center
とfill
)。全体のコードが既に十分複雑なので、ここはtitleView
に画面の全ての幅を取らせるfill
だけにしました。
privatefuncsetUpTitleView(_ 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
}
privatefuncmakeDefaultTitleView(for viewController:UIViewController) ->UIView {
lettitleView= EmbeddedNavigationToolbarTitleOnlyTitleView()
titleView.observe(viewController:viewController)
return titleView
}
}
デフォルトのtitleView
であるEmbeddedNavigationToolbarTitleOnlyTitleView
でやっている時別なことはviewController
のnavigationItem
のtitle
を監視しているところくらいです。
finalclassEmbeddedNavigationToolbarTitleOnlyTitleView:UIView {
privatelettitleLabel= 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)
}
privatevartitleObservation:NSKeyValueObservation?funcobserve(viewController:UIViewController) {
titleObservation = viewController.navigationItem.observe(\.title, options:[.initial]) { [weakself] navigationItem, _ inself?.titleLabel.text = navigationItem.title
}
}
@available(*, unavailable)requiredinit?(coder:NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
最後は左右のボタンに使われるNavigationToolbarButton
です。UIBarButtonItem
を元に普通のボタンを作成しています。戻るボタンの話をした時も書きましたが、残念ながらUIBarButtonItem(systemItem:)
で作成されたUIBarButtonItem
は渡されたsystemItem
をあとで分かるAPIがないので、対応できません。makeBackButtonItem()
同様SF Symbolsを活用するのが一番無難かと思います。
publicfinalclassNavigationToolbarButton:UIButton {
privatestaticletbuttonHorizontalPadding:CGFloat=2.0privateletbarButtonItem:UIBarButtonItemprivatevarenabledObservation:NSKeyValueObservation?publicinit(barButtonItem:UIBarButtonItem) {
self.barButtonItem = barButtonItem
super.init(frame: .zero)
ifletimage= barButtonItem.image {
setImage(image, for: .normal)
} elseiflettitle= barButtonItem.title {
setTitle(title, for: .normal)
iflettintColor= barButtonItem.tintColor {
setTitleColor(tintColor, for: .normal)
setTitleColor(tintColor.heavilyHighlighted, for: .highlighted)
} else {
setTitleColor(.black, for: .normal)
}
setTitleColor(.gray, for: .disabled)
iflettitleTextAttributes= barButtonItem.titleTextAttributes(for: .normal),
letfont= titleTextAttributes[.font] as?UIFont {
titleLabel?.font = font
} else {
titleLabel?.font = UIFont.systemFont(ofSize:16)
}
} else {
fatalError("このボタンアイテムの種類に対応していない:\(barButtonItem)")
}
contentEdgeInsets = UIEdgeInsets(
top:0,
left:Self.buttonHorizontalPadding,
bottom:0,
right:Self.buttonHorizontalPadding
)
accessibilityLabel = barButtonItem.accessibilityLabel
iflettarget= barButtonItem.target, letaction= barButtonItem.action {
addTarget(target, action:action, for: .touchUpInside)
}
enabledObservation = barButtonItem.observe(\.isEnabled, options:[.initial]) { [weakself] item, _ inself?.isEnabled = item.isEnabled
}
sizeToFit()
if #available(iOS 13.4, *) {
isPointerInteractionEnabled =true
}
}
@available(*, unavailable)requiredinit?(coder:NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extensionUIColor {
fileprivatevarrgbaComponents: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
varred:CGFloat=0.0vargreen:CGFloat=0.0varblue:CGFloat=0.0varalpha:CGFloat=0.0
getRed(&red, green:&green, blue:&blue, alpha:&alpha)
return (red, green, blue, alpha)
}
fileprivatevarheavilyHighlighted:UIColor {
letratio:CGFloat=0.85let (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/
import UIKit
publicprotocolAdditionalToolbarNotNeeded:UIViewController {}
publicprotocolAdditionalToolbarNeeded:UIViewController {
funcwrapInContainer() ->AdditionalToolbarProvidingContainer
}
publicprotocolAdditionalToolbarProvidingContainer:AdditionalToolbarNotNeeded {
varprovidedToolbarViewController:UIViewController { get }
varembeddedViewController:UIViewController { get }
}
publicprotocolNoBarNavigationControllerDelegate:AnyObject {
funcnoBarNavigationController(_ navigationController:NoBarNavigationController, willShow viewController:UIViewController, animated:Bool)
funcnoBarNavigationController(_ navigationController:NoBarNavigationController, didShow viewController:UIViewController, animated:Bool)
}
publicfinalclassNoBarNavigationController:UINavigationController {
overridepublicinit(rootViewController:UIViewController) {
letwrappedRootViewController=Self.wrapIfNeeded(rootViewController)
super.init(nibName:nil, bundle:nil)
viewControllers = [wrappedRootViewController]
}
@available(*, unavailable)requiredinit?(coder aDecoder:NSCoder) {
fatalError("init(coder:) has not been implemented")
}
privatevarinteractivePopGestureHandler:InteractivePopGestureHandler?overridepublicfuncviewDidLoad() {
super.viewDidLoad()
delegate =self
isNavigationBarHidden =true
interactivePopGestureHandler = InteractivePopGestureHandler(controller:self)
ifletinteractivePopGestureRecognizer=self.interactivePopGestureRecognizer {
interactivePopGestureRecognizer.delegate = interactivePopGestureHandler
} else {
assertionFailure("interactivePopGestureRecognizerが作成されてあると期待されています")
}
}
publicweakvaradditionalDelegate:NoBarNavigationControllerDelegate?overridepublicvardelegate:UINavigationControllerDelegate? {
didSet {
assert(delegate ===self, "If you need to use a delegate, use additionalDelegate instead")
}
}
overridepublicfuncsetNavigationBarHidden(_ hidden:Bool, animated:Bool) {
if hidden {
super.setNavigationBarHidden(hidden, animated:animated)
}
}
overridepublicfuncsetViewControllers(_ viewControllers:[UIViewController], animated:Bool) {
letwrappedViewControllers= viewControllers.map { Self.wrapIfNeeded($0) }
super.setViewControllers(wrappedViewControllers, animated:animated)
}
overridepublicfuncpushViewController(_ viewController:UIViewController, animated:Bool) {
letwrappedViewController=Self.wrapIfNeeded(viewController)
super.pushViewController(wrappedViewController, animated:animated)
}
privatestaticfuncwrapIfNeeded(_ originalViewController:UIViewController) ->UIViewController {
letviewController:UIViewControllerif originalViewController isAdditionalToolbarNotNeeded {
assert(!(originalViewController isAdditionalToolbarNeeded), "A view controller cannot at the same time want a navigation controller and not want one")
viewController = originalViewController
} elseiflettoolbarNeedingViewController= originalViewController as?AdditionalToolbarNeeded {
viewController = toolbarNeedingViewController.wrapInContainer()
} else {
viewController = FixedHeightToolbarProvidingContainerViewController(
embedded:originalViewController,
toolbarViewController:EmbeddedNavigationToolbarViewController(viewController:originalViewController)
)
}
assert(viewController isAdditionalToolbarNotNeeded, "A return value not conforming to AdditionalToolbarNotNeeded risks being doubly wrapped when wrapIfNeeded is called once again on it")
return viewController
}
}
privatefinalclassInteractivePopGestureHandler:NSObject, UIGestureRecognizerDelegate {
weakvarnavigationController:UINavigationController!init(controller:UINavigationController) {
navigationController = controller
}
funcgestureRecognizerShouldBegin(_ gestureRecognizer:UIGestureRecognizer) ->Bool {
return navigationController.viewControllers.count >1
}
funcgestureRecognizer(_ gestureRecognizer:UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer:UIGestureRecognizer) ->Bool {
returnfalse
}
funcgestureRecognizer(_ gestureRecognizer:UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer:UIGestureRecognizer) ->Bool {
return otherGestureRecognizer isUIPanGestureRecognizer
}
funcgestureRecognizer(_ gestureRecognizer:UIGestureRecognizer, shouldReceive touch:UITouch) ->Bool {
returntrue
}
}
extensionNoBarNavigationController:UINavigationControllerDelegate {
publicfuncnavigationController(_ navigationController:UINavigationController, didShow viewController:UIViewController, animated:Bool) {
assert(self== navigationController)
additionalDelegate?.noBarNavigationController(self, didShow:viewController, animated:animated)
}
publicfuncnavigationController(_ navigationController:UINavigationController, willShow viewController:UIViewController, animated:Bool) {
assert(self== navigationController)
if animated,
lettransitionCoordinator=self.transitionCoordinator,
letsource= transitionCoordinator.viewController(forKey: .from) as?FixedHeightToolbarProvidingContainerViewController,
letdestination= transitionCoordinator.viewController(forKey: .to) as?FixedHeightToolbarProvidingContainerViewController {
FixedHeightToolbarProvidingContainerViewController.animateAlongsideTransition(
from:source,
to:destination,
inside:self,
coordinatedBy:transitionCoordinator
)
}
additionalDelegate?.noBarNavigationController(self, willShow:viewController, animated:animated)
}
}
publicfinalclassFixedHeightToolbarProvidingContainerViewController:UIViewController {
privateletembedded:UIViewControllerprivatelettoolbarViewController:FixedHeightToolbarViewControllerpublicinit(
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)requiredinit?(coder aDecoder:NSCoder) {
fatalError("init(coder:) has not been implemented")
}
privatevarcanPop:Bool {
return navigationController?.viewControllers.first !=self
}
overridepublicfuncviewWillAppear(_ animated:Bool) {
super.viewWillAppear(animated)
letcanPop=self.canPop
if toolbarViewController.canPop != canPop {
toolbarViewController.canPop = canPop
}
}
overridepublicfuncviewDidAppear(_ animated:Bool) {
super.viewDidAppear(animated)
letcanPop=self.canPop
if toolbarViewController.canPop != canPop {
toolbarViewController.canPop = canPop
}
}
overridepublicfuncviewDidLoad() {
super.viewDidLoad()
view.backgroundColor = toolbarViewController.toolbarBackgroundColor
embedded.view.translatesAutoresizingMaskIntoConstraints =false
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 =truelettoolbarBackgroundView= 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
}
staticfuncanimateAlongsideTransition(
from source:FixedHeightToolbarProvidingContainerViewController,
to destination:FixedHeightToolbarProvidingContainerViewController,
inside navigationController:NoBarNavigationController,
coordinatedBy coordinator:UIViewControllerTransitionCoordinator
) {
if source.toolbarViewController.toolbarHeight != destination.toolbarViewController.toolbarHeight {
return
}
destination.loadViewIfNeeded()
guardletsourceSnapshot= source.toolbarViewController.view.snapshotView(afterScreenUpdates:false) else { return }
letdestinationSnapshot:UIView?if destination.view.superview ==nil {
assert(destination.parent == navigationController, "Unexpected state")
navigationController.view.addSubview(destination.view)
destination.view.layoutIfNeeded()
destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates:true)
destination.view.removeFromSuperview()
} else {
assertionFailure("Unexpected state")
destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates:false)
}
lettoolbarBackgroundView= 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 =1ifletdestinationSnapshot= 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)
destinationSnapshot?.alpha =1
sourceSnapshot.alpha =0
toolbarBackgroundView.backgroundColor = destination.toolbarViewController.toolbarBackgroundColor
}, completion: { _ in
toolbarBackgroundView.removeFromSuperview()
})
}
}
extensionFixedHeightToolbarProvidingContainerViewController:AdditionalToolbarProvidingContainer {
publicvarprovidedToolbarViewController:UIViewController { return toolbarViewController }
publicvarembeddedViewController:UIViewController { return embedded }
}
publicprotocolFixedHeightToolbarViewController:UIViewController {
varcanPop:Bool { getset }
vartoolbarHeight:CGFloat { get }
vartoolbarBackgroundColor:UIColor { get }
}
finalclassEmbeddedNavigationToolbarViewController:UIViewController, FixedHeightToolbarViewController {
privateletviewController:UIViewControllerinit(viewController:UIViewController) {
self.viewController = viewController
super.init(nibName:nil, bundle:nil)
}
@available(*, unavailable)requiredinit?(coder:NSCoder) {
fatalError("init(coder:) has not been implemented")
}
overridefuncloadView() {
letnavigationToolbar= EmbeddedNavigationToolbar(
viewController:viewController,
canPop:canPop
)
navigationToolbar.delegate =self
view = navigationToolbar
}
privatevartoolbar:EmbeddedNavigationToolbar {
guardlettoolbar= view as?EmbeddedNavigationToolbarelse {
fatalError("The view should be an instance of EmbeddedNavigationToolbar")
}
return toolbar
}
MARKvarcanPop:Bool=false {
didSet {
if isViewLoaded {
toolbar.canPop = canPop
}
}
}
lettoolbarHeight= EmbeddedNavigationToolbar.height
lettoolbarBackgroundColor= EmbeddedNavigationToolbar.backgroundColor
}
extensionEmbeddedNavigationToolbarViewController:EmbeddedNavigationToolbarDelegate {
funcnavigationToolbar(_ navigationToolbar:EmbeddedNavigationToolbar, didTapBackButton backButton:UIButton) {
navigationController?.popViewController(animated:true)
}
}
protocolEmbeddedNavigationToolbarDelegate:AnyObject {
funcnavigationToolbar(_ navigationToolbar:EmbeddedNavigationToolbar, didTapBackButton backButton:UIButton)
}
finalclassEmbeddedNavigationToolbar:UIView {
privatestaticlethorizontalMargin:CGFloat=7.0privatestaticlettitleViewVerticalMargin:CGFloat=7.0privatestaticlethorizontalSpacing:CGFloat=3.0privatestaticlettitleViewHorizontalMargin:CGFloat=7.0privatestaticletitemsStackViewMinimumWidth:CGFloat=2.0staticletheight:CGFloat= {
if UIDevice.current.userInterfaceIdiom == .pad {
return50.0
} else {
return44.0
}
}()
staticletbackgroundColor:UIColor= .lightGray
privateletleftItemsStackView:UIStackView= {
letstackView= UIStackView()
stackView.spacing = horizontalSpacing
stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
return stackView
}()
privateletrightItemsStackView:UIStackView= {
letstackView= UIStackView()
stackView.spacing = horizontalSpacing
stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
return stackView
}()
privateletnavigationItem:UINavigationItemprivatevartitleView:UIView?varcanPop:Bool {
didSet {
if canPop != oldValue {
recreateButtons()
}
}
}
privatevarsideButtonItemsObservations:[NSKeyValueObservation]= []
privatevartitleViewObservation:NSKeyValueObservation?weakvardelegate:EmbeddedNavigationToolbarDelegate?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
titleViewObservation = navigationItem.observe(\.titleView, options: .initial) { [weakself, weak viewController] navigationItem, _ inguardletself=self, letviewController= viewController else { return }
self.setUpTitleView(navigationItem.titleView ??self.makeDefaultTitleView(for:viewController))
}
sideButtonItemsObservations = [
navigationItem.observe(\.leftBarButtonItem) { [weakself] _, _ inself?.recreateButtons() },
navigationItem.observe(\.leftBarButtonItems) { [weakself] _, _ inself?.recreateButtons() },
navigationItem.observe(\.rightBarButtonItem) { [weakself] _, _ inself?.recreateButtons() },
navigationItem.observe(\.rightBarButtonItems) { [weakself] _, _ inself?.recreateButtons() },
navigationItem.observe(\.hidesBackButton) { [weakself] _, _ inself?.recreateButtons() },
navigationItem.observe(\.leftItemsSupplementBackButton) { [weakself] _, _ inself?.recreateButtons() },
]
recreateButtons()
}
@available(*, unavailable)requiredinit?(coder aDecoder:NSCoder) {
fatalError("init(coder:) has not been implemented")
}
overridevarintrinsicContentSize:CGSize {
return CGSize(width:UIView.noIntrinsicMetric, height:Self.height)
}
privatevarshouldDisplayBackButton:Bool {
if!canPop {
returnfalse
}
if navigationItem.hidesBackButton {
returnfalse
}
return (navigationItem.leftBarButtonItem ==nil|| navigationItem.leftItemsSupplementBackButton)
}
privatefuncrecreateButtons() {
(leftItemsStackView.arrangedSubviews + rightItemsStackView.arrangedSubviews).forEach { $0.removeFromSuperview() }
varleftBarButtonItems= navigationItem.leftBarButtonItems ?? []
if shouldDisplayBackButton {
leftBarButtonItems.insert(makeBackButtonItem(), at:0)
}
Self.makeButtons(from:leftBarButtonItems).forEach { leftItemsStackView.addArrangedSubview($0) }
Self.makeButtons(from:navigationItem.rightBarButtonItems).reversed().forEach { rightItemsStackView.addArrangedSubview($0) }
Self.preventFromExpandingHorizontally(leftItemsStackView)
Self.preventFromExpandingHorizontally(rightItemsStackView)
}
privatestaticfuncmakeButtons(from barButtonItems:[UIBarButtonItem]?) ->[NavigationToolbarButton] {
return (barButtonItems ?? []).map { item inletbutton= NavigationToolbarButton(barButtonItem:item)
button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
return button
}
}
privatestaticfuncpreventFromExpandingHorizontally(_ stackView:UIStackView) {
assert(stackView.axis == .horizontal, "Vertical stack view are not supported")
if!stackView.arrangedSubviews.isEmpty {
return
}
letview= UIView()
view.widthAnchor.constraint(equalToConstant:itemsStackViewMinimumWidth).isActive =true
stackView.addArrangedSubview(view)
}
privatefuncmakeBackButtonItem() ->UIBarButtonItem {
letbackButtonImage= UIImage(
systemName:"chevron.backward",
withConfiguration:UIImage.SymbolConfiguration(pointSize:23)
)?.withTintColor(.orange, renderingMode: .alwaysOriginal)
letbackButtonItem= UIBarButtonItem(
image:backButtonImage,
style: .plain,
target:self,
action: #selector(didTapBackButton)
)
backButtonItem.accessibilityLabel ="戻る"return backButtonItem
}
@objcprivatefuncdidTapBackButton(_ sender:UIButton) {
delegate?.navigationToolbar(self, didTapBackButton:sender)
}
privatefuncsetUpTitleView(_ 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
}
privatefuncmakeDefaultTitleView(for viewController:UIViewController) ->UIView {
lettitleView= EmbeddedNavigationToolbarTitleOnlyTitleView()
titleView.observe(viewController:viewController)
return titleView
}
}
finalclassEmbeddedNavigationToolbarTitleOnlyTitleView:UIView {
privatelettitleLabel= 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)
}
privatevartitleObservation:NSKeyValueObservation?funcobserve(viewController:UIViewController) {
titleObservation = viewController.navigationItem.observe(\.title, options:[.initial]) { [weakself] navigationItem, _ inself?.titleLabel.text = navigationItem.title
}
}
@available(*, unavailable)requiredinit?(coder:NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
publicfinalclassNavigationToolbarButton:UIButton {
privatestaticletbuttonHorizontalPadding:CGFloat=2.0privateletbarButtonItem:UIBarButtonItemprivatevarenabledObservation:NSKeyValueObservation?publicinit(barButtonItem:UIBarButtonItem) {
self.barButtonItem = barButtonItem
super.init(frame: .zero)
ifletimage= barButtonItem.image {
setImage(image, for: .normal)
} elseiflettitle= barButtonItem.title {
setTitle(title, for: .normal)
iflettintColor= barButtonItem.tintColor {
setTitleColor(tintColor, for: .normal)
setTitleColor(tintColor.heavilyHighlighted, for: .highlighted)
} else {
setTitleColor(.black, for: .normal)
}
setTitleColor(.gray, for: .disabled)
iflettitleTextAttributes= barButtonItem.titleTextAttributes(for: .normal),
letfont= titleTextAttributes[.font] as?UIFont {
titleLabel?.font = font
} else {
titleLabel?.font = UIFont.systemFont(ofSize:16)
}
} else {
fatalError("Unsupported button item type \(barButtonItem)")
}
contentEdgeInsets = UIEdgeInsets(
top:0,
left:Self.buttonHorizontalPadding,
bottom:0,
right:Self.buttonHorizontalPadding
)
accessibilityLabel = barButtonItem.accessibilityLabel
iflettarget= barButtonItem.target, letaction= barButtonItem.action {
addTarget(target, action:action, for: .touchUpInside)
}
enabledObservation = barButtonItem.observe(\.isEnabled, options:[.initial]) { [weakself] item, _ inself?.isEnabled = item.isEnabled
}
sizeToFit()
if #available(iOS 13.4, *) {
isPointerInteractionEnabled =true
}
}
@available(*, unavailable)requiredinit?(coder:NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extensionUIColor {
fileprivatevarrgbaComponents: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
varred:CGFloat=0.0vargreen:CGFloat=0.0varblue:CGFloat=0.0varalpha:CGFloat=0.0
getRed(&red, green:&green, blue:&blue, alpha:&alpha)
return (red, green, blue, alpha)
}
fileprivatevarheavilyHighlighted:UIColor {
letratio:CGFloat=0.85let (red, green, blue, alpha) = rgbaComponents
return UIColor(
red:red* ratio,
green:green* ratio,
blue:blue* ratio,
alpha:alpha
)
}
}