Developer's Blog

iPhone X で画面下部に良い感じにオレオレツールバーをそれっぽく表示する方法

こんにちは、アプリケーション共同開発部の図子です。

iPhone X が 11/3 に発売されますね。みなさんも買いますよね?もちろん僕も買いますよ。

iPhone X ではディスプレイが大きく変わり、筐体前面のほとんどがディスプレイになります。Portrait では画面上部にカメラやマイクが納められているハウジング部分が欠け、下部は Home Indicator のための表示領域があります。Apple から公開されているドキュメントやガイドによると Safe Area を使ってこれらの要素を基本的に避けて表示するようにと案内されています。特に影響が顕著なのは画面下部に表示する Bar もしくはそれっぽい View ではないでしょうか? iOS 標準の UITabBar, UIToolbar などは UIKit 側がよしなにレイアウトを調整してくれ、ボタン類は Safe Area 内に納めつつ、Bar の背景をスクリーンの下の端っこまで引き延ばして表示してくれます。

実際のアプリ開発においては画面下部に固定で配置する View は UITabBar, UIToolbar とは限らず自前のツールバーっぽく振る舞う View を配置することも多いのではないでしょうか?今日は UIKit 標準の Bar 達と同様の事を自作のツールバー風 View で実装する方法を紹介します。

前置き

まずは UINavigationController 配下で UIToolbar を表示した場合の見た目は以下のようになります。至って普通です。iPhone X では Home Indicator あたりも toolbar の背景が描画されて、CollectionView のコンテンツがうっすらと透けています。

次に UIToolbar でなくオレオレツールバーを配置した iPhone 7 Plus で表示させた場合のスクリーンショットです。iOS 10 時代の想定で書いていますので当然 iPhone X のことは知らない想定です。

特におかしいところはありません。よく見かける感じだと思います。しかし、これをそのまま iPhone X で表示させます。

残念なことに Safe Area を無視して Home Indicator に被るようにオレオレツールバーが表示されています。ガイドラインには準拠していませんし、見た目もイマイチです。これを UIToolbar がやっているように良い感じに表示出来るようにオレオレツールバーの実装を変えていきます。

その前に期待値の整理をします。

  • UICollectionView は Safe Area 領域内をスクロール範囲とする
  • ただし、コンテンツは Safe Area 領域外でも見える(Barの後ろに透ける)
  • Bar 上のボタンの位置は UIToolbar を使った iPhone X や iPhone 7 と同等に Safe Area 内に納める
  • Bar の背景は UIToolbar と同様に Safe Area 領域外にも引き延ばされた状態にする

やり方

今回のアプローチは UIToolbar や UINavigationBar の実装に似せています。当然ながら実際のソースコードを読んだわけではありませんが、View Debugging 機能を活用して模倣しています。UIKit の実装に似せることである程度の汎用性が得られるだろうという考えです。

Bar の構成

自前でツールバー風の View を作る場合、単純に実装すれば、バーとして振る舞う View に対してボタンなどのコントロールを直接配置するような2階層だけにすることが多いと思います。この状態でも iPhone X のように画面下部までバーの見た目のままスクロールビューの Inset を調整することはできなくありませんがレイアウトや調整が複雑になりがちです。そこで現在の UINavigationBar, UIToolbar の実装を模倣する形で、以下のようにバーの見た目を担う背景用の階層を1つ追加します。

Bar・・・ UIView のサブクラスで、本来のバーとして表示したいサイズで扱う
├── BackgroundView ・・・ 背景専門。iPhone X の時は親View(Bar)の外にはみ出して表示される
└── Button群 ・・・ ボタン。Bar のsubView として追加する

このような構成にすることで、Bar 自体のサイズは旧来の 44 pt のようにし、ViewController の view の Safe Area 内の1番下にレイアウトしつつ、背景用の View をスクリーンいっぱいに広げられます。スクロールビューの Inset は自身で調整する必要はありますが、Bar の高さを素直に使うだけで問題ありません。Bar の上に配置するボタンなどのコントロールも Bar に対してセンタリングするだけで管理可能です。

Bar のレイアウト

以下は自前のバー風な View を SnapKit を使ってコードでレイアウトしています。bar 本体は ViewController の View 下部に張り付くようにレイアウトします。ただし iOS 11 では safeAreaLayoutGuide の下部に合わせるようにしておきます。背景用の bar.backgroundView は OS バージョンを問わずに ViewController の View の下部に沿わせます。

let bar = SimpleOriginalBar()
self.view.addSubview(bar)

/// バーの本来のサイズとレイアウトは Safe Area 内に配置する
bar.snp.makeConstraints { (make) in
    make.leading.equalToSuperview()
    make.trailing.equalToSuperview()
    make.height.equalTo(44)
    
    if #available(iOS 11, *) {
        make.bottom.equalTo(self.view.safeAreaLayoutGuide)
    } else {
        make.bottom.equalToSuperview()
    }
}

