🚀

【SwiftUI】レイアウトプロセスを観察する方法(LayoutProcessInspector の紹介)

に公開

Overview

後述のLayoutProcessInspectorを使用することで、レイアウトプロセス(≒ ビューが受けたサイズ提案、それに対する応答、配置されたポジション)を観察できます。

Code
struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello, world.")
                .inspectLayoutProcess("Text", printPosition: true)
        }
        .frame(width: 300, height: 300)
    }
}
Output
Text】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(300.0))Text】report size: (94.33333333333333, 20.333333333333332)Text】position: (0.0, 0.0)Text】position: (153.83333333333334, 440.83333333333337)

Description

下記のプロセスに関しては、既に多くの方がご存知だと思います。

  1. 親ビューが子ビューにサイズを提案する。
  2. 子ビューがサイズ提案をもとに、自身のサイズを決定する。
  3. 子ビューが決定した自身のサイズを親ビューに応答として返す。
  4. 親ビューは応答を踏まえて、子ビューの配置を行う。

このプロセスは、WWDC のセッション等で度々言及されています。しかし、このプロセスを観察する方法に関しては、私の知る限り一度も触れられたことはありません。

Layout プロトコルを利用することで、このプロセスが具体的にどのようにして進んでいるかを観察することができたので、本記事で知見を共有したいと思います。


プロセスの観察には、下記の LayoutProcessInspector を使用します。

Code
struct LayoutProcessInspector: Layout {
    var tag: String
    var printPosition: Bool

    init(_ tag: String, printPosition: Bool = false) {
        self.tag = tag
        self.printPosition = printPosition
    }

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        print("【\(tag)】proposal size:", proposal)
        let size = subviews[0].sizeThatFits(proposal)
        print("【\(tag)】report size:", size)
        return size
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let position = bounds.origin
        subviews[0].place(at: position, proposal: proposal)
        if printPosition == true { print("【\(tag)】position: \(position)") }
    }
}

LayoutProcessInspector は、直接呼び出しても問題ありませんが、LayoutProcessInspector は、単一のビューを受け取ることを前提に作成されています。

そのため、カスタムモディファイアから間接的に呼び出すことで、単一のビューを受け取ることを保証させます。

Code
extension View {
    func inspectLayoutProcess(_ tag: String, printPosition: Bool = false) -> some View {
        LayoutProcessInspector(tag, printPosition: printPosition) { self }
    }
}

LayoutProcessInspector は、Layout プロトコルに準拠していますが、カスタムコンテナとしての機能は持ち合わせておらず、レイアウトに一切の影響を与えません。

ただ一つの子ビューを受け入れ、親ビューからのサイズ提案をそのまま子ビューに渡し、子ビューの応答をそのまま親ビューに渡します。子ビューが配置されるポジションも変わりません。


