noki雑記

iOS、ときどきAndroid

AutoLayout を使って苦戦した箇所

AutoLayout を使う機会があったので、よく分かる Auto Layoutを読みながら開発を行いました。 普段からStoryBoard等は使わないで開発を行っているため、SnapKitというライブラリを使用しました。

苦戦した箇所はいろいろあるのですが、覚えている範囲でいくつかまとめます。

トルツメ

UIStackViewを使うとトルツメも簡単に実現できます。いくつかのViewをUIStackViewに追加していき、必要なときに非表示にしたいViewのisHiddentrueにするだけでトルツメされます。
レイアウトを組む上で、特に問題なければ(対応OSバージョンが9以上で良いなど)、UIStackViewを使うと楽ですね。

[label1, label2, label3].forEach(stackView.addArrangedSubview)

stackView.axis = .horizontal
stackView.spacing = 10
stackView.distribution = .fillEqually
stackView.snp.makeConstraints { (make) in
  // add constraints
}

...

// if necessary
label2.isHidden = true

UIStackViewを使わないトルツメの方法もいくつかありますが、自分が苦戦したのは優先度を変更する方法でした。

まず優先度についてですが、AutoLayoutの優先度は1 ~ 1000までの値を設定できます。何も設定しない場合はUILayoutPriorityRequired(1000)が設定されます。 制約が重複していた場合は、優先度が高い順に満たされていきます。 このUILayoutPriorityRequiredが設定された制約は、別の優先度に変更することができません。NSInternalInconsistencyExceptionの例外が投げられます。 優先度を変更する必要がある場合は999以下の値を使用するか、UILayoutPriorityDefaultHighUILayoutPriorityDefaultLowを使用することで解決できます。

トルツメの話に戻りますが、下記のように優先度を指定したConstraintを保持しておき、必要な箇所で優先度を変更することでトルツメが実現できます。 3つのUILabelを表示中に、1つのLabelを非表示にした時にトルツメします。具体的には、label6の左端を決める制約を2つ用意し、それぞれ異なる優先度にしておきます。 優先度の高い制約が優先されるので、必要に応じて優先度を切り替えることでレイアウトを変えています。この優先度に1000を設定していたため、どのように実装しても優先度を変更することができず、はまりました。AutoLayoutをそのまま使っている場合は例外が投げられてクラッシュしてしまうので気づきやすいのですが、SnapKitを使っていると優先度の値が変わらないだけだったので、余計にはまってしまいました。

[label4, label5, label6].forEach(view.addSubview)

label4.snp.makeConstraints { (make) in
    // add constraints
}

label5.snp.makeConstraints { (make) in
    // add constraints
}

label6.snp.makeConstraints { (make) in
    // add constraints

    label6Constraint1 = make.left.equalTo(label5.snp.right).offset(10).priority(UILayoutPriorityDefaultHigh).constraint
    label6Constraint2 = make.left.equalTo(label4.snp.right).offset(10).priority(UILayoutPriorityDefaultLow).constraint
}

// if necessary
label5.isHidden = true
label6Constraint1?.update(priority: label5.isHidden ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh)
label6Constraint2?.update(priority: label5.isHidden ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow)

複数オブジェクトの中央揃え

複数のViewをまとめたカスタムビューを画面中央に配置しつつ、外枠をつけたり背景色をつけたりするときにちょっと悩みました。 イメージとしては、UIButtonに画像とテキストを配置した状態はどのように実装するのか、といった感じです。 中央揃え自体は、centerXcenterYを使用することでなんとかなるのですが、カスタムビュー自体のサイズが0になってしまっていました。なかなか苦戦しました。

実装した際は、intrinsicContentSizeを使うことで解決しました。これは固有のサイズを返すもので、Viewのサイズを計算して返すことでそのView自体のサイズを知ることが出来ます。例えばUILabelのテキストの長さに応じて可変になるViewであれば、そのViewのintrinsicContentSizeでUILabelのintrinsicContentSizeを使った値を返してやることでUILabelが収まるサイズを知ることが出来ます。

実際にはmarginや固定のwidthを指定している箇所もあるため、それらをきちんと管理しないと面倒なことになりそうです。このあたりなんとかしたい。

private let view = UIView()
private let label = UILabel()
private var fixWidth: CGFloat = 0.0
private var fixHeight: CGFloat = 0.0

override var intrinsicContentSize: CGSize {
    return CGSize(width: fixWidth + label.intrinsicContentSize.width, height: fixHeight)
}

override init(frame: CGRect) {
    super.init(frame: frame)

    [view, label].forEach(addSubview)

    view.backgroundColor = .black
    view.snp.makeConstraints { (make) in
        make.size.equalTo(20)
        make.centerY.equalToSuperview()
        make.left.equalToSuperview().inset(10)
    }
    fixWidth += 20 + 10  // view.width + padding
    fixHeight += 20 // view.height

    label.text = "Label"
    label.snp.makeConstraints { (make) in
        make.left.equalTo(view.snp.right).offset(10)
        make.centerY.equalToSuperview()
    }
    fixWidth += 10  // margin

    fixWidth += 10  // padding
}

ただ、後から考えてみるとこんなことをする必要はありませんでした。上記コードだと、labelに右端の制約を付けてやることで、intrinsicContentSizeは必要なくなります。かなりコードがすっきりします。StoryBoard等でAutoLayoutを使用していれば気づいたかもしれないですね。
まあ、ここでのintrinsicContentSizeの知識が後述のUICollectionViewで活きたような気がするのですが、これももっと考えればうまく実装できそうです。

label.snp.makeConstraints { (make) in
    make.left.equalTo(view.snp.right).offset(10)
    make.right.equalToSuperview().inset(10)
    make.centerY.equalToSuperview()
}

CollectionView の Self-Sizing Cells

UITableView の Self-Sizing Cells は非常に便利です。自動でセルの高さを調整してくれる機能ですね。 これをUICollectionViewで使おうとしたのですが、iOS10以上でないと使えないとのことでした。

結局これも、intrinsicContentSizeでサイズを計算してやり、それをCollectionViewに返してやるようにして対処しました。このあたりももう少しスマートに書けたらなと思いますね。

幅に合わせてマージンを詰める

デザインをあてはめたレイアウトを組んでいき、後から「iPhone5とかで表示させると幅が狭くなる」ことに気づき、微妙なマージンの調整などをすることがありました。 例えば、デザイン通りでれば10ptのマージンが、iPhone5の場合は5ptくらいにしたい、など。
下記のような感じで、less than or equalを指定することで解決できました。

space.snp.makeConstraints { (make) in
    // add constraints
    make.width.equalTo(10).priority(UILayoutPriorityDefaultHigh)
    make.width.lessThanOrEqualTo(10).priority(UILayoutPriorityDefaultLow)
    ...
}

まとめ

AutoLayoutは非常に便利だと感じました。AutoLayoutを使う以前は、initviewDidLoadで初期化などを行いlayoutSubviews等でレイアウトをごにょごにょしていましたが、AutoLayoutを使うようになってからは初期化と同じタイミングで制約をつけるだけなので、コードが見やすくなった気がします。ライブラリのおかげもあると思いますが。

iPhone Xも発表されましたし、UIStackViewなど、今後もAutoLayoutを使う上で便利なコンポーネントや機能が増えていくのかなと思います。使いこなしていきたいです。

ちなみに、業務でもたまにAndroidを実装することがあるのですが、AndroidConstraintLayoutも気になっています。時間があるときに勉強してみようと思います。