これからの時代のレイアウトを理解する上で良い題材になるかもと思い、取り上げることにしました。
- iPhoneX
- Landscape
- UIScrollView上へUITableViewを横並びにする
というかなり限定的なケースを扱います。しかしながら、iOS11以降でのレイアウト関係の変更を表面的ではなくしっかり理解した上で考えないと解決が難しい問題でした。
現象
「ページング設定にしたUIScrollViewの上へ、複数のUITableViewを配置する」というView構成を考えてみましょう。そういったやり方自体はよく見かけるものかと思います。
最近出たiPhoneXで、これまでの実装のままLandscape表示させてみたところ、次のような状態になってしまいました。
scroll viewのoriginから先頭のtable viewまでに隙間ができてしまっています。
この問題をある方法で解消する(後述)と、他のiPhone端末で見たときと同じように先頭位置からtable viewがスタートするようになります。
しかし、今度は先頭のtable view以外で、cellのマージンがNotchと被っていることに気が付きます。
期待値としては、iOS11で導入されたSafe Areaが考慮された形で、以下のように全てのtable viewへマージンをつけてもらえると嬉しいでしょう。
なぜこのような現象が発生しているのでしょう。また、どのようにすれば解消できるでしょうか?
原因を整理しつつ、ひとつずつ問題を潰していきたいと思います。
原因と対応方法
現象の発生原因
現象を理解して対策をとるためには、以下の要素が持つ仕様を理解する必要があります。
- UIScrollViewのcontentInsets
- 個々のViewが持つLayout Margin
- UITableViewCellとそのcontentView
これらはいずれもiOS11から導入されたSafe Areaを通じて影響し合っています。
Safe AreaとはScreen内でのViewの表示可能領域を指していて、例えばnavigation barやtab barに隠れずに済む領域を示しています。これがiPhoneXだと以下の図のようにHome Indicator・Notch領域も考慮されるようになっています。

このSafe Areaに対して、UIScrollViewは親Viewが持つSafe Areaから自動的にcontentInsetsを設定するようになりました。

詳しくは資料を参照して頂けると嬉しいです。このデフォルト設定によってiPhoneX + Lanscape時のみ、scroll viewとtable viewの場合に隙間が発生してしまうようになりました。
UIScrollViewだけでなく、Layout MarginもSafe Areaの影響を受けます。 UIViewにinsetsLayoutMarginsFromSafeAreaというプロパティが追加され、この値はデフォルトでtrue
となっています。この値がtrueの時はlayoutMargins
の値にはLayout Marginのデフォルト値にSafe Areaの値が足される形で設定されるようになりました。

falseにすると、layoutMargins
はiOS10までと同じ値(Layout Marginのデフォルト値のみ)を取るようになります。

UITableViewとUITableViewCellはこのlayoutMarginの影響を受けます。
UITableViewのseparatorInsets
と、UITableViewCellの中身の要素は、Layout Marginの値によって決定されてます。従って、デフォルトでは親ViewのSafe Areaにも依存していることになります。また、UITableViewCellのcontentView.frameはSafe Areaの影響を受けて変更されるようになっています。
UIScrollViewのSafe Areaはframe内に存在しているsubviewに関して、Safe Areaを考慮してレイアウト計算します。一方でframe外に配置されているものはSafe Areaの領域とは重ならないため特に考慮されません。その結果、最後に示したような先頭table viewとそれ以外でのレイアウトの違いが発生します。
それでは、この問題をどのようなレイアウト実装で解決していけばいいでしょうか。
実装方針
実装の方針を考える上では、まずSafe AreaとLayout Marginに対して、各々が持っている制約を考慮する必要があります。
- Safe Area
- 自身に影響を与える対象を変更することができず、値の上書きもできない
- UIViewの
safeAreaInsets
は親ViewのSafe Areaで自動的に決定され、getterしか用意されていない - UIViewControllerの
additionalSafeAreaInsets
で間接的にsafeAreaInsets
を拡張するということしかできない
- UIViewの
- 自身に影響を与える対象を変更することができず、値の上書きもできない
- Layout Margin
- 自身に影響を与える対象を変更することができ、かつ値の上書きが可能
- UIViewの
layoutMargins
はpreservesSuperviewLayoutMargins
・insetsLayoutMarginsFromSafeArea
・readableContentGuide (cellLayoutMarginsFollowReadableWidth)
をon/offして3パターンの影響をそれぞれ変更できる - UIViewの
layoutMargins
は外からの値の代入が可能
- UIViewの
- 自身に影響を与える対象を変更することができ、かつ値の上書きが可能
Safe Areaはカスタマイズ性が低いのですが、Layout Marginの方はこちら側で自由に変更できます。
また、UITableViewCell内部の各種マージンは、先述の通り、「Safe Areaを考慮したLayout Margin」の影響を受けます。
そこで、Layout Marginを中心に考えてレイアウトしていきましょう。具体的には、「親ViewのSafe Areaが子Viewへ与える影響を無効にする」「Safe AreaがLayout Marginへ与える影響を独自に定義する」という方針で実装していきます。
実装
親のSafe Areaが自動的に与える影響を無効にする
UIScrollViewのSafe Area設定を無効にする
UIScrollViewは、contentInsetAdjustmentBehavior
の設定から、Safe Areaを考慮してcontentInsetsを設定します。
そして、このプロパティへ以下の設定をすることで、その「Safe Areaの考慮」がなくなります。
scrollView.contentInsetAdjustmentBehavior = .never
Storyboard上では、Size Inspectorで設定することができます。

これで以下のようにUIScrollView内のcontentInsetsを無効にすることができます。
UITableViewのinsetsContentViewsToSafeAreaをfalseにする
デフォルトだと、UITableViewCellが持つcontentViewのframeは、Safe Area分だけ内側に設定されています。以下の図では、ハイライトされた部分がcontentViewになります。

この状態はUITableViewのinsetsContentViewsToSafeAreaをfalseにすることで無効にできます。
insetsContentViewsToSafeArea = false
また、Storyboard上でもTableViewのSize Inspectorから設定することができます。

結果、UITableViewCellのcontentViewには親のboundsの値と同じものが入り、全体へ広がるようになります。

UITableView・UITableViewCellとそのcontentViewのLayout Marginから、Safe Areaの影響を取り除く
UITableViewCellのデフォルトスタイルがもつラベルや、UITableViewのseparatorInsetのように、親のlayoutMarginに影響を受けてレイアウトが変わる場合、この対応が必要なります。
Layout Marginの設定からSafe Areaの影響を取り除くには、各ViewのinsetsLayoutMarginsFromSafeAreaの値をfalseにしていきます。
view.insetsLayoutMarginsFromSafeArea = false // UITableView, UITableViewCell, UITableViewCellのcontentViewそれぞれに対して行う
この値も各ViewのSize Inspectorから変更できます。
UITableViewとUITableViewCell、さらにUITableViewCellが持つcontentViewに対して、Layout Margin設定のSafe Area Relative Marginsをoffにしましょう。

(なお、ここまではlayoutMarginへのReadable Widthの影響は全体的に無効になっている想定で考えています。)
Safe AreaがLayout Marginへ与える影響を独自に定義する
UITableViewCellとそのcontentViewのpreservesSuperviewLayoutMarginsをセットする
先程、UITableViewCellとそのcontentViewのLayout Marginが、Safe Areaの影響を受けないようにしました。このままではセル内のコンテンツはSafe Area外へ広がったままです。代わりに親ViewのLayout Marginの設定に影響を受けるようにしましょう。(後述するように、親であるUITableViewのLayout Marginがそのまま全てのセルへエスカレートされるようにするため)
それぞれ、Size Inspectorで以下のように設定されていれば大丈夫です。

UITableViewのlayoutMarginへ、superviewのsafeAreaInsetsが加味された値をセットする
ここまでで、UITableViewのseparatorInsetsとUITableViewCellが持つcontentViewのframeは、親のLayout Marginのみから影響を受けるようになりました。 では、続いてスクリーンのSafe Area領域が配置している全てのTableViewへ影響するようにしていきましょう。
やることはLayout Marginに対してsuperviewのSafe Areaをセットするだけです。
iOS 11から、viewSafeAreaInsetsDidChange()というメソッドがUIViewControllerへ追加されました。Safe Areaの値の変更はこのタイミングで取ることが可能です。

UIKitのAPI Diffから見る Safe Area - iOS LT #32
superviewがあった場合にそのsafeAreaInsetsをtableViewのlayoutMarginsへセットします。
class TableViewController: UITableViewController {
//...
//...
@available(iOS 11.0, *)
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
if let superview = view.superview {
tableView.layoutMargins.left = superview.safeAreaInsets.left + 16
}
}
}
すると、このtable viewがscroll viewへ複数横並びにされた時、全てのseparatorInset・contentViewはSafe Areaの影響を受けて調整されるようになります。
ここでは固定のマージン値をSafe Areaに足す形で設定していますが、本来の挙動としては端末によって値が違うので、細かく設定した場合はlayoutMarginを一旦どこかへ残しておいた上でその値を設定する必要があるかと思います。
また、より本来のSafe Areaの仕様に近づけて場合は、safeAreaInsetsに対して自分のframeも考慮したほうがいいでしょう。
結果
iPhoneXでLandscape・Portraitそれぞれ確認してみましょう。
その他のiPhone端末、iOS9・10などの古いOSで見ても正しくレイアウトされています。
まとめ
今回、Safe Area外へViewコンポーネントを配置した場合にもSafeArea内に配置しているかのような状態を作るために、2つのことを行いました。
- 親ViewのSafe Areaが子Viewへ与える影響を無効にする
- Safe AreaがLayout Marginへ与える影響を独自に定義する
最終結果としてはコンパクトに実装できましたが、その背景として考えなければならないものは多くありました。
- Layout Marginで変化するレイアウトの話
- そのLayout MarginはSafe Areaの影響を受けることを意識する必要がある
- Safe Areaは親Viewから自動的に決定され、アプリの実装者が自由に変更できない想定で作られている
上記仕様によって問題が複雑化されていたように思います。ある程度関係性について慣れておかないと、この先想定外の問題にぶつかって悩まされそうです。
ちなみにこの辺のマージン関係の仕様は、OSのバージョンによって結構差異がある部分なので、その違いにも注意しておく必要があります。
そもそもLandscape利用を想定したUIを考えることはないかもしれませんが、いざ必要になった際に参考になれば幸いです。