では、なぜ LayoutProcessInspector を作成したかというと、「親ビューからの提案を子ビューに渡し、子ビューの応答を親ビューに返す」という過程で、親ビューからのサイズ提案と子ビューの応答を確認できるからです。(sizeThatFits メソッド

また、親ビューが LayoutProcessInspector のために確保した領域に関する情報が CGRect として渡されるため、子ビューが配置されるポジションを確認できます。(placeSubviews メソッド

つまりは、観察対象のビューが親ビューからどのような提案を受け、どのような応答を返し、どこに配置されたかを確認できます。

(観察対象のビューが配置されるポジションに関しては、個人的に確認が必要なケースが少ないと感じているため、フラグを用いて出力を制御しています。蛇足ですが、ビューが配置されるポジションだけ確認したい場合は、onGeometryChange モディファイアがおすすめです。)


Usage Examples

実際にレイアウトプロセスを観察する例をいくつか提示したいと思います。


padding

まず最初に、padding モディファイア適用時のレイアウトプロセスを観察してみます。

Code
struct ContentView: View {
    var body: some View {
        Text("Hello, world.")
            .inspectLayoutProcess("Text")
            .padding(10)
            .inspectLayoutProcess("padding")
    }
}
Output
【padding】proposal size: ProposedViewSize(width: Optional(402.0), height: Optional(778.0))Text】proposal size: ProposedViewSize(width: Optional(382.0), height: Optional(758.0))Text】report size: (94.33333333333333, 20.333333333333332)
【padding】report size: (114.33333333333333, 40.33333333333333)

padding モディファイアは、親ビューからProposedViewSize(width: Optional(402.0), height: Optional(778.0))の提案を受けています。

その後、子ビューである Text にProposedViewSize(width: Optional(382.0), height: Optional(758.0))の提案しています。

padding モディファイアの引数 length には、10pt が設定されているため、上下左右から 10pt を引いた
ProposedViewSize(width: Optional(382.0), height: Optional(758.0))が子ビューである Text に提案されていることが分かります。

Text は、padding モディファイアからの提案から自身のサイズを決定し、padding モディファイアにCGSize(94.33..., 20.33...)と応答します。

この応答を受け、padding モディファイアは上下左右に 10pt を足したCGSize(114.33..., 40.33...)を応答として返します。

以上が padding モディファイア適用時のレイアウトプロセスです。


fixedSize

折り返しや省略がされた Text に対して、fixedSize モディファイアを適用すると、Text が折り返されることも、省略されることもなく表示されることは、ご存知の方も多いと思います。

Code
// Before
Text("Hello, world.")
    .frame(width: 50, height: 50)
    .border(.pink)

// After
Text("Hello, world.")
    .fixedSize(horizontal: true, vertical: false)
    .frame(width: 50, height: 50)
    .border(.pink)
Before After

なぜ「fixedSize モディファイアを適用すると、Text が折り返されることも、省略されることもなく表示される」のかは、fixedSize モディファイア適用時のレイアウトプロセスを観察することで理解できます。

まずは、fixedSize モディファイア適用前のレイアウトプロセスを確認します。

Code
struct ContentView: View {
    var body: some View {
        Text("Hello, world.")
            .inspectLayoutProcess("Text")
            .frame(width: 50, height: 50)
            .border(.pink)
    }
}
Output
Text】proposal size: ProposedViewSize(width: Optional(50.0), height: Optional(50.0))Text】report size: (47.666666666666664, 42.33333333333333)

Text は、ProposedViewSize(width: Optional(50.0), height: Optional(50.0))の提案を受け、CGSize(47.66..., 42.33...)と応答しています。

次に、fixedSize モディファイア適用後のレイアウトプロセスを確認します。

Code
struct ContentView: View {
    var body: some View {
        Text("Hello, world.")
            .inspectLayoutProcess("Text")
            .fixedSize(horizontal: true, vertical: false)
            .inspectLayoutProcess("fixedSize")
            .frame(width: 50, height: 50)
            .border(.pink)
    }
}
Output
【fixedSize】proposal size: ProposedViewSize(width: Optional(50.0), height: Optional(50.0))Text】proposal size: ProposedViewSize(width: nil, height: Optional(50.0))Text】report size: (94.33333333333333, 20.333333333333332)
【fixedSize】report size: (94.33333333333333, 20.333333333333332)

Text は、fixedSize モディファイア適用前とは異なりProposedViewSize(width: nil, height: Optional(50.0))の提案を受けています。

fixedSize モディファイアの引数 horizontal には true が設定されているため、Text に対する幅の提案が未指定( nil )となります。

そのため、fixedSize モディファイアを適用すると、折り返されることも省略されることもなく、Text が表示されるようになります。


aspectRatio

aspectRatio モディファイアは、ビューのアスペクト比を指定するモディファイアと紹介されることが多いです。

しかし、実際には aspectRatio モディファイアも fixedSize モディファイアと同様に、親ビューからのサイズ提案を変更するモディファイアです。

Code & Output
struct ContentView: View {
    @State private var i = 0
    
    var body: some View {
        VStack(spacing: .zero) {
            switch i {
            case 0: rectangle_fit
            case 1: rectangle_fill
            case 2: flexibleRectangle_fit
            case 3: flexibleRectangle_fill
            default: EmptyView()
            }
        }
        .frame(width: 350, height: 600)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .safeAreaInset(edge: .bottom) {
            Button("Change") {
                print("- - - - - - - - - -")
                i = (i + 1) % 4
            }
        }
    }
    
    /// 【aspectRatio】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(600.0))
    /// 【frame】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(300.0))
    /// 【frame】report size: (300.0, 300.0)
    /// 【aspectRatio】report size: (300.0, 300.0)
    ///
    var rectangle_fit: some View {
        Rectangle()
            .fill(.pink)
            .inspectLayoutProcess("frame")
            .aspectRatio(1, contentMode: .fit)
            .inspectLayoutProcess("aspectRatio")
            .frame(width: 300, height: 600)
    }
    
    /// 【aspectRatio】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(600.0))
    /// 【frame】proposal size: ProposedViewSize(width: Optional(600.0), height: Optional(600.0))
    /// 【frame】report size: (600.0, 600.0)
    /// 【aspectRatio】report size: (600.0, 600.0)
    ///
    var rectangle_fill: some View {
        Rectangle()
            .fill(.pink)
            .inspectLayoutProcess("frame")
            .aspectRatio(1, contentMode: .fill)
            .inspectLayoutProcess("aspectRatio")
            .frame(width: 300, height: 600)
    }
    
    /// 【aspectRatio】proposal size: ProposedViewSize(width: Optional(600.0), height: Optional(600.0))
    /// 【frame】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(600.0))
    /// 【frame】report size: (300.0, 600.0)
    /// 【aspectRatio】report size: (300.0, 600.0)
    ///
    var flexibleRectangle_fit: some View {
        Rectangle()
            .fill(.pink)
            .inspectLayoutProcess("frame")
            .aspectRatio(1/2, contentMode: .fit)
            .inspectLayoutProcess("aspectRatio")
            .frame(width: 600, height: 600)
    }
    
    /// 【aspectRatio】proposal size: ProposedViewSize(width: Optional(600.0), height: Optional(600.0))
    /// 【frame】proposal size: ProposedViewSize(width: Optional(600.0), height: Optional(1200.0))
    /// 【frame】report size: (600.0, 1200.0)
    /// 【aspectRatio】report size: (600.0, 1200.0)
    ///
    var flexibleRectangle_fill: some View {
        Rectangle()
            .fill(.pink)
            .inspectLayoutProcess("frame")
            .aspectRatio(1/2, contentMode: .fill)
            .inspectLayoutProcess("aspectRatio")
            .frame(width: 600, height: 600)
    }
}

下記のコードのように、引数 aspectRatio に nil が設定されている場合は、一度子ビューにProposedViewSize(width: nil, height: nil)を提案します。

このときに子ビューから返ってくる応答は理想サイズで、aspectRatio モディファイアは、この応答をもとにアスペクト比を考慮した提案を行います。

Code
struct ContentView: View {
    var body: some View {
        Image(systemName: "swift") // (22.000000000000004, 18.666666666666668)
            .resizable()
            .inspectLayoutProcess("resizable")
            .aspectRatio(contentMode: .fit)
            .inspectLayoutProcess("aspectRatio")
    }
}
Output
【aspectRatio】proposal size: ProposedViewSize(width: Optional(402.0), height: Optional(778.0))
【resizable】proposal size: ProposedViewSize(width: nil, height: nil)
【resizable】report size: (18.666667938232422, 16.666667938232422)
【resizable】proposal size: ProposedViewSize(width: Optional(402.00000000000006), height: Optional(358.92857436257947))
【resizable】report size: (402.00000000000006, 358.92857436257947)
【aspectRatio】report size: (402.00000000000006, 358.92857436257947)

VStack(HStack)

最後に、VStack のレイアウトプロセスを観察してみます。

VStack のレイアウトプロセスは、VStack が受けた提案や子ビューの応答によって異なります。

本記事では、VStack に具体的な提案が行われ、子ビューから具体的な応答が返される一般的なケースを例として挙げたいと思います。


観察で使用するコードの実行時の画面

VStack のレイアウトプロセスの観察に使用するコードは下記のとおりです。

Code
struct ContentView: View {
    var body: some View {
        VStack(spacing: 10) {
            flexibleSizeRectangle(color: .red, minHeight: 10, maxHeight: 100)
                .inspectLayoutProcess("🟥")
            
            fixedSizeRectangle(color: .blue, size: 100)
                .inspectLayoutProcess("🟦")
            
            flexibleSizeRectangle(color: .yellow, minHeight: 50, maxHeight: 100)
                .inspectLayoutProcess("🟨")
        }
        .frame(width: 300, height: 500)
        .border(.gray)
    }
    
    func flexibleSizeRectangle(color: Color, minHeight: CGFloat, maxHeight: CGFloat) -> some View {
        Rectangle()
            .fill(color)
            .frame(width: 100)
            .frame(minHeight: minHeight, maxHeight: maxHeight)
    }
    
    func fixedSizeRectangle(color: Color, size: CGFloat) -> some View {
        Rectangle()
            .fill(color)
            .frame(width: size, height: size)
    }
}
Output
 1【🟦】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(0.0))
 2【🟦】report size: (100.0, 100.0)
 3【🟦】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(inf))
 4【🟦】report size: (100.0, 100.0)
 5【🟥】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(0.0))
 6【🟥】report size: (100.0, 10.0)
 7【🟥】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(inf))
 8【🟥】report size: (100.0, 100.0)
 9【🟨】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(0.0))
