👖

SwiftUIのlayoutシステム

SwiftUIにおけるlayoutの仕組みを調べたのでまとめる。

  • Viewの種類やmodifierによるlayoutの違い
  • どのようにViewのframeが決定されるか

UI実装時のよくある問題

SwiftUIの仕組みを理解していない場合、以下のような問題が起きがちになる。

  • 表示上は正しいけど実装が無駄に複雑になっている
  • fixedSize()をつけるとなんかよく分からないけど直る
  • frame(maxWidth:)layoutPriority(_:)が思った通りに効かない

例題

例えば以下のような仕様を実装しようとしてみる。

  • タイトルとサブタイトルを横に並べる
  • タイトルを可能な限り広く表示し、左寄せにする
  • サブタイトルの最大幅は100pxまでとし、右寄せにする

以下のコードではタイトルが早めに改行されてしまい、スペースを有効活用できていない実装になってしまっている。

なんとなくタイトルに.layoutPriority(1)を付与すると余計レイアウトが崩れる。 なぜこのようなことが起こるのか、基本から学び直してみる。

Viewの種類によるサイズの違い

border(_:width:)でViewのサイズやどのように広がるのか確認する。

Color

Colorは与えられたサイズの全体に広がる。

Text

Textは与えられたサイズの全体に広がらず、必要なサイズのみ。 この挙動はUIKitのintrinsic content sizeに似たようなものをSwiftUIでも持っていることが想定される。

Toggle

Toggleは横方向にのみ広がる。

いろんなViewを調べてみる

  • 全体に広がる・・・ColorLinearGradientAngularGradientEllipseCapsuleRectangle
  • Aspect比率を保持しつつ広がる・・・Circle
  • 必要なサイズ・・・TextImageButtonLinkProgressView
  • 横方向に広がる・・・ToggleSliderStepperGaugeDatePicker
  • 縦方向に広がる・・・ScrollView

Viewの広がりやすさは縦横どちらか一方であったり、比率が関係したりする。

その他、Imageは.resizable()をつけると全体に広がるようになったり、 ProgressViewは.progressViewStyle(.linear)をつけると横に広がるようになったり、 modifierによってもサイズの性質が異なることがわかる。

ここまでのまとめ

  • Viewの種類や付与するmodifierによって、Viewの広がり方(柔軟性)が異なる

layoutプロセス

SwiftUIでのViewのlayoutの手続きはWWDC2019の「Building Custom Views with SwiftUI (opens new window)」のセッションで紹介されている。

layoutプロセスの基本

  1. 親Viewから子Viewのサイズを提案
  2. 子Viewは自分のサイズを決定
  3. 親Viewが子Viewを配置
  4. Viewの端を最も近いピクセルに丸める(エッジが鮮明)

https://developer.apple.com/videos/play/wwdc2019/237/ (opens new window)

上記の例だと、

  1. frameからImageのサイズを提案(30 × 30)
  2. Imageは自分のサイズを決定(20 × 20)
  3. frameはalignment引数のデフォルト値がcenterなので中央にImageを配置

Viewやmodifierの種類による広がり方はステップ2の振る舞いに影響するものだとわかる。

*modifierはViewを包んでいる親Viewとみなす。(例としてframeは制約でも境界でもない)

UIKitのAutoLayoutとの違い

  • 結果に不満足な場合を除いて間違ったlayoutは存在しない
    • 制約が不足したり(Ambiguous Layout)、制約が過剰になったり(Conflict)しない
    • 親Viewと子Viewで双方向の制約を持てないのでシンプル(これがAutoLayoutの複雑さの原因)
  • 環境に応じた自動適応(国際化、アクセシビリティ、マルチプラットフォーム対応)
    • 子Viewが環境に応じてサイズを決める
    • 親Viewが環境に応じて子Viewの配置を決める

https://developer.apple.com/videos/play/wwdc2019/237/ (opens new window)

Stackのlayoutプロセス

複数の子Viewを持つStack形式のView(VStackやHStackなど)のlayoutプロセスは以下のようになる。

  1. Stackの内側のspacingを合計し、親ビューによって提案されたサイズから引く
  2. 子Viewごとに残りのスペースを均等に分割し、柔軟性のないViewからそのサイズを提案
  3. 子Viewから要求されたサイズを残りのスペースから差し引く(これを繰り返す)
  4. Stackはspacingとalignmentに応じて子Viewを並べる

以下はHStackによるレイアウトの例。全体に広がるColorはStack内で均等に分割されていることがわかる。

layoutプロセスのカスタマイズ

WWDC2022の「Compose custom layouts with SwiftUI (opens new window)」で紹介された、iOS16以降で利用できる以下APIでlayoutプロセスをカスタマイズできる。

このAPIを参考にすると、layoutプロセスをより具体的に理解するのに役立つ。

Layoutプロトコルに準拠するために必要なメソッド

/// Returns the size of the composite view, given a proposed size and the view’s subviews.
func sizeThatFits(
    proposal: ProposedViewSize, 
    subviews: Subviews, 
    cache: inout ()
) -> CGSize
    
/// Assigns positions to each of the layout’s subviews.
func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Self.Subviews,
    cache: inout Self.Cache
)

modifierによるlayout調整

layoutPriority

layoutの優先度を設定するmodifierは以下のAPIになっており、値のデフォルト値は0になっている。

/// Sets the priority by which a parent layout should apportion space to this child.
func layoutPriority(_ value: Double) -> some View

.layoutPriority(1)を指定することで、優先的にlayoutできる。

Stackのlayoutプロセスによれば、layoutは柔軟性の低いViewから行われるため、layoutPriorityが機能するのは以下の3つのパターン。

  • layoutPriorityが同じ
  • 柔軟性の高い(優先度が低い)ViewのlayoutPriorityを上げる
  • 柔軟性の低い(優先度が高い)ViewのlayoutPriorityを下げる

fixedSize

paddingには以下2種類のmethodがある。横方向もしくは縦方向のみ有効にする場合は後者を利用する。

/// Fixes this view at its ideal size.
func fixedSize() -> some View

/// Fixes this view at its ideal size in the specified dimensions.
func fixedSize(horizontal: Bool, vertical: Bool) -> some View

子Viewに対し具体的なサイズを提案せず(unspecified (opens new window))、子Viewが要求する理想的なサイズ(ideal size)をそのまま受け入れる。 そのため、fixedSize自身は親Viewからの提案サイズを超えることがある。

例えば、Textのサイズを横幅は親Viewの提案サイズ内で改行してすべて表示したい場合、 .fixedSize(horizontal: false, vertical: true)を指定する。

改行させずに表示する場合は、.fixedSize(horizontal: true, vertical: false)を指定する。

fixedSizeとい命名から挙動が想定しづらい。子Viewにとっての理想的なサイズを表示するのでドキュメントコメントを参考にして fixedIdealSize() などの方が直感的になりそう。

Fixes this view at its ideal size.

また、fixedSizeは複数のViewの高さや幅を揃えたいユースケースにも活用できる。

高さを揃える場合

  • 揃えたい要素の高さを後述の.frame(maxHeight: .infinity)などで広がりやすくする
  • 揃えたい要素を包むHStackに.fixedSize(horizontal: false, vertical: true)をつけて最小限の高さにする

幅を揃える場合

  • 揃えたい要素の幅を後述の.frame(maxWidth: .infinity)などで広がりやすくする
  • 揃えたい要素を包むVStackに.fixedSize(horizontal: true, vertical: false)をつけて最小限の幅にする

frame

frameには以下の2種類のmethodがある。

/// Positions this view within an invisible frame with the specified size.
func frame(width: CGFloat?, height: CGFloat?, alignment: Alignment) -> some View

/// Positions this view within an invisible frame having the specified size constraints.
func frame(minWidth: CGFloat?, idealWidth: CGFloat?, maxWidth: CGFloat?, minHeight: CGFloat?, idealHeight: CGFloat?, maxHeight: CGFloat?, alignment: Alignment) -> some View

width、height

親Viewの提案サイズに関係なく指定した値にサイズを決定する。 少しUIKitの考え方と異なるのは、frameに相当するView自身がこのサイズになるだけで、子Viewのサイズがこのサイズになるとは限らない。 子ViewがColorのように柔軟な場合のみ子Viewとサイズが一致する。

minWidth、minHeight

If a minimum constraint is specified and the size proposed for the frame by the parent is less than the size of this view, the proposed size, clamped to that minimum.

親Viewの提案サイズを超える範囲で指定した値にサイズを決定する。 子ViewのサイズがこのminWidthで制限されるわけでないないことに注意が必要。

maxWidth、maxHeight

If a maximum constraint is specified and the size proposed for the frame by the parent is greater than the size of this view, the proposed size, clamped to that maximum.

親Viewの提案サイズを超えない範囲で指定した値にサイズを決定する。 子ViewのサイズがこのmaxWidthで制限されるわけでないないことに注意が必要。

idealWidth、idealHeight

理想的なサイズを返すことができる。理想的なサイズはfixedSize()が指定されている場合にframeのサイズに反映される。

まとめ

例題を振り返る

例題のコードはどう修正すれば良かったのか?

  • 親Viewの提案する幅いっぱいに広げたい場合は、maxWidth: .infinityを指定する。
  • Textを理想的な幅〜最大幅まで可変にしたい場合は、maxWidth: 最大幅.fixedSizeを組み合わせる。
  • タイトルを先にlayoutしてしまうと、サブタイトルのスペースがなくなるので、タイトルに.layoutPriority(1)をつけてはいけない

SwiftUIのlayoutの要点

  • Viewの種類に応じた柔軟性がレイアウトに影響し、modifierによって柔軟性を変更できる
  • layoutプロセスは親が子のサイズ提案→子がサイズ決定→親が子の配置決定
  • Stackでのlayoutは柔軟性が低いViewから配置する
  • layoutPriority、frame、fixedSizeはUIKitと考え方が異なるので、layoutの仕組みを理解して利用する

サンプルコード: shtnkgm/SwiftUILayoutSystem (opens new window)