[SwiftUI] Layout 考察

SwiftUI2021

     
SwiftUI での Layout について、少し深く考えてみます。具体的には、sizeThatFits と placeSubview にわたされる proposal と bounds についてです。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterey 13 Beta9
  • Xcode 14.1 beta 3
  • iOS 16.0

SwiftUI の Layout

自分でレイアウトを作る時に使用する Protocol です。

Apple のドキュメントは、こちら

最低限実装しないメソッドとまとめると、以下のような定義です。

@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
public protocol Layout : Animatable {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Self.Subviews,
                      cache: inout Self.Cache) -> CGSize
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
                       subviews: Self.Subviews, cache: inout Self.Cache)
}

以前の記事として、Layout の基本的な使い方をまとめています。
SwiftUI2021[SwfitUI] Layout を理解する
SwiftUI2021[SwiftUI] Layout を理解する/View のレイアウト情報取得

sizeThatFits

実装しなければいけないメソッドの1つ sizeThatFits について詳細を確認します。

    func sizeThatFits(proposal: ProposedViewSize, subviews: Self.Subviews,
              cache: inout Self.Cache) -> CGSize

与えられる引数は、proposal: ProposedViewSize, subviews: LayoutSubviews です。
(以下の説明では cache は省略してます)

引数 proposal: ProposedViewSize

1つ目の引数は、proposal: ProposedViewSize です。

親 Container からのレイアウトについてのリクエストを表しています。

以下のようなリクエストがありえます。
・特定のサイズにレイアウトしてほしい
・できるだけ小さいサイズにレイアウトしてほしい
・いっぱいの大きさを使ってレイアウトしてほしい
・自分で決めた大きさでレイアウトしてほしい

それぞれ、以下のような情報が、proposal にセットされてきます。
・width, height を持つ ProposedViewSize
・ProposedViewSize.zero
・ProposedViewSize.infinity
・ProposedViewSize.unspecified

あくまで、親 Container の要望なので、それに沿うかどうかは Layout 次第です。

対応具合はレイアウトによっていろいろだと思いますが、
例えば .zero 指定された時や、width/height 指定されたサイズに収まらないケースでは、一部のビュー表示を省略する等の対応も考えられそうです。

引数 subviews: LayoutSubviews

2つ目の引数は、LayoutSubviews です。

定義では、Self.Subviews になっていますが、LayoutSubviews の typealias です。

その名前の通り、子 View (の Proxy) です。(Proxy であり 子 View そのものではありません)

子 View の Proxy に対して、sizeThatFits メソッドを呼ぶことができますので、子View が必要とするサイズの確認をすることができます。

sizeThatFits が返す値(CGSize)

proposal で親 Container からの レイアウトに対する要望がわかり、subviews を参照することで、子 View の必要とするサイズが分かりますので、結果として、子 View を含めた 自分が必要とするサイズがわかることになります。

この情報が、sizeThatFits から 親 Container に返す情報となります。

placeSubviews

実装しなければいけないメソッドのもう1つ placeSubviews について詳細を確認します。

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
                       subviews: Self.Subviews, cache: inout Self.Cache)

与えられる引数は、bounds: CGRect, proposal: ProposedViewSize, subviews: LayoutSubviews です。
(以下の説明では cache は省略してます)

sizeThatFits と比較すると、bounds: CGRect が増えています。

引数 bounds: CGRect

この bounds は、先に、sizeThatFits で自分の返した 大きさを持っていて、配置に使用することが想定されている CGRect です。

Apple のドキュメントにもありますが、この CGRect では、領域の原点を (0,0) に持たないことも考えられるので、常に、minX/midX/maxX, minY/midY/maxY 等を使用して計算しないといけません。

注意

気をつけないといけないのが、与えられた bounds は、自分の返した大きさを持っていますが、親 Container が別の要素と干渉していないことを保証してくれるわけではないという点です。
干渉しない大きさについては、次の引数である proposal を参照することが必要となります。

引数 proposal: ProposedViewSize

2つ目の引数は、proposal: ProposedViewSize です。

proposal という名前ですが、他の View と衝突しない領域は、この proposal の領域です。 

sizeThatFits に渡される proposal では、.zero / .infinity / .unspecified という特殊な値がありましたが、確認した範囲では、placeSubviews に渡される proposal は、実際に値を持っているようです。

注意

あくまで、”確認した範囲” です。Apple のドキュメントには特に説明はありません。

引数 subviews: LayoutSubviews

3つ目の引数は、LayoutSubviews です。

この引数は、sizeThatFits に渡されてくる同名の引数と同じです。

注意点:placeSubviews

Apple のドキュメントには、proposal を変えて何度か呼び出される可能性があると説明されています。

当初、sizeThatFits がさまざまな組み合わせで何度も呼び出されると理解していたのですが、placeSubviews も様々な組み合わせで何度も呼び出されます。

親 Container としては、子 View から 複数パターンでの必要サイズを取得し、他の 子 View との調整を行った後に 一斉に placeSubviews を呼び出し配置すると “勝手に” 思っていましたが、実際の動作は違うようです。

同じ値を持つ引数を使った呼び出しも複数回確認できました。

sizeThatFits/placeSubviewsに与えられる proposal

placeSubviews の bounds の表す大きさは、sizeThatFits で返した大きさです。
このことは、Apple のドキュメントにも記載されています。

では、sizeThatFits/ placeSubviews に与えられる proposal は、どのような値が来るのでしょうか?