10【🟨】report size: (100.0, 50.0)
11【🟨】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(inf))
12【🟨】report size: (100.0, 100.0)

13【🟦】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(160.0))
14【🟦】report size: (100.0, 100.0)
15【🟨】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(190.0))
16【🟨】report size: (100.0, 100.0)
17【🟥】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(280.0))
18【🟥】report size: (100.0, 100.0)

計 18 回の出力がありますが、これは「仮の提案フェーズ」と「実際の提案フェーズ」に分けられます。

仮の提案フェーズは、1 回目から 12 回目までの出力です。

このフェーズでは、各子ビューに対して最小サイズと最大サイズの提案を行い、柔軟性の測定を行います。(このときの提案の順序は不明)

最小サイズの提案:ProposedViewSize(width: Optional(300.0), height: Optional(0.0))
最大サイズの提案:ProposedViewSize(width: Optional(300.0), height: Optional(inf))

これらの提案に対し、各子ビューは下記のとおり応答しています。

🟦 の応答 🟥 の応答 🟨 の応答
最小サイズ CGSize(100.0, 100.0) CGSize(100.0, 10.0) CGSize(100.0, 50.0)
最大サイズ CGSize(100.0, 100.0) CGSize(100.0, 100.0) CGSize(100.0, 100.0)

