iPhoneXでのAutoLayoutをきれいに書くための試行錯誤

iPhoneXでは画面サイズが広くなると同時にナビゲーションバー、タブバーの大きさが若干異なります。

そのため、今まで問題がなかったレイアウトでも不具合が発生する可能性があります。

## ナビゲーションバー

ナビゲーションバーの位置に変更があります。

- iPhoneSE
	- (0.0, 20.0, 320.0, 44.0)
- iPhone7, 8
	- (0.0, 20.0, 375.0, 44.0)
- iPhone7plus, 8plus
	- (0.0, 20.0, 414.0, 44.0)
- iPhoneX
	- (0.0, 44.0, 375.0, 44.0)

iPhoneXだけ24pt下に下がっています。


## タブバー

- iPhoneSE
	- (0.0, 519.0, 320.0, 49.0)
- iPhone7, 8
	- (0.0, 618.0, 375.0, 49.0)
- iPhone7plus, 8plus
	- (0.0, 687.0, 414.0, 49.0)
- iPhoneX
	- (0.0, 729.0, 375.0, 83.0)

iPhoneXだけタブバーが34pt拡大しています。


## 基本 ナビゲーションバーの下から始めたい時

iOS11からUIViewにsafeAreaInsetsという表示可能領域を取得するためのプロパティが追加されました。

```swift

var topMargin : CGFloat = 0
		
if #available(iOS 11, *) {
	topMargin = self.view.safeAreaInsets.top
}

self.view.addConstraint(NSLayoutConstraint(
	item: self.contentView, 
	attribute: .top, 
	relatedBy: .equal, 
	toItem: self.view, 
	attribute: .top, 
	multiplier: 1.0, 
	constant: topMargin
))
```

この時の注意点ですが、viewDidLoad()でsafeAreaInsetsを取得しようとした場合safeAreaInsets.topは0となります。

利用する場合はviewDidLayoutSubviews()内部で取得する必要があります。

またiOS11以降でしか使えないため、iOS10未満でコードの切替が必要です。

全くAutoではないですが、一応これでも使えます。



## 基本 タブバーの上から始めたい

ナビゲーションバー同様にsafeAreaInsets.bottomを利用することでタブバーの上を指定することが出来ます。

```swift

var bottomMargin : CGFloat = 0
		
if #available(iOS 11, *) {
	bottomMargin = self.view.safeAreaInsets.bottom
}

self.view.addConstraint(NSLayoutConstraint(
	item: self.contentView, 
	attribute: .bottom, 
	relatedBy: .equal, 
	toItem: self.view, 
	attribute: .bottom, 
	multiplier: 1.0, 
	constant: bottomMargin
))
```

こちらも問題点は同じく、viewDidLayoutSubviews()時に取得することと、回転時に再取得する必要があることがあります。


## 課題

今までの記述でとりあえずはしのげるかもしれませんが、プロジェクトが肥大化するにあたって、次の２点の問題を解決する必要があります。

1. iOS11未満でも同じコードで書きたい
2. 制約の追加がviewDidLayoutSubviews()に依存しないようにしたい


## コンテンツの大きさにフィットするViewを作成する

まずは自動的にコンテンツの大きさにピッタリはまるViewを作成します。

コンテンツの大きさは``画面全体 - ナビゲーションバー - タブバー``の領域を除いたものとします。

余白の取得はiOSのバージョン毎に切り替えて取得します。

- iOS11の場合はsafeAreaInsetsを利用する
- iOS10以下の場合はtopLayoutGuide、bottomLayoutGuideを利用する

UIViewControllerとのライフサイクルをまとめると以下の流れで行います。

1. （表示、回転時）UIViewController#viewDidLayoutSubviews
2. UIView#layoutSubviews() ここで領域情報を取得
3. 領域が変更されていた場合、制約を更新する

