Exploring SwiftUI Layout Protocol | Creating Custom Layout

Apple introduces new SwiftUI's Layout protocol with the release of iOS 16. It is a powerful tool for constructing custom views with SwiftUI elegance.

Alex Dremov
Exploring SwiftUI Layout Protocol | Creating Custom Layout

Apple introduces a new SwiftUI Layout protocol with the release of iOS 16. It is a powerful tool for constructing custom views with SwiftUI elegance. In this post, I will cover what Layout is and how it can be used.

In the end, we will construct a custom table view that auto-arranges its subviews. Complete code is provided!

Conforming to Layout

The discussed Layout is a new protocol that allows you to select a way of arranging your views.

Through it, you literally can say at what coordinates you want to place subviews. For example, now HStack, VStack, and ZStack can easily be implemented through it in iOS 16.

protocol Layout : Animatable

To conform to the protocol, you need to define two methods

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
)

You also can define makeCache(subviews:) if your layout has some calculations that do not depend on a proposal and depend only on subviews. Then, you can make your calculations in makeCache(subviews:) and then use these values.

Method sizeThatFits

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

Returns a size that indicates how much space the container needs to arrange its subviews. SwiftUI can call this method several times, probing your view and finally deciding the best option

💥
Only finite sizes can be returned. Returning size with infinite coordinate results in a crash without a reasonable call stack, so keep attention to sizes that you return

To calculate it, you can use passed arguments:

proposal

Basically, it's SwiftUI's proposal for your view's size. I like to think about it as a negotiation.

I can give you this much space. What's your size is going to be? Will you even fit?

— SwiftUI negotiator

ProposedViewSize is like a CGSize that also can have some specific values.

  • The zero proposal; the view responds with its minimum size.
  • The infinity proposal; the view responds with its maximum size.
  • The unspecified proposal; the view responds with its ideal size.

You can also access width and height of proposal if it is not of the above values.

The proposal can have one dimension fixed and the second one as nil. For example, an HStack might measure the flexibility of its subviews’ widths, while using a fixed value for the height.

subviews

It is just a container of subviews' proxies LayoutSubview. Through it, you can ask subviews about their size, and also give them your proposal

Dear subview, I give you this much space. What's your size is going to be?

— Custom Layout negotiator

You can ask for subview size through

func sizeThatFits(ProposedViewSize) -> CGSize

and

func dimensions(in: ProposedViewSize) -> ViewDimensions

cache

It is a cache provided by your makeCache(subviews:) function. It also can be Void (no cache).

Method placeSubviews

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

It's where the magic happens. In this method (and only this) you are given bounds for your view and subviews for your disposal.

To place subviews, you need to call place method on subviews elements.

func place(
    at position: CGPoint,
    anchor: UnitPoint = .topLeading,
    proposal: ProposedViewSize
)

The definition is pretty self-explanatory. For every subview, you need to specify a point to place it, an anchor for this point, and your proposal for the selected subview.

bounds

It's bounds for your view to use. It is one of your sizeThatFits outputs.

💡
While it is named bounds, it is actually frame. So, the origin point is also specified and you need to arrange subviews with respect to that

proposal

The size proposal from which the container generated the size that the parent used to create the bounds parameter.

About caching

You may not use it, but usually, some subviews-concerned calculations can be cached which is a good practice and great for performance.

When subviews are changed, func updateCache(inout Self.Cache, subviews: Self.Subviews) is called. Its default implementation is just to call makeCache(subviews:).

Creating auto-filled table

SwiftUI has a Grid to construct table-like structures, but what if you have an unknown number of subviews? Then, you need to construct GridRow somehow correctly.

Let's better use the new Layout protocol feature!

Subscribe and don't miss posts!

Calculating sizes

Deciding what size the result view will have is relatively simple.

public func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
    
        let subviewProposal = getSubviewProposal(
            subviewsCount: subviews.count,
            from: proposal
        )
        
        let rowHeights = getRowHeights(
        	subviews: subviews,
            globalProposal: proposal
        )
        
        let resultWidth = proposal.width ?? 
        		((subviewProposal.width ?? 0) * CGFloat(columnsNumber))
        return CGSize(
            width: resultWidth,
            height: rowHeights.reduce(0, +)
        )
    }

It uses several helper-functions

/**
 Get array of heights for every row.
 Just get max height on every row
 */
private func getRowHeights(subviews: Subviews, subviewProposal: ProposedViewSize) -> [CGFloat] {
    var subviewProposalNoHLimit = subviewProposal
    subviewProposalNoHLimit.height = .infinity
    
    var rowHeights = [CGFloat]()
    var index = 0
    while index < subviews.count {
        var rowMax: CGFloat = 0
        for _ in 0..<columnsNumber where index < subviews.count {
            let size = subviews[index].sizeThatFits(subviewProposalNoHLimit)
            rowMax = max(rowMax, size.height)
            index += 1
        }
        rowHeights.append(rowMax)
    }
    return rowHeights
}

/**
 Calculates proposal for subview — one cell in table
 */
func getSubviewProposal(subviewsCount: Int, from globalProposal: ProposedViewSize) -> ProposedViewSize {
    let rowHeight = max(ceil(Double(subviewsCount / columnsNumber)), 1)
    return ProposedViewSize(
        width: (globalProposal.width ?? 0)
                        / CGFloat(columnsNumber),
        height: (globalProposal.height ?? 0) / rowHeight
    )
}

Placing subviews

Finally, we just need to carefully place views on their places. Just iterating over subviews and calculating their x and y position.

public func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout ()
) {
    var subviewProposal = getSubviewProposal(
        subviewsCount: subviews.count,
        from: proposal
    )
    let colRealWidth = subviewProposal.width ?? 0
    let rowHeights = getRowHeights(subviews: subviews, subviewProposal: subviewProposal)
    
    var curPos: CGFloat = bounds.minX
    var curHeight: CGFloat = bounds.minY
    
    var rowIndex = 0
    for (index, subview) in subviews.enumerated() {
        subviewProposal.height = rowHeights[rowIndex]
        let size = subview.dimensions(in: subviewProposal)
        
        subview.place(
            at: CGPoint(x: curPos, y: curHeight),
            anchor: .topLeading,
            proposal: subviewProposal
        )
        
        if index % columnsNumber == columnsNumber - 1 {
            curPos = bounds.minX
            curHeight += rowHeights[rowIndex]
            rowIndex += 1
        } else {
        	curPos += colRealWidth
        }
    }
}

Example

Now, we can construct a table with the needed number of columns as easy as just a regular view.

ColumnsLayout(columnsNumber: 2) {
    VStack {
        Text("That's one view")
        Image(systemName: "tortoise.fill")
    }
    .padding()
    .border(.red)
    Text("That's the second view ")
        .padding()
        .border(.red)
    Text("That's the third view with long lines that are warped automatically")
        .fixedSize(horizontal: false, vertical: true)
        .padding()
        .border(.red)
}
.border(.blue)
.padding()

And it magically re-assembles after changing the number of columns to three.

Final notes

I believe that you see how powerful this tool is. For example, Apple creates a radial view in their example with Layout protocol.

So, it's only up to you how to place views inside your container and it's finally a room of flexibility so needed for SwiftUI in iOS 16.

Let me know what you think about it in the comments!

Share

Subscribe to Alex Dremov

Get the email newsletter and receive valuable tips to bump up your professional skills