これらの応答を確認すると、柔軟性の高さは 🟥 (90) > 🟨 (50) > 🟦 (0) であることが分かります。

各子ビューの柔軟性を測定できたところで、次は実際の提案フェーズです。

実際の提案フェーズは、13回目から18回目までの出力で、このフェーズでの提案と応答は実際に使用されます。

提案の順序は、柔軟性の低い順で、🟦 > 🟨 > 🟥 となります。

最初に 🟦 に対して、ProposedViewSize(width: Optional(300.0), height: Optional(160.0))の提案がされています。

VStack は、高さ 500pt から二つ分のスペーシングを引いた 480pt をビューに使用できます。

この 480pt をビューの数(3)で割った 160pt が 🟦 に対しての提案となります。

この提案に対し 🟦 はCGSize(100.0, 100.0)と応答し、VStack が使える残りの高さは 100pt を引き、380pt となります。

次に 🟨 に対して、ProposedViewSize(width: Optional(300.0), height: Optional(190.0))の提案がされています。

VStack は、残りの使用できる高さ(380pt)をビューの数(2)で割った 190pt を 🟨 に対して提案します。

この提案に対し 🟨 はCGSize(100.0, 100.0)と応答し、VStack が使える残りの高さは 100pt を引き、280pt となります。

最後に 🟥 に対して、ProposedViewSize(width: Optional(300.0), height: Optional(280.0))の提案がされています。

VStack は、使用できる残りの高さ全て(280pt)を 🟥 に対して提案し、🟥 はCGSize(100.0, 100.0)と応答します。

(VStack が使える残りの高さは 100pt を引き、180pt となりますが、これは使われません。)

この一連の流れがわかったところで、先ほどのコードの VStack に対し、.inspectLayoutProcess("VStack")を適用します。

Code
VStack(spacing: 10) {
    flexibleSizeRectangle(color: .red, minHeight: 10, maxHeight: 100)
        .inspectLayoutProcess("🟥")
    
    fixedSizeRectangle(color: .blue, size: 100)
        .inspectLayoutProcess("🟦")
    
    flexibleSizeRectangle(color: .yellow, minHeight: 50, maxHeight: 100)
        .inspectLayoutProcess("🟨")
}
.inspectLayoutProcess("VStack") // ✅
.frame(width: 300, height: 500)
.border(.gray)