```swift:ContentLayoutGuideView.swift

import Cartography

class ContentLayoutGuideView : UIView {
	
	override func layoutSubviews() {
		super.layoutSubviews()
		_updateInsets()
	}
	
	private func _updateInsets(){
		
		var nextInsets = self.contentInsets
		
		if #available(iOS 11, *) {
			nextInsets = self.superview?.safeAreaInsets ?? .zero
		}else{
			
			let originTop = self.viewController?.topLayoutGuide.length ?? 0
			let originBottom = self.viewController?.bottomLayoutGuide.length ?? 0
			
			nextInsets = UIEdgeInsets(
				top: originTop,
				left: 0,
				bottom: originBottom,
				right: 0
			)
		}
		
		self.contentInsets = nextInsets
		
		//回転対策：0.2秒後に再取得する
		DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
			self._updateInsets()
		}
	}
	
	private func _fitToContentArea(){
		
		guard let _ = self.superview else { return }
		
		let insets : UIEdgeInsets = self.contentInsets
		
		self.group = constrain(self, replace: group) { guide in
			
			guide.top == guide.superview!.top + insets.top
			guide.left == guide.superview!.left + insets.left
			guide.right == guide.superview!.right - insets.right
			guide.bottom == guide.superview!.bottom - insets.bottom
			
		}
	}
	
	//iOS10以下でtopLayoutGuidを利用するために保持
	weak var viewController : UIViewController?
	var group : ConstraintGroup?

}
```

NSLayoutConstraintでも同じことが出来ますが、制約を簡単に記述するために[Cartography ](https://github.com/robb/Cartography)を利用しています。


## 黒魔術を使用する

コンテンツ領域が必要なUIViewController毎にContentLayoutGuideViewを作成するという方針でも問題はありませんが、思ったよりいい感じに出来たので黒魔術を利用してUIViewControllerにContentLayoutGuideViewが自動で設置されるようにしてみます。

```swift

extension UIViewController {
	
	var contentLayoutGuide : ContentLayoutGuideView {
		get {
			guard let value = objc_getAssociatedObject(self, &ContentLayoutGuideView.GUIDE_KEY) as? ContentLayoutGuideView else {
				
				let layoutGuide = ContentLayoutGuideView()
				
				layoutGuide.isUserInteractionEnabled = false
				layoutGuide.backgroundColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.3)
				layoutGuide.viewController = self
				
				self.view.addSubview(layoutGuide)
				self.view.sendSubview(toBack: layoutGuide)
				
				//store
				objc_setAssociatedObject(self, &ContentLayoutGuideView.GUIDE_KEY, layoutGuide, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
				
				return layoutGuide
			}
			
			//iOS11未満で呼ばれないために強制呼び出しする
			value.setNeedsLayout()
			
			return value
		}
	}
}
```

本来extensionではstored propertyを追加することが出来ませんが、objc_getAssociatedObjectという黒魔術を使ってプロパティを保持します。

UIViewControllerの内部で``self.contentLayoutGuide``は初期化を気にせずに利用することが出来るようになりました。


## Cartographyを利用したViewの初期化

Storyboardで利用した場合、子ViewはOptional値を取るのが地味に不便なので、コードベースで初期化してコードで制約を追加する方針を取っています。

Cartographyを利用することで、任意の場所（viewDidLayoutSubviews以外でもOK）で制約を追加することが出来ます。

最近はlazy varを使う形がオススメされています。ルールがシンプルなViewであれば、初期化と同時に制約を追加することが出来ます。

例えば、画面右下に固定のボタンはこんな感じで書けます。

```swift

class ViewController : UIViewController {

	lazy var plustButton : UIButton = {
		
		let btn = UIButton( ... )
		self.view.addSubview(btn)
		
		constrain(btn, self.contentLayoutGuide) { btn, content in
			btn.right == content.right - 10
			btn.bottom == content.bottom - 10
			btn.width == 40
			btn.height == 40
		}
		
		return btn
	}()
	
}
```


### まとめ

元からbottomLayoutGuide、topLayoutGuideを正しく使ってレイアウトしていた場合はiPhoneXでもレイアウト崩れは発生しませんが、ついうっかりナビゲーションバーの高さを指定して計算してしまったりマジックナンバーを書いたまま放置してしまっていたりすると、表示崩れが発生します（しました）

黒魔術の利用は極力避けた方が良いですが、この手の共通処理には致し方ないような気もします。

回転時にlayoutSubviews呼ばれるけどsafeAreaInsetsが更新されてなくてasyncAfterで遅延実行するとか、ちょっと無理やりな実装になっています。

コンテンツ領域の確保含め、他に何か良い方法がないかあれば教えてください。