iOS
UIKit
UIStackView

UIStackViewの5つのdistributionを理解する

iOS 9から導入されたUIStackViewは、複雑になりがちなレイアウトを簡単に組むことができます。
導入から3年近くたった今、すでに使ったことのあるエンジニアさんも多くいらっしゃるのでないでしょうか。

しかし、使ってみると案外うまく使えなかったり、コンフリクトに遭遇してしまうことがありました。
そこで、UIStackViewの中でも最も重要なプロパティのdistributionについて調べた結果をまとめました。

この記事の内容

この記事では、5種類のあるUIStackViewDistributionの全てでレイアウト例を示し、そのレイアウトがどのような制約によって成り立っているか解説します

注意して書いたつもりですが、もし内容に間違いや不備があればご指摘よろしくおねがいします :bow:

.fill

まずは、最も基本の.fillでのレイアウト例を確認してみましょう。
各プロパティは以下のように設定しています。
axis: .horizontal
distribution: .fill
alignment: .center
spacing: 0
fill1-2.png
図1-1

図1-1のレイアウトはどう構成されているのでしょうか。横方向の制約に番号をつけてみます。

fill2-3.png
図1-2

図1-2の中の制約1 ~ 4はそれぞれUIStackViewの働きにより与えられた制約です。どんな制約かを列挙します。

1: stackView.left = imageView.left
2: imageView.right = label.left
3. label.right = rect.left
4. rect.left = stackView.left

となっています。1~4をひとつづつみていくと、stackViewの左端から右端までarrangedSubviewsが連結するような制約なっています。その結果、axisの方向(この例では横方向)にarrangedSubviewsがいっぱいに満たされています。
このいっぱいに満たされる効果により.fillという名前がつけられています

横幅の制約について

先ほどの制約1 ~ 4は、arrangedSubviewsの両端をそろえる制約でした。では、横幅はどうやって決まっているのでしょうか。
今度は横幅の制約に番号を振ってみましょう。下図を見てください。

fill3-5.png
図1-3

制約の内容は以下です。

5: stackView.width = your_constraint
6: imageView.width = imageView.intrinsicContentSize.width
7: label.width = label.intrinsicContentSize.width

制約5stackViewの外部から与えられた制約です。プログラマーが明示的に与えた制約だったり、stackViewが別のUIStackViewの中にネストさせたりといった方法で与えられます。制約5がないとレイアウトを決定するのに必要な制約が足りず、レイアウトが崩れてしまう可能性があります。

制約6制約7intrinsicContentSizeによるものです。UIImageViewUILabelはその内容によってintrinsicContentSizeが与えられます。UIImageViewだと写真の大きさ、UILabelだとテキストやフォントサイズによって決まります。
rectはただのUIViewなのでintrinsicContentSizeはありません。ただ、制約1 ~ 7によって横幅が決まります(詳しくは図1-3を見てください)。

rectに横幅の制約があった場合

最後に、エンジニアがrectに対して明示的に横幅の制約を与えた場合について考えてみましょう。
この場合制約5は不要になります。
stackViewの横幅は
stackView.width = imageView.width + label.width + rect.width
と決定することができるからです。

.fillEqually

.fillでは、UIStackViewarrangedSubviewに対してaxis方向の大きさ(つまり横幅や高さ)の制約は与えませんでした。
しかし、.fillEquallyにするとUIStackViewarrangedSubviewに対して横幅や高さの制約を与えてくれます。
以下の条件でのレイアウト例をみてみましょう。

axis: .horizontal
distribution: .fillEqually
alignment: .center
spacing: 0
fillEqually1-2.png
図2-1

まず、.fillの時と同じくarrangedSubviewは左端から右端までいっぱいに満たすようになります(図1-2と同じなので図は割愛します)。

横幅の制約は先ほどと大きく異なります。番号を振ってみましょう。

fillEqually2-2.png
図2-2

1: stackView.width = your_constraint
2, 3, 4: rect1.width = rect2.width = rect3.width

制約1は、stackViewの外部からの制約です。図1-3の制約5と同じです。
制約2, 制約3, 制約4UIStackViewの働きによるもので、rect1, rect2, rect3横幅が全て同じという制約です。stackViewの横幅(つまり制約1による横幅)を均等に3等分してくれます。
axis方向の大きさが全て同じという効果によりequallyという名前がつけられています。

.fillProportionally

.fillProportionallyは、arrangedSubviewsに対するaxis方向の制約の計算方法が.fillEquallyと異なります。それ以外は同じです。

さっそく、以下の条件でのレイアウト例を見てみます。

axis: .horizontal
distribution: .fillProportionally
alignment: .fill
spacing: 0

fillProportionally1.png
図3-1

