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 take a first look at SwiftUI's new Layout protocol and integrate the flow layout we previously built.

00:06 It's the last day of WWDC, and we got a bunch of cool updates. The new APIs for programmatic navigation in SwiftUI seem promising, but we first want to take a look at the new Layout protocol.

00:33 The Layout protocol allows us to use built-in layouts and swap them out dynamically, as demonstrated by Harlan Haskins. We've tried to do this in the past by building our own stack views, but now we get to use the official VStack and HStack and animate between the two layouts.

Switching Layouts

01:31 We first create an enum of the different layouts we want to use. In a computed property, we produce a Layout for each of the cases. Since VStack and HStack conform to Layout if their content is empty — i.e. when we don't provide a view builder closure — we can just return them directly:

enum Algo: CaseIterable {
    case vstack
    case hstack
    
    var layout: Layout {
        switch self {
        case .vstack: return VStack()
        case .hstack: return HStack()
        }
    }
}

02:29 Swift warns us that we can't use the protocol as the return type. One reason is that it has an associated type for its cache. Swift 5.7 introduces the any keyword, which allows us to write any Layout as the return type:

enum Algo: CaseIterable {
    case vstack
    case hstack
    
    var layout: any Layout {
        switch self {
        case .vstack: return VStack()
        case .hstack: return HStack()
        }
    }
}

02:45 This is huge, because not only does the any keyword free us from only being able to use a protocol with an associated type as a generic constraint, but it also enables us to return different types from each of the branches of the switch statement. This wasn't possible before.

03:03 Next, we create a state property for the layout choice, and we add a picker that lets us switch between the two layouts. To use the any Layout produced by the algo in our view, we have to first wrap it in an AnyLayout:

struct ContentView: View {
    @State var algo = Algo.hstack
    
    var body: some View {
        VStack {
            Picker("Algorithm", selection: $algo) {
                ForEach(Algo.allCases) { algo in
                    Text("\(algo.rawValue)")
                }
            }
            let layout = AnyLayout(algo.layout)
            layout {
                ForEach(0..<5) { ix in
                    Text("Item \(ix)")
                        .padding()
                        .background(Capsule()
                            .fill(Color(hue: .init(ix)/10, saturation: 0.8, brightness: 0.8)))
                }
            }
            .frame(maxHeight: .infinity)
        }
    }
}

04:36 We make Algo conform to Identifiable, and we set its raw value type to String, so that we can pass the cases to ForEach and output them in Text views:

enum Algo: String, CaseIterable, Identifiable {
    case vstack
    case hstack
    
    var layout: any Layout {
        switch self {
        case .vstack: return VStack()
        case .hstack: return HStack()
        }
    }
    
    var id: Self { self }
}

05:04 By default, the picker displays as a button with a popup menu, but we prefer a segmented control, which we get by changing the picker style:

Picker("Algorithm", selection: $algo) {
    ForEach(Algo.allCases) { algo in
        Text("\(algo.rawValue)")
    }
}
.pickerStyle(.segmented)

05:14 We want to center the sample views in the remaining space, so we add a flexible frame with a maximum height of .infinity.

05:37 We can now switch between the layouts, but we also want to add an animation to the view that's triggered when the layout changes:

struct ContentView: View {
    @State var algo = Algo.hstack
    
    var body: some View {
        VStack {
            Picker("Algorithm", selection: $algo) {
                ForEach(Algo.allCases) { algo in
                    Text("\(algo.rawValue)")
                }
            }
            .pickerStyle(.segmented)
            let layout = AnyLayout(algo.layout)
            layout {
                ForEach(0..<5) { ix in
                    Text("Item \(ix)")
                        .padding()
                        .background(Capsule()
                            .fill(Color(hue: .init(ix)/10, saturation: 0.8, brightness: 0.8)))
                }
            }
            .animation(.default, value: algo)
            .frame(maxHeight: .infinity)
        }
    }
}

06:11 Now we can toggle between the layouts using the picker, and the views animate between the two layouts:

06:16 SwiftUI understands that it's only the layout that changes, whereas the underlying views stay the same. That's why it's able to animate the frames of those views from one layout to the next. When we slow the animations down, we can see that even the text inside the view animates from fitting on one line in the vertical layout to wrapping over two lines in the horizontal layout.

ZStack

07:13 The obvious third candidate for the enum is a ZStack layout. But we can't use ZStack as a Layout. Instead, we can return a _ZStackLayout value:

enum Algo: String, CaseIterable, Identifiable {
    case vstack
    case hstack
    case zstack
    
    var layout: any Layout {
        switch self {
        case .vstack: return VStack()
        case .hstack: return HStack()
        case .zstack: return _ZStackLayout()
        }
    }
    
    var id: Self { self }
}

08:00 Other built-in types that conform to Layout are Grid and _Circle.

Custom Layouts

08:09 Besides using the built-in layouts, we can also create our own conforming types. The way the protocol works might feel familiar to people who've been following along with our SwiftUI reimplementations, because it turns out to work pretty much exactly how we hoped it would. In fact, we can copy in most of the code we wrote when we built our own flow layout.

