iOSエンジニア必見!!iOSのレイアウトで押さえておきたいこと【総集編】

iOSエンジニア必見!!iOSのレイアウトで押さえておきたいこと【総集編】

こんにちは!CouplesのiOSエンジニアをしている丹です。
今回はiOSエンジニアなら、絶対に押さえておきたいViewのレイアウトについてまとめました。Viewのレイアウトはアプリを作る上で基本中の基本ですが、深い理解がなくても、動くものは作れます。しかし、パフォーマンスを意識したり、設計をしっかりする上でViewのレイアウトの理解は必須です。レイアウトの理解を深めるために、本記事が少しでも参考になれば嬉しいです!対象読者は初級者〜中級者の方を想定しています。

* 本記事は、執筆時点で最新のXcode7.2.1、Swift2.1を使用しています。

目次

  • ViewとViewControllerのレイアウトサイクル
  • Constraints
    • ViewのupdateConstraints
    • ViewのIntrinsic Content Sizeとは
    • Content HuggingとCompression Resistance
    • Intrinsic Content Sizeを動的に変更する
    • ViewControllerのupdateViewConstraints
    • カスタムビューはrequiresConstraintBasedLayoutを書こう
  • Layout
    • ViewのlayoutSubviews
    • NSLayoutConstraintの値を更新した場合
    • ViewControllerのviewWillLayoutSubviewsとviewDidLayoutSubviews
  • Viewのレイアウトサイクルのメソッドまとめ
  • AutoLayoutとアニメーション
  • コードでAutoLayoutを組む
    • コードでAutoLayoutを組むときの注意点
    • Couplesで使用しているAutoLayoutのライブラリ「Cartography」について
  • おわりに

ViewとViewControllerのレイアウトサイクル

ViewとViewControllerのレイアウトサイクルは絶対押さえておきたいところです。
アプリの裏側がどう動いているかを理解するために、レイアウトサイクルを知ることはとても重要です。

今回はSubviewが1つもない空のViewControllerを用意します。
このViewControllerを表示した時、以下の順番でViewControllerとViewControllerのviewの各メソッドが呼ばれます。

レイアウトサイクル

説明が必要なメソッドについては、別途説明します。ここでは大雑把に3つのステップがあることを理解してください。

Step1. 制約(Constraints):
このステップでAutoLayoutの制約を更新します。制約の更新はsubviewからsuperviewの順番で呼ばれます。

Step2. レイアウト(Layout):
Step1の制約をもとにレイアウトを実行します。ここでViewのcenterとboundsを決定します。レイアウトの更新はsuperviewからsubviewの順番で呼ばれます。

Step3. 描画(Draw):
Step2のレイアウト後にUIViewのdrawRect(rect: CGRect)が呼ばれます。この描画ステップではCore Graphicsを使って描画します。Core Graphicsで描画が必要なアプリはあまり多くないと思いますので、本記事では説明を省略していますが、是非学習してみてください。

以上をまとめると、レイアウトサイクルは1. 制約、2. レイアウト、3. 描画の順番で、ステップ1, 2はUIViewとUIViewControllerの両方にメソッドが用意されています。

ConstraintsとLayoutの更新順序

Constraints

ここからはそれぞれのサイクルについて詳しく見ていきます。まずは制約についてです。

ViewのupdateConstraints

updateConstraintsの書き方

UIViewの updateConstraints は制約を更新するときに呼ぶメソッドです。サブクラスでオーバーライドして使用します。以下のように書きます。superを最後に呼ぶことに注意してください。

override func updateConstraints() {
    // 制約を更新するコードをここに書きます。
    // また、レイアウトを実行するsetNeedsLayout, layoutIfNeededは呼んではいけません。
    // setNeedsDisplayも同じく呼んではいけません。

    // superは最後に呼びます。
    super.updateConstraints()
}

updateConstraintsを呼ぶ方法

アプリ内のコンテンツが変化した際に、Viewの制約を更新したいことがあると思います。このとき、ViewのupdateConstraintsをコードで直接呼び出してはいけません。これはシステムが良きタイミングで呼んでくれます。システムに制約を更新してほしい!と伝えるためにUIViewにsetNeedsUpdateConstraintsというメソッドが用意されています。

