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


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

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

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


# 現象

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

![12月-04-2017 12-06-18.gif](https://qiita-image-store.s3.amazonaws.com/0/29477/a196ad20-60c6-4b48-6fe3-c1d793255fe1.gif)


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

![12月-04-2017 12-27-12.gif](https://qiita-image-store.s3.amazonaws.com/0/29477/099b29bf-e4b9-954e-1e4f-16cff85adb92.gif)

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

![aaaa.png](https://qiita-image-store.s3.amazonaws.com/0/29477/39b88970-e3c3-350f-9373-b98868340e53.png)


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

![12月-04-2017 12-25-59.gif](https://qiita-image-store.s3.amazonaws.com/0/29477/f70c71e3-3e3d-4994-cbda-16aa05515fd6.gif)


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

![bbbbb.png](https://qiita-image-store.s3.amazonaws.com/0/29477/007ef718-4102-5bdc-ae84-0add19dbc21a.png)


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

![12月-04-2017 12-24-49.gif](https://qiita-image-store.s3.amazonaws.com/0/29477/8cc34f55-eb02-6b39-364c-927d0436f2df.gif)

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

# 原因と対応方法

## 現象の発生原因

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

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

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

<img width="793" alt="スクリーンショット 2017-12-04 12.33.27.png" src="https://qiita-image-store.s3.amazonaws.com/0/29477/e23429d4-6f38-d908-66e1-91680ef6bf82.png">
[Human Interface Guideline](https://developer.apple.com/ios/human-interface-guidelines/overview/iphone-x/)より

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

<img width="707" alt="スクリーンショット 2017-12-04 12.40.22.png" src="https://qiita-image-store.s3.amazonaws.com/0/29477/dac485b8-f131-763e-a7d0-a5c401dacf21.png">
[UIKitのAPI Diffから見る Safe Area - iOS LT #32](https://speakerdeck.com/kazuhiro4949/uikitfalseapi-diffkarajian-ru-safe-area-ios-lt-number-32)より

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

UIScrollViewだけでなく、Layout MarginもSafe Areaの影響を受けます。　UIViewに[insetsLayoutMarginsFromSafeArea](https://developer.apple.com/documentation/uikit/uiview/2891101-insetslayoutmarginsfromsafearea)というプロパティが追加され、この値はデフォルトで```true```となっています。この値がtrueの時は```layoutMargins```の値にはLayout Marginのデフォルト値にSafe Areaの値が足される形で設定されるようになりました。

<img width="707" alt="スクリーンショット 2017-12-04 12.45.58.png" src="https://qiita-image-store.s3.amazonaws.com/0/29477/bc445110-8e10-9ce3-c9d5-a54e037c95c0.png">
[UIKitのAPI Diffから見る Safe Area - iOS LT #32](https://speakerdeck.com/kazuhiro4949/uikitfalseapi-diffkarajian-ru-safe-area-ios-lt-number-32)より

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

<img width="705" alt="スクリーンショット 2017-12-04 12.46.08.png" src="https://qiita-image-store.s3.amazonaws.com/0/29477/9728c26b-0c40-c065-3828-154d6c7a2ea8.png">
[UIKitのAPI Diffから見る Safe Area - iOS LT #32](https://speakerdeck.com/kazuhiro4949/uikitfalseapi-diffkarajian-ru-safe-area-ios-lt-number-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の```layoutMargins```は```preservesSuperviewLayoutMargins```・```insetsLayoutMarginsFromSafeArea```・```readableContentGuide (cellLayoutMarginsFollowReadableWidth)```をon/offして３パターンの影響をそれぞれ変更できる
        - 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の考慮」がなくなります。

```swift
scrollView.contentInsetAdjustmentBehavior = .never
```

Storyboard上では、Size Inspectorで設定することができます。

<img width="259" alt="スクリーンショット 2017-12-04 12.12.16.png" src="https://qiita-image-store.s3.amazonaws.com/0/29477/45e5493d-daca-15d7-0e69-082d2350a8b2.png">

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

![12月-04-2017 12-25-59.gif](https://qiita-image-store.s3.amazonaws.com/0/29477/f70c71e3-3e3d-4994-cbda-16aa05515fd6.gif)

### UITableViewのinsetsContentViewsToSafeAreaをfalseにする

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


<img width="812" alt="スクリーンショット 2017-12-04 13.54.35.png" src="https://qiita-image-store.s3.amazonaws.com/0/29477/3b86a2fe-acf2-f4e1-3dbc-29572fb840ee.png">

この状態はUITableViewの[insetsContentViewsToSafeArea](https://developer.apple.com/documentation/uikit/uitableview/2921665-insetscontentviewstosafearea)をfalseにすることで無効にできます。

```swift
insetsContentViewsToSafeArea = false
```

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

<img width="260" alt="254dd3a9-4341-8e7e-bcf1-627004a3243f.png" src="https://qiita-image-store.s3.amazonaws.com/0/29477/a346b427-95d4-cc62-1ae9-4aa755b92b89.png">


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

<img width="814" alt="スクリーンショット 2017-12-04 13.57.33.png" src="https://qiita-image-store.s3.amazonaws.com/0/29477/4b45fa5a-16e5-2044-b0e2-6b90aa491e52.png">

### UITableView・UITableViewCellとそのcontentViewのLayout Marginから、Safe Areaの影響を取り除く
UITableViewCellの[デフォルトスタイル](https://developer.apple.com/documentation/uikit/uitableviewcellstyle)がもつラベルや、UITableViewのseparatorInsetのように、親のlayoutMarginに影響を受けてレイアウトが変わる場合、この対応が必要なります。
Layout Marginの設定からSafe Areaの影響を取り除くには、各Viewの[insetsLayoutMarginsFromSafeArea](https://developer.apple.com/documentation/uikit/uiview/2891101-insetslayoutmarginsfromsafearea)の値をfalseにしていきます。

```swift
view.insetsLayoutMarginsFromSafeArea = false // UITableView, UITableViewCell, UITableViewCellのcontentViewそれぞれに対して行う
```

この値も各ViewのSize Inspectorから変更できます。
UITableViewとUITableViewCell、さらにUITableViewCellが持つcontentViewに対して、Layout Margin設定のSafe Area Relative Marginsをoffにしましょう。

<img width="258" alt="3a75a6c4-01b7-2d89-0312-6675b18a22f0.png" src="https://qiita-image-store.s3.amazonaws.com/0/29477/fb895c28-9d53-9c55-14f5-5ffc78d83799.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で以下のように設定されていれば大丈夫です。

<img width="260" alt="673a54ae-4964-1ea6-42d5-e909f4e9bf26.png" src="https://qiita-image-store.s3.amazonaws.com/0/29477/0de69163-19bb-ab24-7e54-48cf590d5fae.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の値の変更はこのタイミングで取ることが可能です。

<img width="706" alt="スクリーンショット 2017-12-03 14.58.27.png" src="https://qiita-image-store.s3.amazonaws.com/0/29477/b7893eef-3e23-f265-b6b0-cb0c159c151a.png">

[UIKitのAPI Diffから見る Safe Area - iOS LT #32](https://speakerdeck.com/kazuhiro4949/uikitfalseapi-diffkarajian-ru-safe-area-ios-lt-number-32?slide=24)

superviewがあった場合にそのsafeAreaInsetsをtableViewのlayoutMarginsへセットします。

```swift
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それぞれ確認してみましょう。

![12月-04-2017 14-23-52.gif](https://qiita-image-store.s3.amazonaws.com/0/29477/b207aee3-dc7e-c77f-e963-634f0161cdf4.gif)

![12月-04-2017 12-24-49.gif](https://qiita-image-store.s3.amazonaws.com/0/29477/8cc34f55-eb02-6b39-364c-927d0436f2df.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を考えることはないかもしれませんが、いざ必要になった際に参考になれば幸いです。

# 参考資料
- [UIKitのAPI Diffから見る Safe Area - iOS LT #32](https://speakerdeck.com/kazuhiro4949/uikitfalseapi-diffkarajian-ru-safe-area-ios-lt-number-32)
- [safeAreaInsets](https://developer.apple.com/documentation/uikit/uiview/2891103-safeareainsets)
- [additionalSafeAreaInsets](https://developer.apple.com/documentation/uikit/uiviewcontroller/2902284-additionalsafeareainsets)
- [cellLayoutMarginsFollowReadableWidth](https://developer.apple.com/documentation/uikit/uitableview/1614849-celllayoutmarginsfollowreadablew)
- [readableContentGuide](https://developer.apple.com/documentation/uikit/uiview/1622644-readablecontentguide)
- [insetsLayoutMarginsFromSafeArea](https://developer.apple.com/documentation/uikit/uiview/2891101-insetslayoutmarginsfromsafearea)
- [preservesSuperviewLayoutMargins](https://developer.apple.com/documentation/uikit/uiview/1622653-preservessuperviewlayoutmargins)