.inspectLayoutProcess("VStack")適用後の出力は、下記のとおりです。

Output
 1VStack】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(500.0))

 2【🟦】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(0.0))
 3【🟦】report size: (100.0, 100.0)
 4【🟦】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(inf))
 5【🟦】report size: (100.0, 100.0)
 6【🟥】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(0.0))
 7【🟥】report size: (100.0, 10.0)
 8【🟥】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(inf))
 9【🟥】report size: (100.0, 100.0)
10【🟨】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(0.0))
11【🟨】report size: (100.0, 50.0)
12【🟨】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(inf))
13【🟨】report size: (100.0, 100.0)

14【🟦】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(160.0))
15【🟦】report size: (100.0, 100.0)
16【🟨】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(190.0))
17【🟨】report size: (100.0, 100.0)
18【🟥】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(280.0))
19【🟥】report size: (100.0, 100.0)

20VStack】report size: (100.0, 320.0)

2 回目から 19 回目までの出力は、先ほどと同じものです。

1 回目の出力から VStack がProposedViewSize(width: Optional(300.0), height: Optional(500.0))の提案を受けていることが分かります。

この提案に対し、VStack は子ビューとのやり取りを経て、CGSize(100.0, 320.0)と応答します。(20 回目の出力)

このときの高さは、「 🟥 の高さ + スペーシング + 🟦 の高さ + スペーシング + 🟨 の高さ」です。

つまりは、「 100pt + 10pt + 100pt + 10pt + 100pt 」で、VStack の高さは 320pt となります。


おまけ 1 : 上記のコードを理解した上で見ると、結構面白いコード

下記のコードでは「 🟥 が高さ 250pt、🟦 が高さ 50pt」、もしくは「🟥 が高さ 240pt、🟦 が高さ 60pt」などの組み合わせであれば VStack が上限とする高さ 300pt を全て使用できます。

Code
struct ContentView: View {
    var body: some View {
        VStack(spacing: .zero) {
            rectangle(color: .red, minHeight: 200, maxHeight: 250) // (300.0, 200.0)
            rectangle(color: .blue, minHeight: 0, maxHeight: 60) // (300.0, 60.0)
        }
        .frame(width: 300, height: 300)
        .border(.primary)
    }
    
    func rectangle(color: Color, minHeight: CGFloat, maxHeight: CGFloat) -> some View {
      Rectangle()
            .fill(color)
            .frame(minHeight: minHeight, maxHeight: maxHeight)
    }
}

しかし、実際には 🟥 の高さが 200pt、🟦 の高さが 60pt となっており、上下に 20pt ずつ余白が設けられています。

なぜ上限である 300pt 全てが使用されないのかは、レイアウトプロセスを観察することで理解できます。

rectangleinspectLayoutProcessモディファイアを適用し、出力を確認します。

Code
rectangle(color: .red, minHeight: 200, maxHeight: 250)
    .inspectLayoutProcess("🟥") // ✅
rectangle(color: .blue, minHeight: 0, maxHeight: 60)
    .inspectLayoutProcess("🟦") // ✅
Output
【🟦】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(0.0))
【🟦】report size: (300.0, 0.0)
【🟦】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(inf))
【🟦】report size: (300.0, 60.0)
【🟥】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(0.0))
【🟥】report size: (300.0, 200.0)
【🟥】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(inf))
【🟥】report size: (300.0, 250.0)

【🟥】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(150.0))
【🟥】report size: (300.0, 200.0)
【🟦】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(100.0))
【🟦】report size: (300.0, 60.0)

上記の出力から、柔軟性の高さは 🟦 (60) > 🟥 (50) であることが読み取れます。

🟥 の方が柔軟性が低いので、先に 🟥 に対してProposedViewSize(width: Optional(300.0), height: Optional(150.0))の提案がされます。

なぜ高さ 150pt かというと、VStack は使える高さ 300pt を、ビューの数である 2 で割った結果です。

それに対し 🟥 はCGSize(300.0, 200.0)と応答します。

提案の高さは 150pt ですが、🟥 の最低の高さは 200pt なので、高さは 200pt と応答しています。

