The SwiftUI Layout protocol is a powerful tool for defining your own complex layouts. It is new with iOS 16. Previously, you had to become creative if the capabilities of HStack, VStack, ZStack and Grid were not enough for your layouting needs.

When the iOS 16 beta SDKs came out, I read a bit about this new protocol but never had any real need for it — until recently.

Requirements of the layout

My goal was to create a horizontal layout similar to HStack that would overlap its children a bit. Its intended for a list of image views. If, however, the available horizontal space was not enough to fit all children, the layout should increase the amount of overlap to make them fit.

I wanted to layout to be as dynamic as possible and to not assume anything about its children. For simplicity’s sake, though, in the final implementation it assumes that all children are of the same size.

In Figma, the design looked like this:

Figma design

Easy enough, I thought. During the implementation, I realized that overlapping views in SwiftUI had always meant some kind of weird hack in the past and that it might not be done with a simple HStack. Luckly, I remembered that I had saved this awesome in-depth article about the SwiftUI Layout protocol and now I had a reason to work through it.

The result is what I got from reading the first part of the article. In its second part, more advanced topics like caching and custom animations are discussed. The following layout does not pay attention to those features in its implementation. I think it is still a simple example on how to use the SwiftUI Layout protocol.

Defining the basics

We first define this new layout. The idea is that all views overlap each other by 6 points by default.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import SwiftUI

struct OverlappingHStack {

    let overlap: CGFloat

    init(overlap: CGFloat = 6) {
        self.overlap = overlap
    }

}

To implement the basics of the Layout protocol, we need to implement sizeThatFits(proposal:subviews:cache:) and placeSubviews(in:proposal:subviews:cache:). Here is a simple implementation that places all views next to each other without spacing. This is based on the previously mentioned article’s implementation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
extension OverlappingHStack: Layout {

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let idealSizes = subviews.map { $0.sizeThatFits(.unspecified) }

        let width = idealSizes.reduce(0) { $0 + $1.width }
        let height = idealSizes.reduce(0) { max($0, $1.height) }

        return CGSize(width: width, height: height)
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        var location = bounds.origin

        for subview in subviews {
            subview.place(
                at: location,
                proposal: .unspecified
            )

            location.x += subview.sizeThatFits(.unspecified).width
        }
    }

}

This code will result in the following layout, leaving us with a feature-less HStack implementation.

Step 1

Sidenote: As we are implementing the characteristics of a stack, we also define the layoutProperties property. The LayoutProperties type does not provide any other properties.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
extension OverlappingHStack {

    static var layoutProperties: LayoutProperties {
        var properties = LayoutProperties()

        properties.stackOrientation = .horizontal

        return properties
    }

}

Overlapping our subviews

Coming back to our layout code, we now begin to use the overlap value specified to us in the type’s definition. Basically, we decrease the offset amount for each view by the amount of overlap:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
extension OverlappingHStack: Layout {

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let idealSizes = subviews.map { $0.sizeThatFits(.unspecified) }

        let width = idealSizes.reduce(0) { $0 + $1.width }
        let totalOverlap = CGFloat(subviews.count - 1) * overlap // Overlap between the views

        let height = idealSizes.reduce(0) { max($0, $1.height) }

        return CGSize(width: width - totalOverlap, height: height) // the overlap amounts reduce our total width
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        var location = bounds.origin

        for subview in subviews {
            subview.place(
                at: location,
                proposal: .unspecified
            )

            location.x += subview.sizeThatFits(.unspecified).width - overlap // reduce the step size
        }
    }

}

Easy, we now have overlapping views within our layout. This was the easy part.

Step 2

Increasing the number of subviews while restricting the available width, we can see that our layout implementation does not care. It still asks its subviews for their optimal sizes and builds the layout structure from that, without care for the environment its in.

Step 2 with many items

Increasing overlap for constrained width

Now this is where I spent most time during my implementation of the layout.

First thing to do is unify the code that determines the amount of space the layout wants to use and the implementation of the placing of the views itself. Otherwise, you need to be careful not to use a different amount of space that you requested.

Second, we need to calculate the total width that we need for our subviews minus the overlap and compare that against the maximum available width, if applicable. The latter part is what our layout code currently does not do and also the part where the logic might get complicated. This is because the layout will be queried for different sets of constraints to see what sizes it would take. Basically, SwiftUI is probing us to find out the best layout possible. Sometimes, this includes a fixed width value, other times it will be for the minimum or the maximum size.

Calculating the total layout size with dynamic overlap

Let’s first take a look at the sizeThatFits. Implementation details for size(of:in:for:)? and totalOverlap(for:on:) are described in the last section. The calculation of the layout size is more complicated but should be straight forward.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// MARK: - SizeThatFits

extension OverlappingHStack {

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        // Query the view sizes, similar as before.
        let subviewSizes = subviews.map {
            size(
                of: $0,
                in: subviews,
                for: proposal
            )
        }
        let totalSubviewsWidth = subviewSizes.reduce(0, { $0 + $1.width })

        // Calculate the total overlap of our views, based on the proposed available width,
        // falling back to the total width required by our subviews.
        // Giving it the width is important to let the function increase the overlap to fit
        // all subviews.
        let viewOverlap = totalOverlap(
            for: subviews,
            on: specificValue(for: proposal.width) ?? totalSubviewsWidth
        )

