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