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 return to a five year old layout challenge that we can now solve using the layout protocol.

00:06 A while back — about five years ago! — we asked for challenging Swift layout problems and we dug up an old tweet that responded to that question. It presents a really interesting layout that still isn't entirely straightforward to implement.

00:35 The example shows a three-part view: two text labels and a bar. The large text is leading-aligned with the bar, the smaller text is trailing-aligned, and the bar's width is relative to the container width. If the bar isn't wide enough to fit both texts naturally, the labels should be positioned side by side instead:

01:12 We could add some more constraints. For example, if only a limited width is available and the texts together exceed that width, we'd have to truncate one or both of the texts, and we could make that behavior configurable.

01:28 This question predates SwiftUI's Layout protocol. Back then, we would have relied on preferences, geometry readers, and similar techniques, which are doable but quite complex. Today, the right approach is to implement this as a custom layout. That allows us to fully leverage layout priorities, alignment, layout values, and everything the layout system provides.

02:17 Getting started with SwiftUI and custom layouts is fairly approachable, but doing things correctly can still be tricky. This is a good example of what they call "progressive disclosure" in SwiftUI's API design.

Setting Up

02:45 We begin by creating two Text views and a bar with a fixed height. A simple HStack with a Spacer seems like a good starting point, but it quickly breaks down when the red bar is smaller than the combined width of the text views:

struct ContentView: View {
    var body: some View {
        VStack(alignment: .leading) {
            HStack {
                Text("Leading Label")
                Spacer()
                Text("Trailing Label")
            }
            Color.red
                .frame(height: 8)
                .frame(width: 150)
        }
        .padding()
    }
}

03:56 We can try alternative approaches, like putting the HStack in an overlay on top of the bar so they share the same width, but then it'd be difficult to make the text views overflow when the bar becomes too small. Ultimately, we need a custom layout. That way, we can encapsulate the behavior cleanly and provide a simple API: just pass in two labels and a bar, and let the layout handle the positioning.

Creating the Custom Layout

04:39 We write a new struct and conform it to the Layout protocol, which requires two methods: sizeThatFits and placeSubviews. In sizeThatFits, we calculate the total size by asking each subview for its size and combining the results.

05:36 We begin by asserting that exactly three subviews are provided, since our layout depends on that assumption. Next, we compute the size of each subview by calling sizeThatFits on it, for now just passing along the proposal as we receive it:

struct BarLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        assert(subviews.count == 3)
        let label1Size = subviews[0].sizeThatFits(proposal)
        let label2Size = subviews[1].sizeThatFits(proposal)
        let barSize = subviews[2].sizeThatFits(proposal)


        return .zero
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {


    }
}

07:26 With all sizes available, we can start computing positions for the three views. We keep track of a currentPoint as we step through the layout progress, starting at .zero. This way, we compute a frame for each of the views, without worrying about adding spacing between the views yet:

struct BarLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        assert(subviews.count == 3)
        let label1Size = subviews[0].sizeThatFits(proposal)
        let label2Size = subviews[1].sizeThatFits(proposal)
        let barSize = subviews[2].sizeThatFits(proposal)
        var currentPoint = startingPoint
        let label1Frame = CGRect(origin: currentPoint, size: label1Size)
        currentPoint.x += label1Size.width
        var label2Frame = CGRect(origin: currentPoint, size: label2Size)
        currentPoint.x = 0
        currentPoint.y = max(label1Frame.maxY, label2Frame.maxY)
        let barFrame = CGRect(origin: currentPoint, size: barSize)
        // ...
    }
    // ...
}

11:00 We then compute the overall size by taking the union of all frames:

struct BarLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        // ...
        return [label1Frame, label2Frame, barFrame].reduce(CGRect.null) {
            $0.union($1)
        }.size
    }
    // ...
}

11:53 To avoid duplicating logic, we extract this into a helper function, computeFrames, that returns an array of CGRects. We call this function from sizeThatFits, and later reuse it in placeSubviews:

struct BarLayout: Layout {
    func computeFrames(proposal: ProposedViewSize, subviews: Subviews) -> [CGRect] {
        assert(subviews.count == 3)
        let label1Size = subviews[0].sizeThatFits(proposal)
        let label2Size = subviews[1].sizeThatFits(proposal)
        let barSize = subviews[2].sizeThatFits(proposal)
        var currentPoint = CGPoint.zero
        let label1Frame = CGRect(origin: currentPoint, size: label1Size)
        currentPoint.x += label1Size.width
        var label2Frame = CGRect(origin: currentPoint, size: label2Size)
        currentPoint.x = 0
        currentPoint.y = max(label1Frame.maxY, label2Frame.maxY)
        let barFrame = CGRect(origin: currentPoint, size: barSize)
        return [label1Frame, label2Frame, barFrame]
    }

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let frames = computeFrames(proposal: proposal, subviews: subviews)
        return frames.reduce(CGRect.null) { $0.union($1) }.size
    }
    // ...
}

13:01 Next, we implement placeSubviews by recomputing the frames and placing each subview at its corresponding origin:

