Swift Talk # 318

Inspecting SwiftUI's Layout Process

This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We use SwiftUI's new Layout protocol to inspect the proposed and reported sizes in the layout process.

00:06 A while ago, we reimplemented the SwiftUI layout system to figure out how proposed and reported sizes work. That involved a lot of trial and error, because there was hardly any documentation available. But now, we have the Layout protocol. Not only can this be used to create custom layouts, but we can also repurpose it to find out exactly how sizes are proposed to built-in views, and which sizes they report back.

Sample View

01:16 Let's start with a Text view and play around with the size it gets proposed by using a Slider. We add borders around the Text and around its frame so that we can see how the two sizes relate to each other:

struct ContentView: View {
    @State var proposedSize: CGSize = CGSize(width: 100, height: 100)
    var body: some View {
        VStack {
            Text("Hello, world!")
                .border(Color.red)
                .frame(width: proposedSize.width, height: proposedSize.height)
                .border(Color.green)
            Slider(value: $proposedSize.width, in: 0...300, label: { Text("Width")})
        }
    }
}

02:53 The green border makes the size we propose to the Text view visible. The red border shows the size the Text view actually becomes. As we decrease the proposed width with the slider, we get to the point where the Text view can no longer fit the string on a single line, so it starts to wrap words to the next line. If the available space becomes smaller than a single word, the view even breaks that word to wrap individual letters:

Measuring versus Logging

03:19 Previously, when we wanted to know the precise size of a view, we had to place a geometry reader in an overlay and read the size from the geometry proxy. We could then, for example, display that size in the overlay like so:

extension View {
    func measure() -> some View {
        overlay(GeometryReader { proxy in
            Text("\(Int(proxy.size.width)) × \(Int(proxy.size.height))")
                .foregroundColor(.white)
                .background(.black)
                .font(.footnote)
        })
    }
}

struct ContentView: View {
    @State var proposedSize: CGSize = CGSize(width: 100, height: 100)
    var body: some View {
        VStack {
            Text("Hello, world!")
                .font(.title)
                .measure()
                .border(Color.red)
                .frame(width: proposedSize.width, height: proposedSize.height)
                .border(Color.green)
            Slider(value: $proposedSize.width, in: 0...300, label: { Text("Width")})
        }
    }
}

05:21 Thanks to the Layout protocol, this is no longer the only way we can find out the size of a view — we can wrap the view in a custom layout that logs its calls. We write the LogSizes layout, in which we assert that there's just one subview, and we forward the method calls to sizeThatFits and placeSubviews to that subview, without modifying any of the parameters:

struct LogSizes: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        assert(subviews.count == 1)
        return subviews[0].sizeThatFits(proposal)
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        subviews[0].place(at: bounds.origin, proposal: proposal)
    }
}

07:32 A helper method makes it easier to wrap any view in a LogSizes layout, taking in a label to describe the view we're logging:

extension View {
    func logSizes(_ label: String) -> some View {
        LogSizes(label: label) { self }
    }
}

struct LogSizes: Layout {
    var label: String
    
    // ...
}

struct ContentView: View {
    @State var proposedSize: CGSize = CGSize(width: 100, height: 100)
    var body: some View {
        VStack {
            Text("Hello, world!")
                .font(.title)
                .logSizes("Text")
                .border(Color.red)
                .frame(width: proposedSize.width, height: proposedSize.height)
                .border(Color.green)
            Slider(value: $proposedSize.width, in: 0...300, label: { Text("Width")})
        }
    }
}

08:31 Finally, we add print statements to log the proposed size and the reported size to the console:

struct LogSizes: Layout {
    var label: String
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        assert(subviews.count == 1)
        let result = subviews[0].sizeThatFits(proposal)
        print("\(label): \(proposal) \(result)")
        return result
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        subviews[0].place(at: bounds.origin, proposal: proposal)
    }
}

/*
Text: ProposedViewSize(width: Optional(100.0), height: Optional(100.0)) (74.66666666666, 67.66666666666)
*/

09:25 As we'll be printing a lot of these logs, we want to make them a bit easier to read. We define pretty-printed descriptions for the CGFloat, CGSize, and ProposedSize types. And, since the parameters of a proposed size are optional, we also define a pretty for optional floats, which prints "nil" if there's no value:

extension CGFloat {
    var pretty: String {
        String(format: "%.2f", self)
    }
}

extension CGSize {
    var pretty: String {
        "\(width.pretty)\(height.pretty)"
    }
}

extension Optional where Wrapped == CGFloat {
    var pretty: String {
        self?.pretty ?? "nil"
    }
}