親ビューは、子ビューのサイズの決定を尊重するため、VStack が使える残りの高さは 200pt を引き、 100pt となります。

次に、VStack は 🟦 にProposedViewSize(width: Optional(300.0), height: Optional(100.0))の提案し、🟦 はCGSize(300.0, 60.0)と応答します。

よって、VStack の高さは 🟥 の高さ(200pt)と 🟦 の高さ(60pt)を足した 260pt となり、上下に 20pt ずつ余白が設けられることになります。


ちなみに VStack が 300pt の高さを全て使うようにしたい場合は、layoutPriority モディファイアを用いて調整ができます。

Code
struct ContentView: View {
    var body: some View {
        VStack(spacing: .zero) {
            rectangle(color: .red, minHeight: 200, maxHeight: 250) // (300.0, 240.0)
            rectangle(color: .blue, minHeight: 0, maxHeight: 60) // (300.0, 60.0)
                .layoutPriority(1.0)
        }
        .frame(width: 300, height: 300)
        .border(.primary)
    }
    
    func rectangle(color: Color, minHeight: CGFloat, maxHeight: CGFloat) -> some View {
        Rectangle()
            .fill(color)
            .frame(minHeight: minHeight, maxHeight: maxHeight)
    }
}
おまけ 2 : VStackに具体的ではない提案が行われるケース

VStackに具体的ではない提案(幅と高さが未指定)

Code
struct ContentView: View {
    var body: some View {
        VStack(spacing: 10) {
            flexibleSizeRectangle(color: .red, minHeight: 10, maxHeight: 100)
                .inspectLayoutProcess("🟥")
            
            fixedSizeRectangle(color: .blue, size: 100)
                .inspectLayoutProcess("🟦")
            
            flexibleSizeRectangle(color: .yellow, minHeight: 50, maxHeight: 100)
                .inspectLayoutProcess("🟨")
        }
        .inspectLayoutProcess("VStack")
        .fixedSize()
        .border(.gray)
    }
    
    func flexibleSizeRectangle(color: Color, minHeight: CGFloat, maxHeight: CGFloat) -> some View {
        Rectangle()
            .fill(color)
            .frame(width: 100)
            .frame(minHeight: minHeight, maxHeight: maxHeight)
    }
    
    func fixedSizeRectangle(color: Color, size: CGFloat) -> some View {
        Rectangle()
            .fill(color)
            .frame(width: size, height: size)
    }
}
Output
VStack】proposal size: ProposedViewSize(width: nil, height: nil)
【🟥】proposal size: ProposedViewSize(width: nil, height: nil)
【🟥】report size: (100.0, 10.0)
【🟦】proposal size: ProposedViewSize(width: nil, height: nil)
【🟦】report size: (100.0, 100.0)
【🟨】proposal size: ProposedViewSize(width: nil, height: nil)
【🟨】report size: (100.0, 50.0)VStack】report size: (100.0, 180.0)
【🟥】proposal size: ProposedViewSize(width: Optional(100.0), height: nil)
【🟥】report size: (100.0, 10.0)
【🟦】proposal size: ProposedViewSize(width: Optional(100.0), height: nil)
【🟦】report size: (100.0, 100.0)
【🟨】proposal size: ProposedViewSize(width: Optional(100.0), height: nil)
【🟨】report size: (100.0, 50.0)
おまけ 3 : VStack による各子ビューの柔軟性の測定において、応答の幅は測定に使用されないことを立証(?)するコード

下記のコードのみで「VStack による各子ビューの柔軟性の測定において、応答の幅は測定に使用されない」と結論づけるのは早計かもしれませんが、一応検証の際に使用したコードを載せておきます。

Code
struct RedRectangleProxy: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        switch proposal {
        case .init(width: 300, height: .zero): .init(width: 90, height: 100)
        case .init(width: 300, height: .infinity): .init(width: 110, height: 100)
        default: .init(width: 100, height: 100)
        }
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        subviews.first?.place(at: bounds.origin, proposal: proposal)
    }
}

