Auto Layoutの設計ベストプラクティスと、Viewの種類ごとのテクニック集


Auto Layoutを使って既存アプリをiPhone 6(S)/6(S) Plus対応した際に得た知見をまとめてみました。
以下、上級編となります。

※iOS 9で導入されたStack ViewについてはiOS 8を切れない事情で使えませんでしたorz...

※下記のサンプルに使用したStoryboardファイルはこちらです。
https://gist.github.com/ypresto/ee3b2f592b40936c11ec

※設計が悪くて画面サイズ変わるとぶっ壊れちゃう箇所をUnit Testで把握するライブラリ書きました
https://github.com/ypresto/AutoLayoutLint
http://qiita.com/yuya_presto/items/742a9e6dd95667bd62c9

詳解編

Auto Layoutの仕組みやPriority、Intrinsic Content Sizeなどの機能については、WWDCの動画で非常に丁寧に解説されているので、こちらをぜひご覧ください。

https://developer.apple.com/videos/play/wwdc2015-218/
https://developer.apple.com/videos/play/wwdc2015-219/

設計方針

Auto Layoutは設計が命です。明快で、レスポンシブなConstraints群を構成するためのプラクティスについて紹介します。

なるべく親Viewとの距離で指定する

Auto Layoutは、各ViewのX軸とY軸それぞれの位置と大きさを、Constraintに基づいて決めることで機能します。
指定方法が1つではないのですが、多くの場合は親Viewとの距離で指定しておくことをおすすめします。

  • ○ Top/Bottom/Leading/Trailing Space to Container
    • 直感的で、必要に応じてMarginを数値で指定したり、Layout Marginsを利用したりできます。
  • × Center Horizontally/Vertically in Container + Equal Width/Height to Superview
    • MarginをつけようとするとEqual Width/Heightが満たせなくなり壊れます。

幅や高さを数値で指定しない

文字や画像の大きさを親Viewに伝搬する(Viewが内側から広がるように作る)

ユーザがアプリの文字サイズを変更できるDynamic Type機能を使ったり、多言語対応したりすると、UILabelなどの文字要素のサイズが固定ではなくなります。

一部の要素にはIntrinsic Content Sizeがあり、幅や高さが自他のConstraintsで決定されない場合は、自らの文字の幅や高さで決定します。その結果、周りの要素も押し拡げる効果があります。これを使えば、文字列要素の大きさに応じて親Viewの大きさを変化させることができます。

  • UILabel、UITextField、UIButtonなどの文字列要素
  • UISwitch、UIActivityIndicatorなど大きさが元々決まっている要素
  • UITextViewかつscrollEnabled = NO
  • UIImageViewかつimageがセットされている
  • etc.

文字以外の要素の大きさ割合で指定する

画面サイズが固定の古き良き時代は終わったので、縦向き横向き含めてどんな画面サイズにも対応できるように作る必要があります。
右寄せ、左寄せでよいボタンや画像については問題ないかと思うのですが、横幅の伸縮に応じてうまく大きさを変えないといけないケースもありそうです。

幅や高さを割合で定義するProportional Width/HeightのConstraintsを活用すると、画面サイズに応じて要素のサイズを増減させることができます。また、Aspect RatioをUIImageViewに指定しておくと、アスペクト比維持の拡大縮小をAuto Layoutにうまく反映させることができます。

Proportional Width/Heightは、一度Equal Width/HeightのConstraintを追加してから、Multiplierに設定します。下のgifではCtrl押しながらドラッグ&ドロップして追加しています。

Aspect Ratioは右下のConstraints追加ボタンのEqual Widths/Heightsの下にあります。

※Aspect Ratioを使う際の注意点として、横画面に対応する場合は幅が高さよりも広くなるので、Constraintsの入れ方によっては要素の大きさが画面の高さよりも大きくなってしまうことがあります。Heightの最大値を数値で制限したり(Lesser Than or Equal)、Size ClassesでConstraintsを切り替えるのが良さそうです。