setNeedsUpdateConstraintsを呼んだ瞬間は制約の更新は行われず、システムが1度にまとめて制約の更新を行ってくれます。つまり、以下のようにsetNeedsUpdateConstraintsを何回呼んでも制約の更新は1回だけです。このメソッドは後でupdateConstraintsを呼ぶための印をつける意味で使います。

func doSomething() {
    for _ in 0 ..< 10 {
        // 何回呼んでもupdateConstraintsは1回だけ呼ばれます。
        view.setNeedsUpdateConstraints()
    }
}

また、システムがまとめて制約の更新を行う際にupdateConstraintsIfNeededを呼びます。このメソッドは開発者自身も呼ぶことができます。このメソッドが呼ばれる前に、setNeedsUpdateConstraintsを呼んだViewのみupdateConstraintsが呼ばれます。更新の影響を受けるのは、updateConstraintsIfNeededを呼んだViewとそのSubviewです。以下の図のように、view1と3でsetNeedsUpdateConstraintsを呼び、view2でupdateConstraintsIfNeededを呼んだ場合、直後にupdateConstraintsが呼ばれるのはview3のみです。view1はview2のSuperviewなので呼ばれません。この場合、システムがview1のupdateConstraintsを呼びます。

ViewのupdateConstraintsの例

ViewのIntrinsic Content Sizeとは

ViewはIntrinsic Content Size(イントリンジックと読みます)という独自のサイズを持っています。サイズなので、HorizontalとVerticalの方向に設定ができます。片方向のみを設定することもできます。UILabelの場合は、テキストがちょうど収まるサイズがIntrinsic Content Sizeです。また、UIProgressViewではVertical方向のみに設定されています。UIViewには両軸ともに設定されていません。

Intrinsic Content Sizeが定義されている方向の例

カスタムビューでは以下のようにしてメソッドをオーバーライドすることで設定できます。設定しない方向にはUIViewNoIntrinsicMetricを設定します。

override func intrinsicContentSize() -> CGSize {
    return CGSize(width: UIViewNoIntrinsicMetric, height: 10)
}

Intrinsic Content Sizeを設定する意味を次で説明します。

Content HuggingとCompression Resistance

Intrinsic Content Sizeが定義されている場合、WidthとHeightを決める制約を追加しない場合にIntrinsic Content Sizeのサイズにリサイズされます。この挙動になる理由は、Intrinsic Content Sizeが設定された場合にデフォルトで以下の制約が追加されるからです。

// UILabelのIntrinsic Content Size = CGSize(width: 100, height: 30)の場合

H:[label(<=100@250)]
H:[label(>=100@750)]
V:[label(<=30@250)]
V:[label(>=30@750)]

WidthとHeightを決める制約を追加しなかった場合、上記4つの制約によりラベルのサイズは(width: 100, height: 30)となります。上の表記は制約のVisual Formatです。Visual Formatは、

Horizontal方向にlabelの横幅が100以下の制約。プライオリティは250
H:[label(<=100@250)]

Vertical方向にlabelの縦幅が30以上の制約。プライオリティは750
V:[label(>=30@750)]

のように読みます。詳しくはAppleのドキュメントに載っています。

上記の制約はそれぞれ名前が付けられています。

H:[label(<=100@250)] <- Content Hugging
H:[label(>=100@750)] <- Compression Resistance

Content Huggingは広がりにくさ、Compression Resistanceは縮みにくさを表します。それぞれのプライオリティが異なることで、その他の制約によって広がるのか、縮むのかが決まります。

Interface Builderやコードで設定する制約のデフォルトのプライオリティは1000です。そのため、基本的には開発者自身で設定した制約が優先されると思います。Content HuggingとCompression Resistanceの使いどころは、UILabelやUIButtonなどコンテンツによって動的にサイズが変わる場合に、あらかじめWidthとHeightの制約を決めたくないときです。

Content HuggingとCompression ResistanceはInterface Builderとコードでプライオリティを変更することができます。

view.setContentCompressionResistancePriority(UILayoutPriorityRequired, forAxis: UILayoutConstraintAxis.Horizontal)

Interface BuilderでContent HuggingとCompression Resistanceを変更する方法

Intrinsic Content Sizeを動的に変更する

Intrinsic Content Sizeを参照するのはViewがupdateConstraintsを呼んだ直後です。しかし、自動的に参照するのは最初の1回のみです。そこで、カスタムビューではinvalidateIntrinsicContentSizeを呼ぶ必要があります。例えば、UILabelの実装はtextが変更されたときにinvalidateIntrinsicContentSizeを呼び、Intrinsic Content Sizeを再計算しています。