08:54 We first define a new type that conforms to the Layout protocol. We have to implement two methods to conform. Immediately, we can see that the ProposedSize type is now public, and it works exactly as we expected — it has an optional width and an optional height, both CGFloat?, where a nil dimension tells a view to adopt its ideal size:

struct FlowLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        // ...
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        // ...
    }
}

09:33 We can also finally see the magic number of 10 points — which views fall back to when one of the proposed dimensions is nil — when we look at the signature of the replacingUnspecifiedDimensions method on ProposedSize:

func replacingUnspecifiedDimensions(by size: CGSize = CGSize(width: 10, height: 10)) -> CGSize

10:00 We paste in the flow layout code we wrote earlier. This function takes an array of subview sizes and the available space of the container view, and it places the elements on lines, wrapping to the next line if the current line has no more room. Both the offsets for the elements and the resulting size of the entire content are returned:

func layout(sizes: [CGSize], spacing: CGFloat = 10, containerWidth: CGFloat) -> (offsets: [CGPoint], size: CGSize) {
    var result: [CGPoint] = []
    var currentPosition: CGPoint = .zero
    var lineHeight: CGFloat = 0
    var maxX: CGFloat = 0
    for size in sizes {
        if currentPosition.x + size.width > containerWidth {
            currentPosition.x = 0
            currentPosition.y += lineHeight + spacing
            lineHeight = 0
        }
        
        result.append(currentPosition)
        currentPosition.x += size.width
        maxX = max(maxX, currentPosition.x)
        currentPosition.x += spacing
        lineHeight = max(lineHeight, size.height)
    }
    
    return (result, CGSize(width: maxX, height: currentPosition.y + lineHeight))
}

10:32 Previously, we'd use this function after we measured all subviews and propagated their sizes up using the preference system. We no longer have to do this, because we receive proxies of the subviews, which provide a sizeThatFits method for each subview. We can get the ideal size of a view by calling sizeThatFits with the .unspecified size. And we get the container width from the proposed size:

struct FlowLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let containerWidth = proposal.replacingUnspecifiedDimensions().width
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        return layout(sizes: sizes, containerWidth: containerWidth).size
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        // ...
    }
}

12:30 In placeSubviews, we have to do something similar to what we did in sizeThatFits. But when this function gets called, the size of the view is already determined, so we can get the container width from the bounds parameter. We call layout again to get the offsets for the subviews. Then we zip the offsets and the subviews to place each subview at its offset:

struct FlowLayout: Layout {
    // ...
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        let offsets = layout(sizes: sizes, containerWidth: bounds.width).offsets
        for (offset, subview) in zip(offsets, subviews) {
            subview.place(at: offset, proposal: .unspecified)
        }
    }
}

Since we used .unspecified for the proposed size to get the subviews' ideal sizes, we can do so again in this step.

14:31 We're almost there, but we can already try running the app to see what's going on. To do so, we add a new case to Algo for our flow layout:

enum Algo: String, CaseIterable, Identifiable {
    case vstack
    case hstack
    case zstack
    case flow
    
    var layout: any Layout {
        switch self {
        case .vstack: return VStack()
        case .hstack: return HStack()
        case .zstack: return _ZStackLayout()
        case .flow: return FlowLayout()
        }
    }
    
    var id: Self { self }
}

14:52 When we select the flow layout in the picker, the views float to the top of the screen. This happens because we're not taking the origin of the bounds parameter into account. We have to offset the subviews with this origin:

struct FlowLayout: Layout {
    // ...
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        let offsets = layout(sizes: sizes, containerWidth: bounds.width).offsets
        for (offset, subview) in zip(offsets, subviews) {
            subview.place(at: CGPoint(x: offset.x + bounds.minX, y: offset.y + bounds.minY), proposal: .unspecified)
        }
    }
}

15:59 The flow layout now works correctly. When we rotate the device, SwiftUI computes the layout again, making the subviews reflow for the new container width:

Other Aspects

16:26 There's more to Layout than what we've seen now. It lets us communicate values to the layout using a LayoutValue protocol. It also allows us to cache computed values between layout passes, which we should really do instead of computing the flow layout twice.

16:56 It'll be interesting to see the kinds of layouts people will come up with. We can also keep refining the flow layout logic. We could, for example, take vertical alignment of items into account and add support for the built-in spacing parameter on views.

17:24 Finally, let's see how easy it is to try the _Circle layout. The underscore in the name suggests we shouldn't rely on it for a real app, but we can play with it to get some inspiration:

enum Algo: String, CaseIterable, Identifiable {
    case vstack
    case hstack
    case zstack
    case circle
    case flow
    
    var layout: any Layout {
        switch self {
        case .vstack: return VStack()
        case .hstack: return HStack()
        case .zstack: return _ZStackLayout()
        case .flow: return FlowLayout()
        case .circle: return _CircleLayout(radius: 200)
        }
    }
    
    var id: Self { self }
}

This lays the subviews out in a big circle across the screen:

18:09 We'll be discussing Layout more in the future, not only to create custom layouts, but also to inspect what the layout system does.

References

Resources

  • Sample Code

    Written in Swift 5.6

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

166 Episodes · 57h46min

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