その他、守った方が良いと思うこと

  • 極力、自身、兄弟、直接の親のViewとの間のConstraintsだけを定義し、祖先を含むそれ以外のViewとの間の定義はなるべく避ける(Layout Guideは例外)。
  • 細かい調整は必要ないが離しておきたいView間の距離はStandardを使う。
  • 個々のConstraintは、自然なMarginをつけた時にマイナスの指定にならないような順番にしておく。Ctrl+ドラッグ&ドロップを使って指定する場合は、子から親に向かってドロップする。

注意:Constraintsを配置に反映するようにし、配置をConstraintsに変換するのは絶対にしない

Interface Builderは大変よくできていて、Constraintsが足りない時に画面上の配置に応じて勝手に追加する機能が用意されているのですが、これがかなり曲者で意図しないConstraints(サイズが変わったらすぐに壊れるなど)が追加されてしまいます。これを使ってしまうと最初からやり直した方が簡単という事案になってしまいます・・・。なので下記のようなAdd Missing Constraintsは絶対に使わないようにしましょう。

※ぐちゃぐちゃな自動生成のソースコードをそのままコミットする行為に等しいと思います・・。

UIButton

背景色付きのUIButtonの周りにスペースを取りたいケースがあると思いますが、WidthやHeightを数値で指定すると、ラベルの変化やフォントサイズの増減に対応できなくなってしまいます。そこでInterface Builder上のContent Inset設定を使います。

下記の例ではWidth/Height指定なしで親Viewの中心に配置されているViewにContent Insetを設定しています。

※なお、Apple公式では、Dynamic TypeはUIコンポーネント(ボタンとか)には使わないというような趣旨のことが書かれています。

Self-Sizing Cells、UITableViewCell、UICollectionViewCell

UITableViewCell

これまでのUITableViewでは、スクロール可能エリアの高さを決めるために、あらかじめすべてのセルの正確な高さを返す必要がありましたが、iOS 8で導入されたSelf-Sizing Cells機能を使うと、UITableViewCellの高さをセルの中身に応じて伸縮できるようになります。

Self-Sizing Cellsを使うには、セルのContent Viewを子要素に応じて広がるようにConstraintで指定しておき、下記のようにestimatedHeightで大体の大きさを返すと、スクロール可能な高さをざっくり計算することで実際のheightを表示時にAuto Layoutで決定することができます。

- (void)viewDidLoad
{
    self.tableView.rowHeight = UITableViewAutomaticDimension;
    self.tableView.estimatedRowHeight = 44.0;
}

UITableViewDelegateとして実装することも可能です。

- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return 44; // 例えば幅に応じて高さを計算する場合は、 `tableView.bounds.size.width * ratio` みたいな感じにします。
}

ただし、Interface Builder上(少なくともStoryboard上)ではセルの高さは今のところ固定にしかできません。なので、場合によってはY軸のConstraintsを満たせなくなる場合があります。高さが伸縮する要素と親Viewの下端との距離を"Greater Than or Equal"にすることで解消できます。

追記: iOS 8.xで、画面遷移して戻ってきたり、UIPageViewControllerが横スクロールされるとスクロール位置が変わってしまう問題があるようです。コードでスクロールする(特にscroll to bottom)際もずれるようで、その場合の回避策はStack Overflowに書きました。さらにiOS 8.3未満でUIPageViewController+UITableViewを使う場合は他にもdidMoveWindowのタイミングでスクロール位置が変わってしまう問題があるので要注意です・・。

標準スタイルのセルと左右の余白を揃えたい場合

標準のスタイルのセルの左右余白は、実は端末によって異なる幅になっていて、iPhone 5SよりもiPhone 6 Plusの方が大きかったりします。なので、カスタムセルの左右余白を数値で指定してしまうと、標準スタイルのものとずれてしまいます。この幅はUITableViewのLayout Marginsで決まっているようなので、うまく設定すると数値指定なしで揃えることができます。

具体的には、"Preserve Superview Margins"のチェックボックスをUITableViewCellとContent Viewの両方に入れることで実現できます。
http://stackoverflow.com/a/31133547/1474113

Static Cellsの場合

Static Cellsでラベルの中身だけ書き換えているようなケースや、おそらくDynamic Type対応する場合に必要になると思います。
UITableViewControllerのサブクラスで自分でheightForRowAtIndexPathを実装する必要があります。

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    if ([self isDynamicHeightIndexPath:indexPath) {
        return UITableViewAutomaticDimension;
    }
    return [super tableView:tableView heightForRowAtIndexPath:indexPath];
}

- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    // NOTE: UITableViewAutomaticDimensionを返していないindexPathにも叩かれることがあるみたいなので注意が必要です。
    return 44; // 計算が必要であればここでします。
}

accessoryの有無が混在する場合に右寄せの要素を揃える

カスタムのセルでaccessoryViewを使わずにセルの右端に要素を配置したい場合、Content Viewに対して右端へのConstraintsを張ると、accessoryの有無によって位置が変わってしまいます。この場合、Content Viewの代わりにUITableViewCell自体に右端のConstraintsを張ると、accessoryの有無に関わらず右端から指定したmarginの位置に配置されるようになります。

カスタムのLayoutを使用する際のUICollectionViewCellのパフォーマンス

訂正:UICollectionViewFlowLayoutにはestimatedItemSizeがあり、これが使われているとSelf-Sizing Cellsが有効になり、その時だけpreferredLayoutAttributesFittingAttributesが叩かれるとドキュメントに記載がありました。FlowLayoutでestimatedItemSize未指定の場合の挙動については未確認です。なお、下記の挙動はiOS 9.0.xで確認しています。

デフォルトでSelf-Sizing Cells機能が有効になっているようですが、セルの数が多いとパフォーマンスを劣化させる原因になります。UITableViewと違い、UICollectionViewLayoutで指定されたレイアウト通りに表示することが多いはずなので、この機能は無効にしても大丈夫です。
スクロールがもたつく場合は、下記のように、-[UICollectionReusableView preferredLayoutAttributesFittingAttributes:]メソッドをoverrideして潰すと、スクロールがより滑らかになります(このメソッドはCellが表示されるたびに呼ばれ、デフォルトの実装でAuto Layoutを実際に適用してViewの大きさを測ってみる処理が入っています)。

@implementation YourCollectionViewCell

- (UICollectionViewLayoutAttributes *)preferredLayoutAttributesFittingAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes
{
    // NOTE: We don't use self-sizing cells, so skip here to keep from bad scroll performance.
    // http://rbnsn.me/posts/2015/10/04/uicollectionviewcell-autolayout-performance/
    return layoutAttributes;
}

@end

※実アプリでUICollectionViewをスクロール中にInstrumentsをとってみた結果:

Running Time    Self (ms)       Symbol Name
2949.0ms   33.0%    0.0             -[UICollectionReusableView preferredLayoutAttributesFittingAttributes:]

UIScrollView

UIScrollViewにはそれ自体の幅、高さとは別に、スクロール可能なエリアの大きさを設定するcontentSizeが存在します。
Auto Layoutを使うと、中身に応じてcontentSizeを自動で設定することができます。副作用として、Interface Builder上でUIScrollViewの中でAuto Layoutを使う場合は、UIScrollViewの上下左右の端を内側からConstraintsで縛る必要があります。

例として、画像をズームする機能を実装したい場合、単にUIScrollViewの中に(画像未指定の)UIImageViewを入れて、親Viewにぴったりくっつけただけではエラー(赤表示)になってしまいます。これはそのままだとContent Sizeの大きさが決まらないからです。そこで、UIScrollViewとUIImageViewのサイズを同じにするConstraintsを追加すると、Content Sizeを決定できるようになります。

※画像をxib上で入れるとエラー表示が消えます。これは画像のIntrinsic SizeでUIScrollViewのContent Sizeが決定できるからです。

より高度な例として、横スクロールを実装したい場合、そのままではInterface Builder上で1ページ分しか編集することができません。Interface Builder上でだけ有効なPlaceholder Constraintsを使い、強制的にスクロールされる内容の幅と高さを小さくすることで、Interface Builder上で複数ページの内容を編集することができるようにします。

点線のConstraintsはUIScrollViewの高さ幅をダミーのViewと同じ大きさにするためのもので、Priorityが999になっているため点線になっています。灰色の線のConstraintsは、ダミーのViewにより優先度の高いWidthとHeight==100のConstraintを設定したもので、実際の実行時には削除されているので灰色になっています。