        // Our layout width is still the total width without overlap.
        var width = -viewOverlap + totalSubviewsWidth
        if let proposalWidth = specificValue(for: proposal.width) {
            // If we have a maximum width specified, we use the smaller of the two width values.
            width = min(width, proposalWidth)
        }
        let height = subviewSizes.reduce(0, { max($0, $1.height) })

        return CGSize(
            width: width,
            height: height
        )
    }

    private func specificValue(for value: CGFloat?) -> CGFloat? {
        // This function basically only returns exact values.
        // For requests to minimum (0) and maximum (.infinity) sizes,
        // it also returns `nil`.
        guard let value,
              value > 0,
              value < .infinity
        else {
            return nil
        }
        return value
    }

}

Placing views with dynamic overlap

Next, let’s take a look at the actual placing of the subviews. Once SwiftUI has decided on the layout’s size, we can determine what our actual overlap needs to be for the available width. The placing of the subviews is similar to what we had before. We use a few helper functions that calculate the width of each view and the overlap needed to fit all views within the available space. Implementation of those functions is described in the next section.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// MARK: - PlaceSubviews

extension OverlappingHStack {

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        // Calculate the proposed width for every subview. Its the same width
        // for every view.
        // Reminder: `0` is a minimum width.
        let viewWidth = proposeSubviewWidth(
            for: subviews,
            with: bounds.width
        ) ?? 0
        let subviewProposedSize = ProposedViewSize(
            width: viewWidth,
            height: proposal.height
        )

        // Calculates the overlap needed for distributing all the views within
        // the given width.
        let layoutOverlap = overlap(
            forWidth: bounds.width,
            distributing: subviews,
            proposedViewSize: subviewProposedSize
        )

        // We start the the top-left (on iOS).
        var location = bounds.origin
        for view in subviews {
            // Place the view, giving it our proposed size.
            view.place(
                at: location,
                anchor: .topLeading,
                proposal: subviewProposedSize
            )

            let width = view.dimensions(in: subviewProposedSize).width
            // We increase the next view's horizontal position by the current
            // view's width, minus the overlap value.
            location.x += width - layoutOverlap
        }
    }

}

Calculating common values before and during the placement of views

Previously, I mentioned common helper functions for calculating view sizes and overlap. These are described in this section.

On a high level, we have functions that calculate a subview’s size and another set of functions for calculating the overlap of views given a particular width.

  • size(of:in:for:) is used for calculating a subview’s exact size given the proposed size for the entire layout. It will distribute the available width evenly between all subviews and return the size that the subview determines best for that configuration.

  • proposeSubviewWidth(for:with:) proposes a subview’s width. If the proposed width from the layout is no exact value, it will simply return the same value. If we have a exact width value, then we add the overlap to the available width and divide it by the number of subviews.

  • totalOverlap(for:on:) calculates the total overlap that all views have between them. Simple.

  • overlap(forWidth:distributing:proposedViewSize:) takes the total width of all subviews and reduces it by the amount of overlap between each subview. The value is reduce twice because the overlap allows both views’ widths to be neglected by that amount.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// MARK: - Proposed subview size

private extension OverlappingHStack {

    func size(
        of view: Subviews.Element,
        in subviews: Subviews,
        for proposal: ProposedViewSize
    ) -> CGSize {
        let width = proposeSubviewWidth(
            for: subviews,
            with: proposal.width
        )
        let subviewProposedSize = ProposedViewSize(
            width: width,
            height: proposal.height
        )

        return view.sizeThatFits(subviewProposedSize)
    }

    func proposeSubviewWidth(
        for subviews: Subviews,
        with proposedWidth: CGFloat?
    ) -> CGFloat? {
        guard let proposedWidth,
              proposedWidth != 0,
              proposedWidth != .infinity
        else {
            return proposedWidth
        }

        let availableWidth = proposedWidth + totalOverlap(
            for: subviews,
            on: proposedWidth
        )
        return availableWidth / CGFloat(subviews.count)
    }

}

// MARK: - Subview overlap

private extension OverlappingHStack {

    func totalOverlap(
        for subviews: Subviews,
        on totalWidth: CGFloat
    ) -> CGFloat {
        overlap(
            forWidth: totalWidth,
            distributing: subviews
        ) * ( CGFloat(subviews.count) - 1 )
    }

    func overlap(
        forWidth width: CGFloat,
        distributing subviews: Subviews,
        proposedViewSize: ProposedViewSize = .unspecified
    ) -> CGFloat {
        let totalViewsWidth = subviews.map {
            size(
                of: $0,
                in: subviews,
                for: proposedViewSize
            )
        }.reduce(0, { $0 + $1.width })
        let totalWidth = totalViewsWidth - 2 * overlap * CGFloat(subviews.count - 1)

        if totalWidth >= totalViewsWidth {
            return overlap
        }
        let difference = totalViewsWidth - totalWidth
        return overlap + difference / CGFloat(subviews.count)
    }

}

Results

Now, we can show a few views next to each other. Each one is slightly overlapping its predecessor.

Result 1

If we restricts the available width, the layout will dynamically increase the overlap to allow all children to fit within the container. Notice how the views are resized as well. All views are of the same size still.

Result 2

We can even fit in a Spacer that will take up the same space as our children!

Result 3

I know this is neither the most exciting layout idea out there and the implementation might not be the best there is. Still, I think it shows the flexibility and ease of use on the API level. I am excited to use it again should I come up against another demanding layout. Apple is creating new ways to build our interfaces each year so maybe there’s even more coming at WWDC2023.