名前にfillとある通り、arrangedSubviewsは左端から右端まで満たされるようにいっぱいにレイアウトされます。これは.fill.fillEquallyと同じです。

横幅の制約に番号を振りましょう。.fillEquallyとの違いに注目してください。

fillProportionally2-2.png
図3-2

1: stackView.width = your_constraint
2, 3: bigLabel.width = smallLabel.width ✕ (5 / 3)
2, 4: bigLabel.width = icon.width ✕ (5 / 2)

制約1はこれまでと同様です。stackViewの横幅は外部から与えられています。
制約2 ~ 4では.fillEquallyと同様にarrangedSubviewsの横幅同士がイコールで結ばれています。ただ、違うのは、比率が設定されていることです。
この比率はどこから来ているかというと、intrinsicContentSizeの比率です。この例では、実は

  • bigLabel.intrinsicContentSize.width = 500pt
  • smallLabel.intrinsicContentSize.width = 300pt
  • icon.intrinsicContentSize.width = 200pt

というintrinsicContentSizeになっています。したがって、比率にすると

  • bigLabel.intrinsicContentSize.width : smallLabel.intrinsicContentSize.width = 5 : 3
  • bigLabel.intrinsicContentSize.width : icon.intrinsicContentSize.width = 5 : 2

になります。それに合わせてUIStackViewが横幅を設定してくれたのが制約2 ~ 4です。
fillProportionallyというのは「比率を保ちながら満たす」という感じの意味からつけられた名前です。

横幅の制約の優先度について

以上から、bigLabelの横幅には
1. intrinsicContentSizeによる横幅
2. 制約2による横幅

の二つの制約があるのですが、制約2priorityは1000(必須)です。一方、デフォルトではintrinsicContentSizeによる制約の優先度は1000よりも低くなっています。そのため、制約2による制約が優先されます(他のsmallLabeliconも同様です)。

.equalSpacing

.equalSpacingではarrangedSubviewsaxis方向の大きさ(つまり横幅あるいは高さ)の決定はエンジニアの仕事です。intrinsicContentSizeを使ったり、明示的に制約を与えましょう。また、UIStackViewのaxis方向の大きさも外部から与えるのが基本です。

以下の設定でのレイアウト例を示します。
axis: .horizontal
distribution: .equalSpacing
alignment: .fill
spacing: 0

equalSpacing1-2.png
図4-1

.equalSpacingの特徴はarrangedSubviews同士の間隔をUIStackViewが動的に決定してくれることです。そのためspacingは通常は0に設定します。spacingの設定が0にもかからわず、ラベルやアイコンの間に隙間があることに注意してください。

隙間はどのような制約によって成り立っているか確認します。

equalSpacing2-3.png
図4-2

1: stackView.left = label1.left
2, 3: label2.left - label1.right = icon.left - label2.right
4: icon.right = stackView.right

注) label1, label2, iconの横幅はintrinsicContentSizeによるものです。

制約1 ~ 4は全てUIStackViewの働きによるものです。
制約2, 制約3が重要です。arrangedSubviewの横幅の制約に合わせて隙間を同じ量だけ確保しています。隙間が同じ量だけあるため、equalSpacingという名前になっています。

.equalCentering

.equalCenteringでもarrangedSubviewsstackViewaxis方向の大きさの制約はエンジニアの仕事です。
また、arrangedSubviews同士の隙間はUIStaciViewが動的に設定してくれます。そのため.equalSpacingと同様にspacingの値は0にしておきます。

以下の設定でレイアウトしてみましょう。
axis: .horizontal
distribution: .equalSpacing
alignment: .fill
spacing: 0
equalCentering1.png
図5-1

spacingが0になっているにもかかわらず隙間があるのはequalSpacingと同じです。
隙間の計算方法を確認するために、制約に番号を振ってみましょう。

equalCentering2-2.png
図5-2

1: stackView.left = label1.left
2, 3: label2.center.x - label1.center.x = icon.left - label2.right
4: icon.right = stackView.right

注) label1, label2, iconの横幅はintrinsicContentSizeによるものです。

制約1 ~ 4は全てUIStackViewの働きによるものです。
制約2, 制約3は、arrangedSubviewsの中心を基準にしていることに注目してください。中心同士の距離が等しいという制約になっています。この性質のため、equalCenteringという名前がついています。

参考にした記事

以下の記事を参考にしました。

UIStackView Guide
https://developer.apple.com/documentation/uikit/uistackview

Let's Build UIStackView
http://kean.github.io/post/lets-build-uistackview

StackViewを賢く使ってらくちんAutoLayout
https://qiita.com/yucovin/items/ff58fcbd60ca81de77cb

Exploring UIStackView Distribution Types
https://spin.atomicobject.com/2016/06/22/uistackview-distribution/