Proportional layout with SwiftUI

The power of custom layouts

published on January 25, 2025

Starting from iOS 16, SwiftUI gives us the option to build custom layouts that can control very precisely where each subview should be placed. We have the option to build layouts that can be used just as the VStack, HStack or any other built in Layout you may be familiar with from SwiftUI.

This new tool creates an opportunity for us to really easily create layouts that size their subviews proportionally to its own dimensions, similar to flex box in CSS

Before custom Layout

First, let’s see how would we implement something like this without using any custom layouts. The key for achieving the desired results is the GeometryReader Using it, SwiftUI exposes the available space to our view hierarchy and makes it possible for us to do calculations based on it. Let’s see the code

Group {
    GeometryReader { proxy in
        HStack(spacing: 0) {
            Color.red.frame(width: proxy.size.width * 0.5)
            Color.green.frame(width: proxy.size.width * 0.2)
            Color.blue.frame(width: proxy.size.width * 0.3)
        }
    }
}
.padding()
.frame(width: 300, height: 300)

As you can see, this solution works, but there are a couple of issues

  1. As you may already know, Geometry reader tries to take up as much space as possible thus making it more complicated to achieve UIs that adapt well to different screen sizes and content sizes
  2. This solution is rather verbose and it would be pretty challenging to encapsulate this in an easily reusable component

Container values

Before we look at the custom layout we are making, let’s talk about container values real quick. Container values were introduced in iOS 18 and are meant to provide a way for passing values from the different views back to the containers, AKA our layout. To define a container value, you can use the @Entry property wrapper.

extension ContainerValues {
    @Entry var proportion: Double = 1
}

public extension View {
    func proportion(_ value: Double) -> some View {
        self.containerValue(\.proportion, value)
    }
}

Let’s build the container

Finally let’s build our proportional stack. Start by creating a new layout

public struct ProportionalStack: Layout {
    //...
}

The layout protocol requires two methods to be implemented. First add sizeThatFits. This method is called to determine the optimal size that works both for your layout and its surrounding. In this method you could provide any special logic, but for now we just accept the proposed size.

public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    return proposal.replacingUnspecifiedDimensions()
}

Notice the replacingUnspecifiedDimensions which is used to replace any nil values in the proposal by a non nil value

The second method to implement is placeplaceSubviews. This is where we can perform the actual logic for placing the subviews and sizing them according to the given proportion

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

We will use 1 as the default value for cases when no proportion is provided, to calculate the final factor for each view, we will need to calculate the sum of all proportion values then divide each views proportion with the sum

let totalSizeFactor = subviews.map(\.containerValues.proportion).reduce(0, +)
var origin = bounds.origin

for view in subviews {
    let proportion = view.containerValues.proportion / totalSizeFactor
    // ... 
}

Finally we can place our view to the screen. To keep track of where each view ends, we are going to use the origin variable

    let size = CGSzie(width: bounds.size.width * proportion, height: bounds.size.height)
    view.place(at: origin, proposal: .init(size))
    origin.x += size.width

That’s it! Our layout is ready to be used

ProportionalStack(direction: .horizontal) {
    Color.red.proportion(3)
    Color.green
    Color.blue.proportion(2)
}
.frame(width: 300, height: 300)

Next steps

This layout can be extended to work in both direction (horizontal and vertical) or to fill the least amount of space the subviews need instead of just accepting the proposed size.

To see the full code or to start using the layout as a swift package, check out my repository

Follow me on X