struct ContentView: View {
    var body: some View {
        VStack(spacing: .zero) {
            RedRectangleProxy {
                Rectangle()
                    .fill(.red)
                    .frame(width: 100, height: 100)
            }
            .inspectLayoutProcess("🟥")
            
            Rectangle()
                .fill(.blue)
                .frame(width: 100, height: 100)
                .inspectLayoutProcess("🟦")
        }
        .frame(width: 300, height: 300)
    }
}
Output
【🟦】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(0.0))
【🟦】report size: (100.0, 100.0)
【🟦】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(inf))
【🟦】report size: (100.0, 100.0)
【🟥】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(0.0))
【🟥】report size: (90.0, 100.0)
【🟥】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(inf))
【🟥】report size: (110.0, 100.0)
【🟥】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(150.0))
【🟥】report size: (100.0, 100.0)
【🟦】proposal size: ProposedViewSize(width: Optional(300.0), height: Optional(200.0))
【🟦】report size: (100.0, 100.0)

Considerations

position: (0.0, 0.0)

ビューが配置されたポジションの出力を有効化しているとき、時折【xxx】position: (0.0, 0.0)のような出力がありますが、特に気にする必要はなさそうです。

Code
struct ContentView: View {
    var body: some View {
        Text("Hello, world.")
            .inspectLayoutProcess("Text", printPosition: true)
            .border(.pink)
    }
}
Output
Text】proposal size: ProposedViewSize(width: Optional(402.0), height: Optional(778.0))Text】report size: (94.33333333333333, 20.333333333333332)Text】position: (0.0, 0.0)Text】position: (153.83333333333334, 440.8333333333333)

layoutPriority

layoutPriority モディファイア適用後に inspectLayoutProcess モディファイアを適用すると、layoutPriority モディファイアの効果は無くなります。

Code
struct ContentView: View {
    var body: some View {
        VStack(spacing: .zero) {
            rectangle(color: .red, minHeight: 200, maxHeight: 250) // (300.0, 200.0)
                .inspectLayoutProcess("🟥")
            rectangle(color: .blue, minHeight: 0, maxHeight: 60) // (300.0, 60.0)
                .inspectLayoutProcess("🟦")
                .layoutPriority(1.0)
                .inspectLayoutProcess("layoutPriority")
        }
        .frame(width: 300, height: 300)
        .border(.primary)
    }
    
    func rectangle(color: Color, minHeight: CGFloat, maxHeight: CGFloat) -> some View {
        Rectangle()
            .fill(color)
            .frame(minHeight: minHeight, maxHeight: maxHeight)
    }
}

ContainerValue & LayoutValue

ContainerValue と LayoutValue を使用している場合は、値が引き継がれないことに注意してください。

ContainerValue
Code
extension ContainerValues {
    @Entry var myContainerValue: Int? = nil
}

struct MyContainerValueChecker: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        guard let subview = subviews.first else {
            return .zero
        }

        print("subview.containerValues.myContainerValue:", subview.containerValues.myContainerValue as Any)

        return subview.sizeThatFits(proposal)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        subviews.first?.place(at: bounds.origin, proposal: proposal)
    }
}

struct ContentView: View {
    var body: some View {
        MyContainerValueChecker {
            Text("Hello, world.")
                .containerValue(\.myContainerValue, 777)
                .inspectLayoutProcess("Text")
        }
    }
}
Output
subview.containerValues.myContainerValue: nilText】proposal size: ProposedViewSize(width: Optional(402.0), height: Optional(778.0))Text】report size: (94.33333333333333, 20.333333333333332)
LayoutValue
Code
struct MyLayoutValue: LayoutValueKey {
    static let defaultValue: Int? = nil
}

struct MyLayoutValueChecker: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        guard let subview = subviews.first else {
            return .zero
        }
        
        print("subview[MyLayoutValue.self]:", subview[MyLayoutValue.self] as Any)
        
        return subview.sizeThatFits(proposal)
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        subviews.first?.place(at: bounds.origin, proposal: proposal)
    }
}

struct ContentView: View {
    var body: some View {
        MyLayoutValueChecker {
            Text("Hello, world.")
                .layoutValue(key: MyLayoutValue.self, value: 777)
                .inspectLayoutProcess("Text")
        }
    }
}
Output:
subview[MyLayoutValue.self]: nilText】proposal size: ProposedViewSize(width: Optional(402.0), height: Optional(778.0))Text】report size: (94.33333333333333, 20.333333333333332)

Discussion