/// 背景用の View は画面の下端まで延ばす
bar.backgroundView.snp.remakeConstraints { (make) in
    make.top.equalToSuperview()
    make.left.equalToSuperview()
    make.right.equalToSuperview()
    make.bottom.equalTo(self.view.snp.bottom)
}

このように見た目を担当する backgroundView は画面下部まで常に伸ばしつつ bar 本体は今までと同様の位置やサイズ感を維持できます。

紹介したレイアウト方法は自前のバーの実装をある程度汎用的にするため ViewController 側がレイアウト時に Bar の詳細に気を配る必要が出てきている点はイマイチかと思っています。UINavigationController のような Container ViewController 側でレイアウトするか、この Bar を画面下部専用として作った場合には Bar 側で BackgroundView のレイアウトまで出来そうなので、利用側である ViewController は単純に画面下部に貼るということだけをすれば済みます。

その他の Bar の実装

そのほかに注意が必要な点は Bar 自体に self.clipsToBounds = false を必ず設定することです。こうしないとはみ出させた backgroundView が描画されず。Home Indicator 付近から下部のコンテンツが丸見えになります。

また、backgroundView 上のタップイベントの抑止処理を追加する必要があります。isUserInteractionEnabled を true にしても View の階層が違うため Bar の下部にある TableView や CollectionView のセルがタップできてしまいます。

抑止方法は以下のような感じで大丈夫だと思います。

/// `self` からはみ出すように backgroundView を配置した際、`self` より
/// はみ出ている部分のタップイベントが下の view に伝搬するのを防ぐために override
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
    let isInside = super.point(inside: point, with: event)
    if isInside {
        return isInside
    }
    
    /// 本来の point(inside:with) の結果が false の場合は
    /// backgroundView 上かどうかの結果を返すことで目的を達成させている
    return backgroundView.point(inside: point, with: event)
}

ScrollView の調整

UINavigationController の UIToolbar を使った場合は UIKit 側で contentInset を調整してくれます。この調整によって、画面の上下の UINavigationBar と UIToolbar を避けたスクロール位置且つスクロールインジケータになり、さらに Bar の下にはスクロールされたコンテンツがうっすら透けてみるような見た目になっています。

自前のバーでこれと同様の事をするためには自分で inset 類の調整が必要です。以下のようなコードで対応できます。

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    /// 自前のバーで適切な contentInset を設定するには自分で調整が必要なのでここで行う。
    collectionView.contentInset = 
        UIEdgeInsets(top: 0, left: 0, bottom: bar.bounds.height, right: 0)
    collectionView.scrollIndicatorInsets = 
        UIEdgeInsets(top: 0, left: 0, bottom: bar.bounds.height, right: 0)
}

調整結果

上記のような調整を行った結果が以下のスクリーンショットです。ボタンは Home Indicator にめり込まず、なおかつ黒の半透明のオレオレツールバーの背景色が画面下部いっぱいまで描画されています。

さらに後述する UIToolbar の高さ調整も反映させてみるとこんな感じです。

見た目のバランスも良くなった気がします。

おまけ情報

UINavigationController の UIToolbar の高さが 44 pt ではない

これまで UIToolbar の高さは 44pt でしたが、iPhone X では高さが 44pt ではなく 49pt になっています。ボタン群を配置するための StackView が 44pt の高さになっていて上部に 5pt のマージンが空いています。下部は superView である ContetnView の下部にひっついている状態です。こういった調整のに対応するには、先に挙げた例のように3階層ではなくボタンを配置するための階層も別途設けておいた方が良さそうです。

UIToolbar を addSubview した場合

UINavigationController の toolBar ではなく、自分で UIToolbar を addSubview した場合はある意味当然、よしなにバーが広がったりはしてくれません。

まとめ

iPhone X でそれらしく画面下部のオレオレツールバーを表示する方法を紹介しました。ViewController.view の背景色などで誤魔化す方法ではスクロールビューの場合に対応しきれません。また、オレオレツールバーを単純に高さを増やして表示させる場合にはボタンの位置調整が煩雑になりがちです。今回紹介した方法を使えばある程度レイアウトの責務を分けつつ、利用される場所によって柔軟に対応できそうです。

実装方法を考える時に Apple の実装を参考にするアプローチは有用だと感じました。みなさんも実装に迷ったら UIKit の実装を参考にしてみてはいかがでしょうか?

参考リンク

フェンリルのオフィシャル Twitter アカウントでは、フェンリルプロダクトの最新情報などをつぶやいています。よろしければフォローしてください!

フェンリル採用チームの Twitter アカウントです。応募前のお問い合わせや、ちょっとした相談ごとなどお気軽にどうぞ!

Copyright © 2019 Fenrir Inc. All rights reserved.