Introduction
One of the best SwiftUI additions this year has to be the Layout
protocol. Not only we finally get our hands in the layout process, but it is also a great opportunity to better understand how layout works in SwiftUI.
Back in 2019 I wrote an article about Frame Behaviors with SwiftUI. In it, I described how parents and children negotiate the final size of a view. Many of the things described there had to be guessed by observing the result of various tests. The whole process felt like discovering an exoplanet, where astronomers detect a tiny reduction of a sun’s luminosity and then infer that a planet must be transiting through (see planetary transit if you are interested).
Now, with the Layout
protocol, it is like travelling to that distant solar system and seeing it with our own eyes. It is very exciting.
Creating a basic layout is not hard, we just need to implement two methods. Nevertheless, there are a lot of options we can play with to achieve more complex containers. We will explore beyond the typical Layout
examples. There are some interesting topics I haven’t seen explained anywhere yet, so I will present them here. However, before we can dive into these areas, we need to begin by building a strong foundation.
There’s a lot to cover, so I’ll break this post up in two parts:
Part 1 – The Basics:
- What is the Layout Protocol?
- Family Dynamics of the View Hierarchy
- Our First Layout Implementation
- Container Alignment
- Custom Values: LayoutValueKey
- Default Spacing
- Layout Properties and Spacer()
- Layout Cache
- Great Pretenders
- Switching Layouts with AnyLayout
- Part 1 Conclusion
Part 2 – Advanced Layouts:
- And The Fun Begins!
- Custom Animations
- Bi-directional Custom Values
- Avoiding Layout Loops and Crashes
- Recursive Layouts
- Layout Composition
- Another Composition Example: Interpolating Two Layouts
- Using Binding Parameters
- A Helpful Debugging Tool
- Final Thoughts
If you are already familiar with the Layout
protocol, you may want to skip to part 2. That is alright, although I still recommend you check the first part, at least superficially. That will make sure we are both in the same page when we start exploring the more advanced features described in part 2.
If at any point reading this post, you decide that the Layout protocol is not for you (at least for the moment), I still recommend you check the section: A Helpful Debugging Tool (in part 2). The tool can help you with SwiftUI in general and does not require that you understand the Layout
protocol to use it. There’s a reason I put it at the very end of the second part. It is because the tool is built using the knowledge from this post. Nevertheless, you can just copy the code and use it as is.
What is the Layout Protocol?
The mission of the types that adopt the Layout
protocol, is to tell SwiftUI how to place a set of views, and how much space it needs to do so. These types are used as view containers. Although the Layout
protocol is new this year (at least publicly), we’ve been using this since day 1 of SwiftUI, every time with place views inside an HStack
or VStack
.
Note that at least for the time being, the Layout
protocol cannot be used to create lazy containers, like LazyHStack
, or LazyVStack
. Lazy containers are those that only render views when they scroll in and stop rendering them when they scroll out.
It’s important to know that a Layout
type is not a View
. For example, they don’t have a body property as views have. But do not worry, for the time being you can think of them as if they were Views and use them as such. The framework uses some nice swift language tricks to make your Layout
transparently produce a View
when you insert them in your SwiftUI code. I’ll explain all that later in the section Great Pretenders.
Family Dynamics of the View Hierarchy
Before we start coding layouts, let’s refresh a pillar of the SwiftUI framework. As I described in my old post Frame Behaviors with SwiftUI, during layout, parents propose a size to their children, but it is ultimately up to the children to decide how to draw itself. It then communicates that to the parent, so it can act accordingly. There are three possible scenarios. We will concentrate on the horizontal axis (width), but the same is true for the vertical axis (height):
Scenario 1: If the child takes less than what’s been offered:
For this example consider a Text view that’s been offered more space than what’s needed to draw the text:
struct ContentView: View {
var body: some View {
HStack(spacing: 0) {
Rectangle().fill(.green)
Text("Hello World!")
Rectangle().fill(.green)
}
.padding(20)
}
}
In this example, the window is 400 pt wide. So the text has been offered a third of the HStack’s width ((400 – 40) / 3 = 120). Of those 120 pt, Text only needs 74.0 and communicates that to the parent (the HStack). The parent can now take those extra 46 pt and use them with the other children. Because the other children are shapes, these take everything that’s given to them. In this case 120 + 46 / 2 = 143.
Scenario 2: If the child takes exactly what’s been offered:
Shapes are an example of views that take whatever is offered to them. In the previous example, those green rectangles take everything offered, but not a pixel more.
Scenario 3: If the child takes more than what’s been offered:
Consider the following example. Image views (unless they’ve been modified with the resizable
method) are relentless. They take as much space as they need. In the example below, the Image
is 300×300 and that is how much it uses to draw itself. However, by calling frame(width:100)
, the child is being offered only 100 pt. Is the parent helpless and should do what the child says? Not exactly. The child will use 300 pt to draw, however the parent will layout the other views as if the child were only 100 pt wide. As a result, we have a child that overflows it boundaries, but the surrounding views are not affected by the extra space use by the Image. In the example below, the black border shows the space offered to the image.
struct ContentView: View {
var body: some View {
HStack(spacing: 0) {
Rectangle().fill(.yellow)
Image("peach")
.frame(width: 100)
.border(.black, width: 3)
.zIndex(1)
Rectangle().fill(.yellow)
}
.padding(20)
}
}
There’s a lot of diversity in how views act. For example, we’ve seen how Text takes less than offered when it doesn’t need it. However, if it needs more than offered, several things can happen, depending on how you configure your view. For example, it may truncate the text to stay in the offered size, or it may grow vertically to show the text in the offered width. Or it can even overflow as the Image did in the example if you used the fixedSize
modifier. Remember that fixedSize
tells a view to use its ideal size, no matter how little they’ve been offered.
If you want to learn more about these behaviors and how to alter them, check my old post Frame Behaviors with SwiftUI.
Our First Layout Implementation
Creating a Layout type requires that we implement at least two methods: sizeThatFits
and placeSubviews
. These methods receive some new types as parameters: ProposedViewSize and LayoutSubview. Before we start writing our methods, lets see what these parameters look like:
ProposedViewSize
The ProposedViewSize
is used by the parent to tell the child how to compute its own size. The type is simple, but powerful. It is just a pair of optional CGFloats
for proposed width and height. However, it is how we interpret these values what makes them interesting.
These properties can have concrete values (e.g, 35.0, 74.0, etc), but there’s also special meaning when these values are 0.0, nil, or .infinity:
- For a concrete width, for example 45.0, the parent is offering exactly 45.0 pt, and the view should determine its own size for that offered width.
- For a 0.0 width, the child should respond with its minimum size.
- For an .infinity width, the child should respond with its maximum size.
- For a nil value, the child should respond with its ideal size.
ProposedViewSize
also has some predefined values:
ProposedViewSize.zero = ProposedViewSize(width: 0, height: 0)
ProposedViewSize.infinity = ProposedViewSize(width: .infinity, height: .infinity)
ProposedViewSize.unspecified = ProposedViewSize(width: nil, height: nil)
LayoutSubview
The sizeTheFits
and placeSubviews
methods also receives a Layout.Subviews
parameter, which is a collection of LayoutSubview
elements. One for each view that is a direct descendant of the parent. In spite of its name, the type is not a view, but a proxy to one. We can query these proxies to learn layout information of the individual views we are laying out. For example, for the first time since SwiftUI’s introduction, we can directly query the minimum, ideal or maximum size of a view, or we can also get the layout priority of each view, among other interesting values.
Writing the sizeThatFits Method
func sizeThatFits(proposal: ProposedViewSize, subviews: Self.Subviews, cache: inout Self.Cache) -> CGSize
SwiftUI will call our sizeThatFits
method to determine the size of a container using our layout. When writing this method we should think of ourselves us being both a parent and a child: We are a parent asking our child views for their sizes. But we are also a child telling our parent what our size will be, based on our children replies.
The method receives a size proposal, a collection of subview proxies and a cache. This last parameter may be used to improve the performance of our layout and some other advanced applications, but for the time being we will not use it. We’ll go back to it a bit ahead.
When the sizeThatFits
method is proposed nil in a given dimension (i.e., width or height), we should return the ideal size of the container for that dimension. When the proposal is 0.0 for a given dimension, we should return the minimum size of the container in that dimension. And when the proposal is .infinity for a given dimension, we should return the maximum size for the container in that dimension.
Note that sizeThatFits
may be called multiple times with different proposals to test the flexibility of the container. Proposals can be any combination of the above cases for each dimension. For example, you may get a call with ProposedViewSize(width: 0.0, height: .infinity)
With the information we have so far, let’s begin with our first layout. We will start by creating a basic HStack
. We’ll call it SimpleHStack
. In order to compare them both, we will create a view to place a standard HStack
(blue) on top of the SimpleHStack
(green). In our first try we will implement sizeThatFits, but we will leave the other required method (placeSubviews
) empty for the time being.
struct ContentView: View {
var body: some View {
VStack(spacing: 20) {
HStack(spacing: 5) {
contents()
}
.border(.blue)
SimpleHStack(spacing: 5) {
contents()
}
.border(.blue)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.white)
}
@ViewBuilder func contents() -> some View {
Image(systemName: "globe.americas.fill")
Text("Hello, World!")
Image(systemName: "globe.europe.africa.fill")
}
}
struct SimpleHStack: Layout {
let spacing: CGFloat
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let idealViewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let spacing = spacing * CGFloat(subviews.count - 1)
let width = spacing + idealViewSizes.reduce(0) { $0 + $1.width }
let height = idealViewSizes.reduce(0) { max($0, $1.height) }
return CGSize(width: width, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
{
// ...
}
}
As you can observe, the size of both stacks is identical. However, because we haven’t written any code in the placeSubviews
method, all views are placed in the middle of our container. This is the default if you do not explicitly place a view.
In our sizeThatFits
method, we first compute all ideal sizes for every view. We can easily achieve that because the subview proxies we received have a method that return the size of the subview for a given proposal.
Once we have all ideal sizes computed, we calculate the container size by adding all subviews widths and the spacing in between those views. For the height, our stack will be as tall as the tallest subview.
You may have observed that we are completely ignoring the size we’ve been offered. We’ll go back to that in a minute. For now, let’s now implement placeSubviews.
Writing the placeSubviews Method
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Self.Subviews, cache: inout Self.Cache)
After SwiftUI has tested our container view for several proposals, by calling sizeThatFits
repeatedly with different proposal values, it will eventually call our placeSubviews
method. Our goal here is to iterate through the subviews, determine their positions and place them there.
In addition to the same parameters sizeThatFits
receives, placeSubviews
also gets a CGRect
parameter (bounds). The bounds rect has the size we asked for in the sizeThatFits method. Typically, the origin of the rect is (0,0), but you should not assume that. The origin may have a different value if we are composing layouts, as we will see later on.
Placing views is simple, thanks to the subview proxies, which have a place
method. We must provide the coordinates for the view, the anchor point (center if left unspecified), and the proposal so the child can draw itself accordingly.
struct SimpleHStack: Layout {
// ...
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
{
var pt = CGPoint(x: bounds.minX, y: bounds.minY)
for v in subviews {
v.place(at: pt, anchor: .topLeading, proposal: .unspecified)
pt.x += v.sizeThatFits(.unspecified).width + spacing
}
}
}
Now, remember that I mentioned we ignored the proposal we received from the container’s parent? This means that our SimpleHStack
container will always have the same size. No matter what has been offered, the container computes sizes and placement using .unspecified, which means the container will always have an ideal size. In this case the ideal size of the container is the size that let it place all its subviews with their own ideal sizes. Look what happens if we alter the offered size. In this animation the offered width is represented by the red border:
Observe how the our SimpleHStack will ignore the offered size and it always draws with its ideal size, the one that fits all its subviews with their own ideal sizes.
Container Alignment
The Layout protocol let us also define alignment guides for the container. Note that this indicates how the container as a whole, aligns with the rest of the views. It has no effect on the views inside the container.
In the following example, we make our SimpleHStack
container to align with the second view, but only if the container is aligned with leading
(if you change the VStack
alignment to trailing
, you won’t see any special alignment).
The view with the red border is a SimpleHStack
, the black border views are standard HStack
containers. The green border denotes the enclosing VStack
.
struct ContentView: View {
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 5) {
contents()
}
.border(.black)
SimpleHStack(spacing: 5) {
contents()
}
.border(.red)
HStack(spacing: 5) {
contents()
}
.border(.black)
}
.background { Rectangle().stroke(.green) }
.padding()
.font(.largeTitle)
}
@ViewBuilder func contents() -> some View {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Hello, world!")
}
}
struct SimpleHStack: Layout {
// ...
func explicitAlignment(of guide: HorizontalAlignment, in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGFloat? {
if guide == .leading {
return subviews[0].sizeThatFits(proposal).width + spacing
} else {
return nil
}
}
}
Layout Priorities
When using the HStack, we know that all views compete equally for width, unless they have different layout priorities. By default, all views have a layout priority of 0.0. However, you can change the layout priority of a view by calling the layoutPriority()
modifier.
Enforcing the layout priority is the responsibility of the container’s layout. So if we create a new layout, if relevant, we should add some logic to take into account the view’s layout priority. How we do that, is up to us. Although there are better ways (we’ll address them in a minute), you can use the layout priority value of a view and give it any meaning. For example, in the previous example, we will place the views from left to right, according to the views’ layout priority values.
To achieve that, instead of iterating the subviews collection as it came, we simply sort it by their priorities:
struct SimpleHStack: Layout {
// ...
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
{
var pt = CGPoint(x: bounds.minX, y: bounds.minY)
for v in subviews.sorted(by: { $0.priority > $1.priority }) {
v.place(at: pt, anchor: .topLeading, proposal: .unspecified)
pt.x += v.sizeThatFits(.unspecified).width + spacing
}
}
}
In the example below, the blue circle will appear first, because it has a higher priority than the rest.
SimpleHStack(spacing: 5) {
Circle().fill(.yellow)
.frame(width: 30, height: 30)
Circle().fill(.green)
.frame(width: 30, height: 30)
Circle().fill(.blue)
.frame(width: 30, height: 30)
.layoutPriority(1)
}
Custom Values: LayoutValueKey
Using the layout priority for something other than priority is not recommended. It can confuse other users of your container, or even a future you. Fortunately, we have another mechanism to add new values to the view. And this values are not limited to CGFloat
, they can have any type (as we will see in other examples later).
We will rewrite the previous example, using a new value we will call PreferredPosition
. The first thing to do, is to create a type that conforms with LayoutValueKey
. We only need a struct with a static defaultValue
. This default values are used when none is explicitly specified.
struct PreferredPosition: LayoutValueKey {
static let defaultValue: CGFloat = 0.0
}
And that’s it, we have a new property in our views. To set the value, we use the layoutValue()
modifier. To read the value, we use the LayoutValueKey
type as a subscript of the view proxy:
SimpleHStack(spacing: 5) {
Circle().fill(.yellow)
.frame(width: 30, height: 30)
Circle().fill(.green)
.frame(width: 30, height: 30)
Circle().fill(.blue)
.frame(width: 30, height: 30)
.layoutValue(key: PreferredPosition.self, value: 1.0)
}
struct SimpleHStack: Layout {
// ...
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
{
var pt = CGPoint(x: bounds.minX, y: bounds.minY)
let sortedViews = subviews.sorted { v1, v2 in
v1[PreferredPosition.self] > v2[PreferredPosition.self]
}
for v in sortedViews {
v.place(at: pt, anchor: .topLeading, proposal: .unspecified)
pt.x += v.sizeThatFits(.unspecified).width + spacing
}
}
}
This code doesn’t look as clean as the one we wrote with layoutPriority
, but that can be easily fixed with these two extensions:
extension View {
func preferredPosition(_ order: CGFloat) -> some View {
self.layoutValue(key: PreferredPosition.self, value: order)
}
}
extension LayoutSubview {
var preferredPosition: CGFloat {
self[PreferredPosition.self]
}
}
Now we can rewrite our code like this:
SimpleHStack(spacing: 5) {
Circle().fill(.yellow)
.frame(width: 30, height: 30)
Circle().fill(.green)
.frame(width: 30, height: 30)
Circle().fill(.blue)
.frame(width: 30, height: 30)
.preferredPosition(1)
}
struct SimpleHStack: Layout {
// ...
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
{
var pt = CGPoint(x: bounds.minX, y: bounds.minY)
for v in subviews.sorted(by: { $0.preferredPosition > $1.preferredPosition }) {
v.place(at: pt, anchor: .topLeading, proposal: .unspecified)
pt.x += v.sizeThatFits(.unspecified).width + spacing
}
}
}
Default Spacing
So far our SimpleHStack
has been using a spacing value we provide when initializing our layout. However, if you’ve been using HStack
for a while, you know that if no spacing is specified, the stack will provide a default spacing that will vary according to the platform and context the view is in. A view can have a spacing if it is next to a Text view and a different spacing if it is next to an image. Additionally, each edge has its own preference.
So how can we replicate the same behavior with our SimpleHStack
? Well, I mentioned before that these subview proxies are a wealth of layout knowledge… and they don’t disappoint. They also have a way of querying them for space preferences.
struct SimpleHStack: Layout {
var spacing: CGFloat? = nil
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let idealViewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let accumulatedWidths = idealViewSizes.reduce(0) { $0 + $1.width }
let maxHeight = idealViewSizes.reduce(0) { max($0, $1.height) }
let spaces = computeSpaces(subviews: subviews)
let accumulatedSpaces = spaces.reduce(0) { $0 + $1 }
return CGSize(width: accumulatedSpaces + accumulatedWidths,
height: maxHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
{
var pt = CGPoint(x: bounds.minX, y: bounds.minY)
let spaces = computeSpaces(subviews: subviews)
for idx in subviews.indices {
subviews[idx].place(at: pt, anchor: .topLeading, proposal: .unspecified)
if idx < subviews.count - 1 {
pt.x += subviews[idx].sizeThatFits(.unspecified).width + spaces[idx]
}
}
}
func computeSpaces(subviews: LayoutSubviews) -> [CGFloat] {
if let spacing {
return Array<CGFloat>(repeating: spacing, count: subviews.count - 1)
} else {
return subviews.indices.map { idx in
guard idx < subviews.count - 1 else { return CGFloat(0) }
return subviews[idx].spacing.distance(to: subviews[idx+1].spacing, along: .horizontal)
}
}
}
}
Note that in addition to using space preferences, you can also tell the system what the space preferences of the container view are. That way SwiftUI will know how to space it with its surrounding views. For that, you need to implement the Layout method spacing(subviews:cache:).
Layout Properties and Spacer()
The Layout
protocol has a static property you can implement called layoutProperties
. According to the documentation, the LayoutProperties
contains the layout-specific properties of a layout container. At the time of this writing, there is only one property defined: stackOrientation
.
struct MyLayout: Layout {
static var layoutProperties: LayoutProperties {
var properties = LayoutProperties()
properties.stackOrientation = .vertical
return properties
}
// ...
}
The stackOrientation
is what tells a view like Spacer
if it should expand in its horizontal or vertical axis. For example, if you check the minimum, ideal and maximum size of a Spacer
view’s proxy, this is what it returns in different containers, each with a different stackOrientation
:
stackOrientation | minimum | ideal | maximum |
---|---|---|---|
.horizontal | 8.0 × 0.0 | 8.0 × 0.0 | .infinity × 0.0 |
.vertical | 0.0 × 8.0 | 0.0 × 8.0 | 0.0 × .infinity |
.none or nil | 8.0 × 8.0 | 8.0 × 8.0 | .infinity × .infinity |
Layout Cache
The Layout’s cache is often view as a way of improving the performance of our layout. However, it has other uses. Just think of it as a place to store data we need to persist across sizeThatFits
and placeSubviews
calls. The first application that comes to mind is performance improvement. However, it is also very useful for sharing information with other sub-layouts. We will explore this when we reach the layout composition examples, but let’s begin by learning how we can use the cache to improve performance.
The sizeThatFits
and placeSubviews
methods are called multiple times by SwiftUI during the layout process. The framework tests our containers for its flexibility to determine the final layout of the entire view hierarchy. To improve the performance of our Layout
containers, SwiftUI let us implement a cache that is updated only when at least one view inside our container changes. Because sizeThatFits and placeSubviews can be called many times for a single view change, it makes sense to keep a cache of the data that doesn’t need to be recomputed for every call.
Using a cache is not compulsory. In fact, more often than not, you won’t need one. In any case, it is probably easier to code our layout without a cache, and add it later if we find we need it. SwiftUI already does some caching. For example, values obtained from the subview proxies are stored in a cache automatically. Repeated calls with the same parameters will use the cached results. There’s a good discussion of the reasons why you may want to implement your own cache in the makeCache(subviews:) documentation page.
Also note that the cache
parameter in sizeThatFits
and placeSubviews
is an inout
parameter. This means you can update the cache storage in those functions too. We will see how that is especially helpful in the RecursiveWheel
example.
As an example, here’s the SimpleHStack
updated with the cache. Here’s what we need to do:
- Create a type that will have our cache data. In this example, I called it
CacheData
and it will have the calculation of the maxHeight and spaces in between the views. - Implement the makeCache(subviews:) to create the cache.
- Optionally implement the updateCache(subviews:). This method is called when changes are detected. A default implementation is provided that basically recreates the cache by calling makeCache.
- Remember to update the type of the cache parameter in
sizeThatFits
andplaceSubviews
.
struct SimpleHStack: Layout {
struct CacheData {
var maxHeight: CGFloat
var spaces: [CGFloat]
}
var spacing: CGFloat? = nil
func makeCache(subviews: Subviews) -> CacheData {
return CacheData(maxHeight: computeMaxHeight(subviews: subviews),
spaces: computeSpaces(subviews: subviews))
}
func updateCache(_ cache: inout CacheData, subviews: Subviews) {
cache.maxHeight = computeMaxHeight(subviews: subviews)
cache.spaces = computeSpaces(subviews: subviews)
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) -> CGSize {
let idealViewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let accumulatedWidths = idealViewSizes.reduce(0) { $0 + $1.width }
let accumulatedSpaces = cache.spaces.reduce(0) { $0 + $1 }
return CGSize(width: accumulatedSpaces + accumulatedWidths,
height: cache.maxHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) {
var pt = CGPoint(x: bounds.minX, y: bounds.minY)
for idx in subviews.indices {
subviews[idx].place(at: pt, anchor: .topLeading, proposal: .unspecified)
if idx < subviews.count - 1 {
pt.x += subviews[idx].sizeThatFits(.unspecified).width + cache.spaces[idx]
}
}
}
func computeSpaces(subviews: LayoutSubviews) -> [CGFloat] {
if let spacing {
return Array<CGFloat>(repeating: spacing, count: subviews.count - 1)
} else {
return subviews.indices.map { idx in
guard idx < subviews.count - 1 else { return CGFloat(0) }
return subviews[idx].spacing.distance(to: subviews[idx+1].spacing, along: .horizontal)
}
}
}
func computeMaxHeight(subviews: LayoutSubviews) -> CGFloat {
return subviews.map { $0.sizeThatFits(.unspecified) }.reduce(0) { max($0, $1.height) }
}
}
If we print out a message every time one of the layout functions is called, see below the result we obtain. As you can see, the cache is calculated twice, but the other methods are called 25 times!
makeCache called <<<<<<<<
sizeThatFits called
sizeThatFits called
sizeThatFits called
sizeThatFits called
placeSubiews called
placeSubiews called
updateCache called <<<<<<<<
sizeThatFits called
sizeThatFits called
sizeThatFits called
sizeThatFits called
placeSubiews called
placeSubiews called
sizeThatFits called
sizeThatFits called
placeSubiews called
sizeThatFits called
placeSubiews called
placeSubiews called
sizeThatFits called
placeSubiews called
placeSubiews called
sizeThatFits called
sizeThatFits called
sizeThatFits called
placeSubiews called
Note that in addition to using the cache parameter for performance improvement, there is also another use for it. We will go back to it in the RecursiveWheel
example, in the second part of this post.
Great Pretenders
As I already mentioned, the Layout
protocol does not adopt the View
protocol. So how come we’ve been using layout containers in our ViewBuilder
closures as if they were views? It turns out Layout
has a function the system calls to produce a view when you place your layout in code. And what is that function called? You may have guessed it already:
func callAsFunction<V>(@ViewBuilder _ content: () -> V) -> some View where V : View
Thanks to the language addition (described and explained in SE-0253), methods that are named callAsFunction
are special. These methods are called when we use a type instance as if it were a function. In this case it may be confusing cause it seems we are just initializing the type, when in reality, we are doing more than that. We are initializing the type and then calling its callAsFunction
method. And because the returned value of this callAsFunction
method is a View
, we can place it in our SwiftUI code.
SimpleHStack(spacing: 10).callAsFunction({
Text("Hello World!")
})
// Thanks to SE-0253 we can abbreviate it by removing the .callAsFunction
SimpleHStack(spacing: 10)({
Text("Hello World!")
})
// And thanks to trailing closures, we end up with:
SimpleHStack(spacing: 10) {
Text("Hello World!")
}
If our layout does not have an initializer parameter, the code simplifies even more:
SimpleHStack().callAsFunction({
Text("Hello World!")
})
// Thanks to SE-0253 we can abbreviate it by removing the .callAsFunction
SimpleHStack()({
Text("Hello World!")
})
// And thanks to single trailing closures, we end up with:
SimpleHStack {
Text("Hello World!")
}
So there you have it, layout types are not views, but they do produce one when you place them in your SwiftUI code. This swift trick (callAsFunction
) also makes it possible to switch to a different layout, while maintaining view identities, as described in the following section.
Switching Layouts with AnyLayout
Another interesting aspect of Layout
containers, is that we can change the layout of a container, and SwiftUI will nicely animate between the two. No extra code needed! 🥳 This is because the identity of the view is maintained. SwiftUI sees this as a view change, and not as two separate views.
struct ContentView: View {
@State var isVertical = false
var body: some View {
let layout = isVertical ? AnyLayout(VStackLayout(spacing: 5)) : AnyLayout(HStackLayout(spacing: 10))
layout {
Group {
Image(systemName: "globe")
Text("Hello World!")
}
.font(.largeTitle)
}
Button("Toggle Stack") {
withAnimation(.easeInOut(duration: 1.0)) {
isVertical.toggle()
}
}
}
}
The ternary operator (condition ? result1 : result2) requires that both return expressions use the same type. AnyLayout
(a type-erased layout) comes to the rescue here.
NOTE: If you watch the 2022 WWDC Layout session or read the SwiftUI lounge questions, you may have seen that Apple engineers use a similar example, but with VStack
instead of VStackLayout
and HStack
instead of HStackLayout
. That is outdated. After beta 3, HStack
and VStack
no longer adopt the Layout
protocol and they added the VStackLayout
and HStackLayout
layouts (which are used by the VStack
and HStack
views respectively). They also added ZStackLayout
and GridLayout
.
Conclusion
The prospect of writing a Layout
container can be overwhelming if we stop to consider every possible scenario. There are views that use as much space as offered, others try to accommodate, other will use less, etc. There’s also the layout priority, which makes things harder when multiple views are competing for the same space. However, the task may not be as daunting as it seems. It is possible that we will be the users of our own layout and we may know in advanced what type of views our container will have. For example, if you plan to use your container only with square images, or text views, or if you know that your container will have a specific size, or if you know for sure that all your views will have the same priority, etc. This information can simplify your task greatly. And even if you cannot have that luxury to make this assumptions, it may be a good place to start coding. Make your layout work in some scenarios and then start adding code for more complex cases.
In the second part of this post we will start exploring some very interesting topics, like custom animations, bidirectional custom values, recursive layouts or layout composition. I will also introduce a very helpful debugging tool that you can use, even if you are not creating your own layouts.
Feel free to follow me on twitter, if you want to be notified when new articles are published. Until then!