カスタムビューはrequiresConstraintBasedLayoutを書こう

全ての制約をupdateConstraints内で記述している場合、システムはupdateConstraintsを呼んでくれません。AutoLayoutで動作するカスタムビューを作るときは、UIViewの class func requiresConstraintBasedLayout() -> Bool をオーバーライドすることで解決できます。

override class func requiresConstraintBasedLayout() -> Bool {
    return true
}

ViewControllerのupdateViewConstraints

UIViewControllerのupdateViewConstraintsは以下のように書きます。

override updateViewConstraints() {
    super.updateViewConstraints()
    //ここにself.viewのSubviewの制約を更新するコードを書きます
}

ViewControllerのupdateViewConstraintsはViewControllerのself.viewupdateConstraintsの代わりです。

class CustomViewController: UIViewController {
    func doSomthing() {
        self.view.setNeedsUpdateConstraints()
        self.view.updateConstraintsIfNeeded()
        // <- このタイミングでself.updateViewConstraints()が呼ばれます。
    }
}

Layout

次にレイアウトのステップです。このレイアウトのステップ時には制約が確定しており、その制約をもとにViewのレイアウトを実行します。

ViewのlayoutSubviews

layoutSubviewsの書き方

オーバーライド時はsuper.layoutSubviews()を必ず呼びます。

override func layoutSubviews() {
    super.layoutSubviews()
    // ここにコード
}

注意点として、このメソッドはシステムが呼ぶもので、直接呼んではいけません。この考え方は制約と同じです。

layoutSubviewsを呼ぶ方法

こちらも制約でのsetNeedsUpdateConstraintsupdateConstraintsIfNeededと考え方は全く同じです。layoutIfNeededを呼んだViewとそのSubviewのうち、setNeedsLayoutを呼んだViewに対して、layoutSubviewsを呼びます。

NSLayoutConstraintの値を更新した場合

NSLayoutConstraintの値を更新した場合、レイアウトは自動で調整されるのでしょうか。簡単な例で検証してみました。図のようにview1とview2のマージンの制約をleftConstraintとします。

NSLayoutConstraintの値を更新した場合の例

このleftConstraintの値を変更した場合、制約が関係しているViewのうち親のviewのsetNeedsLayoutが呼ばれたときと似た動きをします。詳細は以下をご覧ください。

@IBOutlet weak var leftConstraint: NSLayoutConstraint!
func doSomething() {
    leftConstraint.constant = 100 // 制約を変更します。

    view2.layoutIfNeeded() // 何も起きません。普通ならview2のlayoutSubviewsが呼ばれるはずですが。
    view1.layoutIfNeeded() // view1, view2のlayoutSubviewsが呼ばれます。
}

つまり、view1setNeedsLayoutlayoutIfNeededを書かなくても、制約通りに正しくレイアウトされます。

ViewControllerのviewWillLayoutSubviewsとviewDidLayoutSubviews

ViewControllerに用意されているviewWillLayoutSubviewsviewDidLayoutSubviewsの2つのメソッドは、self.viewlayoutSubviewsの前後で呼ばれます。サンプルコードは以下のようになります。

class CustomViewController: UIViewController {
    @IBOutlet weak var label: UILabel! // self.viewのSubviewです

    func doSomthing() {
        print("1")
        self.label.setNeedsLayout()
        self.view.setNeedsLayout()
        print("2")
        self.view.layoutIfNeeded()
        print("3")
    }
}
- Console -
1
2
ViewController: viewWillLayoutSubviews
ViewController's view: layoutSubviews
ViewController: viewDidLayoutSubviews
label: layoutSubviews
3

ViewControllerのviewのSubviewのlayoutSubviewsviewDidLayoutSubviewsの後に呼ばれることに注意してください。レイアウトはSuperviewからSubviewへと行われるため、この順番になっています。

Viewのレイアウトサイクルのメソッドまとめ

以上の章で出てきたViewのレイアウトサイクルのメソッドをまとめます。
分かりやすいように、以下のように命名しました(正式名称ではありません)。

  • アップデートメソッド: このメソッドで値の更新をします。
  • マークメソッド: アップデートメソッドを呼ぶべきViewにマークをつけます。
  • トリガーメソッド: マークが付いているViewに対してアップデートメソッドをそれぞれ呼びます。