Apple のドキュメントには詳細の説明がないので、すこし試行錯誤しました。

以下の試行では、TestLayout が調査対象の Layout です。高さ方向のみに着目して確認しています。

親 Container の 子 View が 親 Container のサイズに収まる時

自分以外の View が要求する量が 親 Container の持つサイズに対して余裕があるケースでの sizeThatFits/placeSubviews に与えられるサイズを確認しました。

テストしたケース1

まずは、自 View 以外の高さが固定されているケースです。

struct ContentView: View {
    var body: some View {
        VStack(spacing: 0) {
            Color.yellow.frame(height: 200).border(.red)
            TestLayout {
                Color.blue
            }.border(.green)
            Color.orange.frame(height: 200).border(.red)
        }
        .frame(width: 200, height: 500)
        .padding()
    }
}

全体の高さが 500 で、Color.yellow, Color.orange はそれぞれ高さ 200 なので、残りは 100 です。

sizeThatFits には、0.0, inf, 100.0 の高さを持つ proposal が渡されてきました。

placeSubviews に渡される proposal は、高さ100 のみでした。

他の 2つの View は、高さ固定なので、高さ 100 でのみ placeSubviews が呼び出されるのは、理解できます。

Case1

テストしたケース2

自 View 以外にも固定値でない制約が与えられている View が共存しているケースです。

struct ContentView: View {
    var body: some View {
        VStack(spacing: 0) {
            Color.yellow.frame(idealHeight: 200).border(.red)
            TestLayout {
                Color.blue
            }.border(.green)
            Color.orange.frame(height: 200).border(.red)
        }
        .frame(width: 200, height: 500)
        .padding()
    }
}

全体の高さが 500 で、Color.orange の高さは、200 で固定です。
Color.yellow の理想値を 200 にしていますが、あくまで理想値です。

親 Container は、残りの高さ 300 を Color.yellow と 自 View に均等に割り当てようとしました。

sizeThatFits には、0.0, inf, 150.0 が渡されてきました。

placeSubviews に渡される proposal は、高さ 150 のみでした。

Case2
MEMO

本題から少し外れますが、idealHeight を指定する意味がよくわからなくなりました。
TestLayout に高さ制約はなく、sizeThatFits に 0.0 が渡された時には、高さ 0.0 を返すようにしているので、Color.yellow に その理想的な高さ200を与えるように 高さ100 を聞いてくると予想していたのですが、聞いてきませんでした。
少なくともこのケースでは、idealHeight が指定されていても、特にその値を割り当てるよう努力しているように思えません。全てのケースを考慮できているわけではないので、idealHeight が活躍するケースが他にあるのかもしれません。ご存知の方 教えてくださると嬉しいです。

親 Container の 子 View が収まらない時

子 View の要求するサイズが収まらない時には、どうなるのかも確認しました。

テストしたケース3

自分以外の View が要求する量が すでに 親 Container をいっぱいにするケースです。

struct ContentView: View {
    var body: some View {
        VStack(spacing: 0) {
            Color.yellow.frame(height: 200).border(.red)
            TestLayout {
                Color.blue
            }.border(.green)
            Color.orange.frame(height: 300).border(.red)
        }
        .frame(width: 200, height: 500)
        .padding()
    }
}

sizeThatFits への proposal は、0.0, .infinity が与えられてきます。

placeSubviews での proposal は、高さ 0 です。

以下のような配置になり、Color.blue は、高さ 0 で配置されます。つまり、見えません。

Height0

テストしたケース4

自分以外の View が要求する量が すでに 親 Container をいっぱいにするケースです。ただし、別の View は、idealHeight での指定のケース。

struct ContentView: View {
    var body: some View {
        VStack(spacing: 0) {
            Color.yellow.frame(idealHeight: 200).border(.red)
            TestLayout {
                Color.blue
            }.border(.green)
            Color.orange.frame(height: 300).border(.red)
        }
        .frame(width: 200, height: 500)
        .padding()
    }
}

sizeThatFits への proposal には、0.0, inf, 100 が与えられました。

placeSubviews での proposal は、高さ 100 でした。

Color.orange の高さは、300で固定なので、のこりの高さ (500 – 300) = 200 を Color.yellow と Color.blue で分けるような proposal になっていると考えられます。
動作的には ケース2と同じです。

ここでも、idealHeight については、あまり考慮されていないように見えます。

Height100

考察

たった 4ケースですが、動作を確認してみました。

厳密にはもっと調べなければいけませんが、以下のことが言える気がします。

・sizeThatFits には、0.0, .inf と合わせて、他の View との兼ね合いを考慮した 自 View に与えることができる最大サイズ が渡される。

(sizeThatFits から返す値で変わるかもしれませんが)
・placeSubviews には、他の View との兼ね合いを考慮した最大サイズが、渡される。

いずれも、”気”がするだけですが・・・

まとめ

Layout で使われる sizeThatFits と placeSubviews に渡される引数の意味を調べてみました。

Layout で使われる sizeThatFits と placeSubviews に渡される引数の意味
  • placeSubviews に渡される bounds は、sizeThatFits が返した大きさを 親 Container が考える配置予定の箇所に配置した時の bounds であり、他 View との干渉がないことを 親 Container が保証してくれることはなさそう
  • placeSubviews に渡される proposal は、.zero や .infinity ではなく、実際の値がセットされた ProposedViewSize が渡される気がする
  • 他 View との干渉なく配置したい時には、bounds で渡される領域のうち、proposal 分のみで配置することが必要となる気がする

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMatery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です