struct BarLayout: Layout {
    func computeFrames(proposal: ProposedViewSize, subviews: Subviews) -> [CGRect] {
        // ...
    }

    // ...

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let frames = self.computeFrames(proposal: proposal, subviews: subviews)
        for (view, frame) in zip(subviews, frames) {
            view.place(at: frame.origin, proposal: proposal)
        }
    }
}

13:44 We can now apply the new layout in ContentView by wrapping our three subviews in it:

struct ContentView: View {
    var body: some View {
        BarLayout {
            Text("Leading Label")
            Text("Trailing Label")
            Color.red
                .frame(height: 8)
                .frame(width: 150)
        }
        .padding()
    }
}

Adjusting the Second Label

13:54 At this point, the layout compiles, but it doesn't yet behave correctly — it simply places the text views next to each other without respecting the intended design. We also notice an issue with positioning because we aren't taking the bounds passed into placeSubviews into account. Instead of starting at CGPoint.zero, we need to place our layout at the origin of these bounds. We add a parameter to computeFrames, so that we can pass this origin in:

struct BarLayout: Layout {
    func computeFrames(at startingPoint: CGPoint, proposal: ProposedViewSize, subviews: Subviews) -> [CGRect] {
        assert(subviews.count == 3)
        let label1Size = subviews[0].sizeThatFits(proposal)
        let label2Size = subviews[1].sizeThatFits(proposal)
        let barSize = subviews[2].sizeThatFits(proposal)
        var currentPoint = startingPoint
        let label1Frame = CGRect(origin: currentPoint, size: label1Size)
        currentPoint.x += label1Size.width
        var label2Frame = CGRect(origin: currentPoint, size: label2Size)
        currentPoint.x = startingPoint.x
        currentPoint.y = max(label1Frame.maxY, label2Frame.maxY)
        let barFrame = CGRect(origin: currentPoint, size: barSize)
        label2Frame.maxX = max(barFrame.maxX, label2Frame.maxX)
        return [label1Frame, label2Frame, barFrame]
    }

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let frames = computeFrames(at: .zero, proposal: proposal, subviews: subviews)
        return frames.reduce(CGRect.null) { $0.union($1) }.size
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let frames = self.computeFrames(at: bounds.origin, proposal: proposal, subviews: subviews)
        for (view, frame) in zip(subviews, frames) {
            view.place(at: frame.origin, proposal: proposal)
        }
    }
}

15:57 After fixing some small bugs to properly use the startingPoint as the layout's origin, we see that it works as long as the bar is narrower than the two text views. When the bar becomes wider than the labels, we need to introduce spacing between the labels to align the second label to the trailing edge of the bar.

16:39 We want to adjust the second label's frame by setting its maxX to be equal to the bar's maxX. We override maxX in an extension of CGRect, so that we can add a setter to the property:

extension CGRect {
    var maxX: CGFloat {
        get { minX + width }
        set { origin.x = newValue - width }
    }
}

18:03 We only want to adjust the label's frame if the bar is wide enough, because otherwise the two labels would overlap each other when the bar becomes smaller than the texts:

struct BarLayout: Layout {
    func computeFrames(at startingPoint: CGPoint, proposal: ProposedViewSize, subviews: Subviews) -> [CGRect] {
        // ...
        let barFrame = CGRect(origin: currentPoint, size: barSize)
        if barFrame.maxX > label2Frame.maxX {
            label2Frame.maxX = max(barFrame.maxX, label2Frame.maxX)
        }
        return [label1Frame, label2Frame, barFrame]
    }
    // ...
}

19:02 Or, we can avoid the conditional statement by taking the maximum of the two frames' maxX:

struct BarLayout: Layout {
    func computeFrames(at startingPoint: CGPoint, proposal: ProposedViewSize, subviews: Subviews) -> [CGRect] {
        // ...
        let barFrame = CGRect(origin: currentPoint, size: barSize)
        label2Frame.maxX = max(barFrame.maxX, label2Frame.maxX)
        return [label1Frame, label2Frame, barFrame]
    }
    // ...
}

19:24 This removes the overlap when the bar is small and it adds space between the labels when the bar is wide, which is the desired behavior:

Next Steps

19:32 To better visualize the layout in different setups, we add sliders to control both the total width and the bar's width:

struct ContentView: View {
    @State private var width: CGFloat = 200
    @State private var barWidth: CGFloat = 150
    
    var body: some View {
        VStack {
            Slider(value: $width, in: 0...350)
            Slider(value: $barWidth, in: 0...350)
            BarLayout {
                Text("Leading Label")
                Text("Trailing Label")
                Color.red
                    .frame(height: 8)
                    .frame(width: barWidth)
            }
            .border(.blue)
            .frame(width: width)
            .padding()
        }
    }
}

21:02 When reducing the available width, both labels get truncated because we propose the same, total size to all subviews.

21:43 In the next episode, we should improve the size proposal logic. It'd also be useful to define the bar width more declaratively, perhaps as a relative value like a percentage of the total width. We can also think about supporting multiline text and alignment of labels with different font sizes.

Resources

  • Sample Code

    Written in Swift 6.2

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

215 Episodes · 75h19min

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