サイクル アップデート マーク トリガー
Constraints updateConstraints setNeedsUpdateConstraints updateConstraintsIfNeeded
Layout layoutSubviews setNeedsLayout layoutIfNeeded
Draw drawRect setNeedsDisplay, setNeedsDisplayInRect なし

本記事では説明していませんが、描画サイクルではsetNeedsDisplaysetNeedsDisplayInRectを再描画のマークをつけるために使用します。

AutoLayoutとアニメーション

AutoLayoutでアニメーションをする場合、frameを操作する通常のアニメーションとは違う書き方をします。
以下に比較のサンプルを書きます。

// frameを変更する方法
UIView.animateWithDuration(1.0, animations: { () -> Void in             
    view.frame.size.width = 100
})
// 制約を変更する方法
// Constraintが定義され、追加されているとします
let widthConstraint: NSLayoutConstraint = ...
view.addConstraint(widthConstraint)

// アニメーション前にConstraintを更新する
widthConstraint.constant = 100

// レイアウトをします
UIView.animateWithDuration(1.0, animations: { () -> Void in             
    view.layoutIfNeeded()
})

AutoLayoutでアニメーションをする場合は、先に制約を更新しておきます。
レイアウトサイクルの3ステップを思い出すと分りやすいと思います。レイアウト前にStep1の制約が完成されている必要があるため、アニメーションブロックの前に制約を更新しています。そして、アニメーションの実装部分はframeが変更されるので、Step2のレイアウトに当たります。すぐにレイアウトをして欲しいので、layoutIfNeededを使います。ここでも全ての基本は、制約、レイアウト、描画の3ステップです。

コードでAutoLayoutを組む

コードでAutoLayoutを組むときの注意点

コードでAutoLayoutを組む場合、Interface Builderとは違って、UIViewのvar translatesAutoresizingMaskIntoConstraints: Boolfalseにする必要があります。
このプロパティはデフォルトがtrueであり、trueのときViewのAutoresizing maskの値から制約が自動で追加されてしまいます。この制約が開発者自身で追加した制約とコンフリクトを起こし、レイアウトがうまくいきません。
Interface BuilderでAutoLayoutを組むときはシステムが自動でfalseに設定してくれているので、コードでAutoLayoutを組むときのみお気をつけください。

Couplesで使用しているAutoLayoutのライブラリ「Cartography」について

CouplesではしばしばコードでAutoLayoutを書くことがあります。そのとき、UIKitの標準のAPIでは制約を一つ追加するだけで膨大なコードを書く必要があります。これを解消するため、Cartographyというライブラリを導入しています。他にもSnapKitMasonryのSwift版)なども検討しましたが、Cartographyが一番Swiftらしい書き方だったので採用しました。

以下の標準APIが、

addConstraint(NSLayoutConstraint(
    item: button1,
    attribute: .Right,
    relatedBy: .Equal,
    toItem: button2,
    attribute: .Left,
    multiplier: 1.0,
    constant: -12.0
))

シンプルにこう書けます。

constrain(button1, button2) { button1, button2 in
    button1.right == button2.left - 12
}

さらに上記の例では、左側のbutton1translatesAutoresizingMaskIntoConstraintsをfalseにしてくれます。他にも便利なメソッドが用意されているので、詳細はREADMEをご覧ください。

おわりに

今回はiOSのレイアウトサイクルを中心に、Viewのレイアウトの仕組みをまとめました。
レイアウトに関するメソッドは多いので、すべてを把握するのは大変だと思います。
僕自身も最初はとても苦労しました。

iOS初心者の方は、今回紹介した「制約、レイアウト、描画」の3ステップの流れと、setNeeds...等のメソッドの使い方を理解しておくと良いと思います。その後は、UIKitのリファレンスを読んで、プロパティやメソッドの引き出しを増やしていくと慣れてくると思います。(ちなみに、リファレンスを読むにはDashというMacのアプリケーションが大変便利です!)

以上です。読んでいただき、ありがとうございました!

  • このエントリーをはてなブックマークに追加

エウレカでは、一緒に働いていただける方を絶賛募集中です。募集中の職種はこちらからご確認ください!皆様のエントリーをお待ちしております!

Recommend

Go言語 (Tour of Go) で数値解析 〜ニュートン法〜

物理サーバを選定する際のポイント