extension ProposedViewSize {
    var pretty: String {
        "\(width.pretty)\(height.pretty)"
    }
}
print("\(label): \(proposal.pretty) \(result.pretty)")

More Views

12:13 Let's look at some more interesting example views, starting with padding:

struct ContentView: View {
    @State var proposedSize: CGSize = CGSize(width: 100, height: 100)
    var body: some View {
        VStack {
            Text("Hello, world!")
                .font(.title)
                .logSizes("Text")
                .padding(10)
                .logSizes("Padding")
                .border(Color.red)
                .frame(width: proposedSize.width, height: proposedSize.height)
                .border(Color.green)
            Slider(value: $proposedSize.width, in: 0...300, label: { Text("Width")})
        }
    }
}

/*
Text: 80.00×80.00 74.67×67.67
Padding: 100.00×100.00 94.67×87.67
*/

13:08 Looking at the console, we can follow along with the layout process by reading the printed sizes clockwise, starting at the bottom left. The padding gets a proposed size of 100 by 100 points. It then subtracts 10 points on all sides, and it proposes a size of 80 by 80 points to the text view. The text view reports back a size of 74 by 67 points, after which the padding reports a size of 94 by 87 points.

13:54 If we separately print the proposed size and the reported size, it makes the order of the layout passes even clearer:

struct LogSizes: Layout {
    // ...
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        assert(subviews.count == 1)
        print("Propose \(label): \(proposal.pretty)")
        let result = subviews[0].sizeThatFits(proposal)
        print("Report \(label): \(result.pretty)")
        return result
    }
    // ...
}

/*
Propose Padding: 100.00×100.00
Propose Text: 80.00×80.00
Report Text: 74.67×67.67
Report Padding: 94.67×87.67
*/

14:42 Next, we add a background to the padded view, and we log the sizes for the background. Inside the background, we also log the sizes of the orange rectangle:

struct ContentView: View {
    @State var proposedSize: CGSize = CGSize(width: 100, height: 100)
    var body: some View {
        VStack {
            Text("Hello, world!")
                .font(.title)
                .logSizes("Text")
                .padding(10)
                .logSizes("Padding")
                .background {
                    Color.orange
                        .logSizes("Orange")
                }
                .logSizes("Background")
                .border(Color.red)
                .frame(width: proposedSize.width, height: proposedSize.height)
                .border(Color.green)
            Slider(value: $proposedSize.width, in: 0...300, label: { Text("Width")})
        }
    }
}

/*
Propose Background: 100.00⨉100.00
Propose Padding: 100.00⨉100.00
Propose Text: 80.00⨉80.00
Report Text: 74.67⨉67.67
Report Padding: 94.67⨉87.67
Report Background: 94.67⨉87.67
Propose Orange: 94.67⨉87.67
Report Orange: 94.67⨉87.67
*/

15:37 The background, which is the outer view, forwards the proposed size to the padding without modifying it. It also reports back the same size as the padding. After the background is laid out, the orange shape inside gets proposed the size of the background and, because it's a Shape, it happily accepts that size.

16:18 When we add a fixed-sized frame around the orange shape, we can see that the background still only becomes as large as the primary content — i.e. the padded text view — but the orange shape gets its size from the frame, and it's centered to the primary content:

struct ContentView: View {
    @State var proposedSize: CGSize = CGSize(width: 100, height: 100)
    var body: some View {
        VStack {
            Text("Hello, world!")
                .font(.title)
                .logSizes("Text")
                .padding(10)
                .logSizes("Padding")
                .background {
                    Color.orange
                        .frame(width: 200, height: 200)
                        .logSizes("Orange")
                }
                .logSizes("Background")
                .border(Color.red)
                .frame(width: proposedSize.width, height: proposedSize.height)
                .border(Color.green)
            Slider(value: $proposedSize.width, in: 0...300, label: { Text("Width")})
        }
    }
}

/*
Propose Background: 100.00⨉100.00
Propose Padding: 100.00⨉100.00
Propose Text: 80.00⨉80.00
Report Text: 74.67⨉67.67
Report Padding: 94.67⨉87.67
Report Background: 94.67⨉87.67
Propose Orange: 94.67⨉87.67
Report Orange: 200.00⨉200.00
*/

Next

16:58 If we had this type of logging before, our lives would've been so much easier. Until now, we could only inspect the reported size of a view, but we had to guess the size that was proposed to it.

17:43 From here, we can do various things. We can improve how we display the logged size, perhaps by integrating it into the view, so we don't have to look at the console anymore. And we can also look at more interesting views, like stacks or grids.

Resources

  • Sample Code

    Written in Swift 5.6

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

85 Episodes · 30h35min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes