Post

Creating custom layouts in SwiftUI

Creating custom layouts in SwiftUI

Basics

SwiftUI offers the Layout protocol, enabling the precise positioning of subviews during the layout phase. This approach is notably more efficient than relying on GeometryReader to determine sizes and arrange views accordingly. The protocol has two required functions to implement, sizeThatFits and placeSubviews.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
public protocol Layout: Animatable {

    /// Returns the size of the composite view, 
    /// given a proposed size and the view's subviews.
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Self.Subviews,
        cache: inout Self.Cache
    ) -> CGSize

    /// Assigns positions to each of the layout's subviews.
    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Self.Subviews,
        cache: inout Self.Cache
    )
}

In SwiftUI, the child view determines the size it requires, treating the parent’s proposed size more as a suggestion that can be disregarded. The sizeThatFits function is invoked with a suggested size, and you return the total size necessary to arrange all subviews within your custom layout container. SwiftUI then calls placeSubviews to tell your custom layout container to place its subviews.

You might question why these operations are not consolidated into a single function. That is because the parent might call this method more than once during a layout pass with different proposed sizes to test the flexibility of the container, using proposals like:

  • The zero proposal; respond with the layout’s minimum size.
  • The infinity proposal; respond with the layout’s maximum size.
  • The unspecified proposal; respond with the layout’s ideal size.

Creating a Flow layout

One feature absent in SwiftUI is a flow layout. Flow layout operates similarly to a HStack, but it automatically moves to the next line when views fill the available horizontal space. Below is a simple implementation lacking adjustable spacing.

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
struct MyView: View {

    var body: some View {
        ScrollView {
            FlowLayout {
                ForEach(0..<150, id: \.self) {
                    Text("\($0)")
                        .padding()
                        .background(.regularMaterial)
                        .clipShape(.capsule)
                }
            }
        }
    }
}

struct FlowLayout: Layout {

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

        return dryRun(
            containerWidth: containerWidth,
            viewSizes: idealViewSizes
        ).containerSize
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let idealViewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
        let viewOffsets = dryRun(
            containerWidth: bounds.width,
            viewSizes: idealViewSizes
        ).viewOffsets

        for (view, offset) in zip(subviews, viewOffsets) {
            view.place(
                at: .init(
                    x: bounds.minX + offset.x,
                    y: bounds.minY + offset.y
                ),
                proposal: .unspecified
            )
        }
    }

    private func dryRun(containerWidth: CGFloat, viewSizes: [CGSize]) -> (containerSize: CGSize, viewOffsets: [CGPoint]) {
        var containerSize: CGSize = .zero
        var viewOffsets: [CGPoint] = []

        var currentOffset: CGPoint = .zero
        var lineHeight: CGFloat = .zero

        for viewSize in viewSizes {
            if currentOffset.x + viewSize.width > containerWidth {
                currentOffset.x = 0
                currentOffset.y += lineHeight
                lineHeight = 0
            }
            viewOffsets.append(currentOffset)

            currentOffset.x += viewSize.width
            lineHeight = max(lineHeight, viewSize.height)

            containerSize.width = max(containerSize.width, currentOffset.x)
            containerSize.height = max(containerSize.height, currentOffset.y + lineHeight)
        }
        return (containerSize, viewOffsets)
    }
}

Switching layout container

Another benefit is that we can leverage AnyLayout to enable dynamically changing the type of a layout container without destroying the state of the subviews. And we can even animate the change, like this:

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
struct MyView: View {

    @State private var layoutType: LayoutType = .hStack

    var body: some View {
        VStack {
            Picker("Layout Type", selection: $layoutType) {
                ForEach(LayoutType.allCases) {
                    Text($0.rawValue)
                }
            }
            .pickerStyle(.segmented)

            AnyLayout(layoutType.layout) {
                ForEach(0..<10, id: \.self) {
                    Text("\($0)")
                        .fixedSize()
                        .padding()
                        .background(.regularMaterial)
                        .clipShape(.capsule)
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
            .animation(.default, value: layoutType)
        }
    }

    enum LayoutType: String, CaseIterable, Hashable, Identifiable {
        case hStack
        case vStack
        case zStack
        case flowLayout

        var layout: any Layout {
            switch self {
                case .hStack:
                    HStackLayout()
                case .vStack:
                    VStackLayout()
                case .zStack:
                    ZStackLayout()
                case .flowLayout:
                    FlowLayout()
            }
        }

        var id: Self { self }
    }
}
This post is licensed under CC BY 4.0 by the author.