「UIScrollViewの上へUITableViewを並べるとiPhoneX Landscapeでレイアウトが崩れる」という題材で理解するiOS 11時代のレイアウト

これからの時代のレイアウトを理解する上で良い題材になるかもと思い、取り上げることにしました。

  • iPhoneX
  • Landscape
  • UIScrollView上へUITableViewを横並びにする

というかなり限定的なケースを扱います。しかしながら、iOS11以降でのレイアウト関係の変更を表面的ではなくしっかり理解した上で考えないと解決が難しい問題でした。

現象

「ページング設定にしたUIScrollViewの上へ、複数のUITableViewを配置する」というView構成を考えてみましょう。そういったやり方自体はよく見かけるものかと思います。

12月-04-2017 12-06-18.gif

最近出たiPhoneXで、これまでの実装のままLandscape表示させてみたところ、次のような状態になってしまいました。

12月-04-2017 12-27-12.gif

scroll viewのoriginから先頭のtable viewまでに隙間ができてしまっています。

aaaa.png

この問題をある方法で解消する(後述)と、他のiPhone端末で見たときと同じように先頭位置からtable viewがスタートするようになります。

12月-04-2017 12-25-59.gif

しかし、今度は先頭のtable view以外で、cellのマージンがNotchと被っていることに気が付きます。

bbbbb.png

期待値としては、iOS11で導入されたSafe Areaが考慮された形で、以下のように全てのtable viewへマージンをつけてもらえると嬉しいでしょう。

12月-04-2017 12-24-49.gif

なぜこのような現象が発生しているのでしょう。また、どのようにすれば解消できるでしょうか?
原因を整理しつつ、ひとつずつ問題を潰していきたいと思います。

原因と対応方法

現象の発生原因

現象を理解して対策をとるためには、以下の要素が持つ仕様を理解する必要があります。

  • UIScrollViewのcontentInsets
  • 個々のViewが持つLayout Margin
  • UITableViewCellとそのcontentView

これらはいずれもiOS11から導入されたSafe Areaを通じて影響し合っています。
Safe AreaとはScreen内でのViewの表示可能領域を指していて、例えばnavigation barやtab barに隠れずに済む領域を示しています。これがiPhoneXだと以下の図のようにHome Indicator・Notch領域も考慮されるようになっています。

スクリーンショット 2017-12-04 12.33.27.png
Human Interface Guidelineより

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

スクリーンショット 2017-12-04 12.40.22.png
UIKitのAPI Diffから見る Safe Area - iOS LT #32より

詳しくは資料を参照して頂けると嬉しいです。このデフォルト設定によってiPhoneX + Lanscape時のみ、scroll viewとtable viewの場合に隙間が発生してしまうようになりました。

UIScrollViewだけでなく、Layout MarginもSafe Areaの影響を受けます。 UIViewにinsetsLayoutMarginsFromSafeAreaというプロパティが追加され、この値はデフォルトでtrueとなっています。この値がtrueの時はlayoutMarginsの値にはLayout Marginのデフォルト値にSafe Areaの値が足される形で設定されるようになりました。

スクリーンショット 2017-12-04 12.45.58.png
UIKitのAPI Diffから見る Safe Area - iOS LT #32より

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

スクリーンショット 2017-12-04 12.46.08.png
UIKitのAPI Diffから見る Safe Area - iOS LT #32より

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を拡張するということしかできない
  • Layout Margin
    • 影響を受ける対象を変更することができ、かつ値の上書きが可能
      • UIViewのlayoutMarginspreservesSuperviewLayoutMarginsinsetsLayoutMarginsFromSafeAreareadableContentGuide (cellLayoutMarginsFollowReadableWidth)をon/offして3パターンの影響をそれぞれ変更できる
      • UIViewのlayoutMarginsは外からの値の代入が可能

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で設定することができます。

スクリーンショット 2017-12-04 12.12.16.png

これで以下のようにUIScrollView内のcontentInsetsを無効にすることができます。

12月-04-2017 12-25-59.gif

UITableViewのinsetsContentViewsToSafeAreaをfalseにする

デフォルトだと、UITableViewCellが持つcontentViewのframeは、Safe Area分だけ内側に設定されています。以下の図では、ハイライトされた部分がcontentViewになります。

スクリーンショット 2017-12-04 13.54.35.png

この状態はUITableViewのinsetsContentViewsToSafeAreaをfalseにすることで無効にできます。

insetsContentViewsToSafeArea = false

また、Storyboard上でもTableViewのSize Inspectorから設定することができます。

254dd3a9-4341-8e7e-bcf1-627004a3243f.png

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

スクリーンショット 2017-12-04 13.57.33.png

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にしましょう。

3a75a6c4-01b7-2d89-0312-6675b18a22f0.png

(なお、ここまでは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で以下のように設定されていれば大丈夫です。

673a54ae-4964-1ea6-42d5-e909f4e9bf26.png

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の値の変更はこのタイミングで取ることが可能です。

スクリーンショット 2017-12-03 14.58.27.png

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 = superview else {
            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それぞれ確認してみましょう。

12月-04-2017 14-23-52.gif

12月-04-2017 12-24-49.gif

その他の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を考えることはないかもしれませんが、いざ必要になった際に参考になれば幸いです。

参考資料