IntroductionOne of the best new features added to SwiftUI this year has to be layout protocols. Not only do they allow us to participate in the layout process, but they also give us a great opportunity to better understand the role of layout in SwiftUI. Back in 2019, I wrote an article about frame behavior in SwiftUI[1], in which I described how parent views and child views coordinate to form the final view effect. Many of the situations described there require guessing by observing the results of different tests. The whole process is like discovering an alien planet. Astronomers noticed a tiny decrease in the brightness of the sun and then deduced that it must be a planet transiting (understand planet transits[2]). Now, with the layout protocol, it's like wandering around a distant solar system with your own eyes, which is exhilarating. Creating a basic layout is not that hard, and only requires implementing two methods. Still, we have a lot of options to implement a complex container. We will explore beyond the regular layout cases. There are many interesting topics that I haven't seen explained anywhere so far, so I will cover them here. However, before diving into these areas, we need to lay a solid foundation first. Since there is a lot to cover, I will split it into two parts: Part 1 - Basics:
Part 2 - Advanced Layout:
If you are already familiar with the layout protocol, you may want to skip directly to Part 2. This is fine, although I still recommend that you skim Part 1, at least briefly. This will ensure that we are on the same page when we start exploring the more advanced features described in Part 2. If at any point while reading this article, you decide that layout protocols aren’t for you (at least for now), I still recommend checking out this section in Part 2 — a useful debugging tool that helps you use SwiftUI and doesn’t require you to understand layout protocols. I put it at the end of Part 2 for a reason, and this tool is built using the knowledge from this article. However, you can just copy the code and use it. What is a layout protocol?The task of using layout protocol types is to tell SwiftUI how to place a group of views and how much space they need. This type is often used as a view container, and although layout protocols are new this year (at least publicly), we have been using them since the first day of using SwiftUI, every time we use HStack or VStack to place views. Please note that at least for now, the layout protocol cannot create lazy containers, such as LazyHStack or LazyVStack. Lazy containers are views that are only rendered when they are scrolled onto the screen, and stop rendering when they are scrolled off the screen. An important point to know is that Layout types are not views. For example, they don’t have the body property that views have. But don’t worry, for now you can think of them as views and use them like views. This framework uses a nifty Swift language trick to make your layout code produce a transparent view when inserted into SwiftUI. I’ll explain this later in the section - The Clever Pretender. Family dynamics of view hierarchiesBefore we start laying out the code, let's revisit the core of the SwiftUI framework. As I described in my previous article, Frames in SwiftUI, during layout, the parent view provides a size to the child view, but it is ultimately up to the child view to decide how to draw itself. It then communicates this to the parent view so that it can take appropriate action. There are three possible cases, and we will focus on the horizontal axis (width), but the vertical axis (height) is the same: Case 1: If the subview requirement is smaller than the provided view Consider a text view in this example that provides more space than needed to draw the text. struct ContentView : View { In this example, the screen width is 400pt. Therefore, the text provides one-third of the width of the HStack ((400 – 40) / 3 = 120). Of this 120pt, the text only needs 74, and this is communicated to the parent view, which can now take the extra 46pt to use for the other subviews. Since the other subviews are graphics, they receive everything given to them. In this case, 120+46/2=143. Case 2: If the subview fully receives the provided view A shape is an example of a view that accepts whatever you provide it with. In the previous example, the green rectangle takes up all the space provided, but not a single extra pixel. Case 3: If the subview requirements exceed the provided views Consider the following example. Image views are very strict (unless they have modified the resizable method) and they take up as much space as they need. In the example below, the image is 300×300, which is the space they need to draw themselves. However, by calling frame(width:100) the child view is only given 100pt. Is there nothing the parent view can do but obey the child view? Not really. The child view will still be drawn using 300pt, but the parent view will lay out the other views as if the child view is only 100pt wide. As a result, we will have a child view that extends beyond the borders, but the surrounding views will not be affected by the extra space used by the image. In the example below, the black border shows the space provided for the image. struct ContentView : View { There are many differences in the way views behave. For example, we saw how text gets the space it requested and how it handles any extra space it doesn't need, however, if more space is requested than is provided, a number of things can happen depending on how you configure your views. For example, the text might be clipped to the provided size, or it might be displayed vertically within the provided width, or even off the screen if you use fixedSize like in the example image. Remember that fixedSize tells the view to use its ideal size, regardless of what is provided. If you want to learn more about these behaviors and how to change them, check out my previous article on frame behavior in SwiftUI. Our first layout implementationCreating a layout type requires us to implement at least two methods, sizeThatFits and placeSubviews . These methods accept some new types as parameters: ProposedViewSize and LayoutSubview . Before we start writing the methods, let's take a look at what these parameters look like: ProposedViewSizeProposedViewSize is used by superviews to tell subviews how to calculate their own size. It's a simple type, but powerful. It's just an optional pair of CGFloat s that suggest a width and height. However, it's how we interpret these values that makes them interesting. These properties can have concrete values (such as 35, 74, etc.), but they have special meanings when they are equal to 0.0, nil, or .infinity.
ProposedViewSize can also have some predefined values: ProposedViewSize .zero = ProposedViewSize ( width : 0 , height : 0 ) LayoutSubviewThe sizeTheFits and placeSubviews methods also receive a Layout.Subviews parameter, which is a collection of LayoutSubview elements. Each view has one, as a direct descendant of its parent. Despite the name, its type is not a view, but a proxy. We can query these proxies to get layout information about the individual views we are laying out. For example, for the first time since the introduction of SwiftUI, we can directly query the minimum, ideal, and maximum sizes of a view, or we can get the layout priority of each view, as well as other interesting values. sizeThatFits Methodfunc sizeThatFits(proposal: ProposedViewSize, subviews: Self.Subviews, cache: inout Self.Cache) -> CGSize SwiftUI will call the sizeThatFits method to determine the size of our layout container. When we write this method, we should think of ourselves as both a parent view and a child view: when we are a parent view, we need to ask the child view for its size. When we are a child view, we need to tell the parent view the size it needs based on the child view’s reply. This method will receive the suggested size, a collection of child view proxies and a cache. The last parameter may be used to improve the performance of our layout and some other advanced applications, but we will not use it now, we will look at it a little later. When the sizeThatFits method receives a nil suggestion for a given dimension (i.e. width or height), we should return the ideal size of the container. When the suggested size received is 0.0, we should return the minimum size of the container. When the suggested size received is .infinity, we should return the maximum size of the container. Note that sizeThatFits may be called multiple times with different proposals to test the flexibility of the container, and the proposals can be any combination of the above dimension cases. For example, you might get a call with ProposedViewSize(width: 0.0, height: .infinity). With this information at hand, let's start our first layout. We'll start by creating a basic HStack . We'll name it SimpleHStack . To compare the two, we'll create a standard HStack (blue) view and place it on top of a SimpleHStack (green). In our first attempt, we'll implement sizeThatFits , but we'll leave the other required method (placeSunviews) empty. struct ContentView : View { You can observe that both shapes are the same size. However, this is because we did not write any code in the placeSubviews method and all views are placed in the middle of the container. This is the default view of the container if you do not explicitly place it. In our sizeThatFits method, we first calculate all the ideal sizes for each view. We can do this easily because the subview delegate has methods that return suggested sizes. Once we have calculated all the ideal sizes, we can calculate the container size by adding the subview widths and the view spacing. In terms of height, our view will be as tall as the tallest subview. You may have noticed that we completely ignored the provided size, we’ll come back to this in a moment, for now, let’s implement placeSubviews . placeSubviews Methodfunc placeSubviews ( in bounds : CGRect , proposal : ProposedViewSize , subviews : Self .Subviews , cache : inout Self .Cache ) After SwiftUI has tested the container view by repeatedly calling sizeThatFits with different proposed values, it’s finally time to call placeSubviews . Our goal here is to iterate over the subviews, determine their positions, and place them. placeSubviews also gets a CGRect parameter in addition to the same parameters that sizeThatFits receives. The bounds rect has the dimensions we requested in the sizeThatFits method. Usually, the origin of the rectangle is (0, 0), but you shouldn't assume that, if we are composing layouts, this origin may have a different value, as we will see later. Placing views is easy thanks to the subview delegate which has a placement method. We have to provide the view's coordinates, anchor point (center by default), and suggested size so that the subview can draw itself accordingly. struct SimpleHStack : Layout { Now, remember how I mentioned earlier that we ignore the suggestions we receive from the parent? This means that the SimpleHStack container will always have the same size. Regardless of what is provided, the container will use .unspecified to calculate the size and placement, meaning that the container will always have the ideal size. In this case the ideal size of the container is the size that allows it to place all of its children at their ideal size. If we change the provided size and see what happens, in this animation the red box represents the provided width. Observe how SimpleHStack ignores the provided size and always draws itself at the ideal size, which fits the ideal size of all subviews. Container AlignmentThe layout protocol lets us define alignment guides for containers as well. Note that this indicates how the container as a whole is aligned with the rest of the views. It has no effect on the views inside the container. In the example below, we align the SimpleHStack to the second view, but only if the container is aligned to the header (if you change the VStack’s alignment to the trailing, you won’t see any special alignment). Views with red borders are SimpleHStack , views with black borders are standard HStack containers, and views with green borders are enclosing VStack . struct ContentView : View { struct SimpleHStack : Layout { Priority layoutWhen we use HStack, we know that all views compete equally for width unless they have different layout priorities. All views have a default priority of 0.0, however, you can modify the layout priority by calling layoutPriority(). It is the responsibility of the container layout to enforce layout priorities, so if we create a new layout, we need to add some logic to take layout priorities into account if it is relevant. How we do this is up to us. Although there are better ways (we'll address them in a minute), you can use the values of view layout priorities to give them any meaning. For example, in the previous example, we would place the views from left to right based on the value of the view priority. To achieve this effect, there is no need to iterate over the collection of subviews; instead, they are simply sorted by priority. struct SimpleHStack : Layout { In the example below, the blue circle will appear first because it has a higher priority than the other views. SimpleHStack ( spacing : 5 ) { LayoutValueKeyCustom value: LayoutValueKeyIt is not recommended to use layout priorities for anything other than priorities, which may make other users of your container incomprehensible, or even you in the future. Fortunately, we have another way to add new values to the view. This value is not limited to CGFloat, they can have any type (we will see it later in other examples). We will rewrite the previous example to use a new value, we will call it PreferredPosition. The first thing to do is to create a type that conforms to LayoutValueKey. We just need a struct with a static default value. This default value is used when no specific value is specified. struct PreferredPosition : LayoutValueKey { In this way, our view has a new property. To set this value, we need to use layoutValue(), and to read this value, we use the LayoutValueKey type as the subscript of the view proxy: SimpleHStack ( spacing : 5 ) { struct SimpleHStack : Layout { This code isn’t as neat as the layoutPriority we wrote in the first paragraph, but this is easily fixed with these two extensions: extension View { Now we can rewrite it like this: SimpleHStack ( spacing : 5 ) { struct SimpleHStack : Layout { Default SpacingSo far, we've used the spacing values we provided to SimpleHStack when initializing the layout. However, once you've used HStack for a while, you'll know that if no spacing is specified, views will have default spacing that is specific to the platform and the content. A view can have different spacing if it's next to a text view than it does if it's next to an image. In addition, each edge can have its own preferences. So how do we make them behave consistently with SimpleHStack? I mentioned that subview delegates are a treasure trove of layout knowledge, and they don't disappoint. They have methods that can be queried for their spatial preferences. struct SimpleHStack : Layout { Note that in addition to using the spacing preference, you can also tell the system the spacing preference for the container view. This way, SwiftUI will know how to separate it from the surrounding views. To do this, you need to implement the layout method spacing(subviews:cache:). Layout attributes and Spacer()The layout protocol has a static property called layoutProperties that you can implement. According to the documentation, LayoutProperties contains specific layout properties for the layout container. At the time of writing this article, only one property is defined: stackOrientation. struct MyLayout : Layout { stackOrientation tells a view like Spacer whether it should expand in the horizontal or vertical axis. For example, if you check the minimum, ideal, and maximum sizes of the Spacer view delegate, this is what it returns for different containers, each with a different stackOrientation :
Layout CacheThe layout cache is one way that we often use to improve the performance of our layouts. However, it has other uses as well. Just think of it as a place to store data that we need to persist across sizeThatFits and placeSubviews calls. Improving performance is the first thing that comes to mind, but it can also be very useful for sharing information with other subviews in your layout. We'll explore this when we get to the example of composite layouts, but let's start by understanding how to use the cache to improve performance. The sizeThatFits and placeSubviews methods are called multiple times during SwiftUI’s layout process. The framework tests the flexibility of our containers to determine the final layout of the overall view hierarchy. To improve layout container performance, SwiftUI lets us implement a cache that is only updated when at least one view within the container changes. Because both sizeThatFits and placeSubviews can be called multiple times for a single view to change, it makes sense to keep a cache of data that doesn’t need to be recalculated for each call. Using a cache isn’t required. In fact, many times you won’t need to. In any case, it’s easier to write our layout without caching, and then add it later when we need it. SwiftUI already does some caching. For example, values obtained from subview proxies are automatically stored in a cache. Repeated calls with the same arguments will use the cached result. There’s a good discussion of reasons why you might want to implement your own cache on the makeCache(subviews:)[3] documentation page. Also note that the cache parameters in sizeThatFits and placeSubviews have an inout parameter, which means you can also update the cache store with this function, which we’ll see is particularly helpful in the RecursiveWheel example. For example, here's a SimpleHStack that uses an update cache. Here's what we need to do:
struct SimpleHStack : Layout { If we print a message each time one of the layout functions is called, we will get the following results. As you can see, the cache will be calculated twice, but the other method will be called 25 times! makeCache called <<<<<<<< Note that in addition to using cache parameters to improve performance, they also have other uses. We will talk about this in the RecursiveWheel example in Part 2. A good pretenderAs I already mentioned, the layout protocol does not adopt the view protocol. So why do we keep using layout containers in ViewBuilder as if they were views? It turns out that when you lay out your layout in code, there is a system function call that produces the view. So what is this function called? You might have guessed it: func callAsFunction < V > ( @ViewBuilder _ content : ( ) -> V ) -> some View where V : View Due to language additions (described and explained in SE-0253[5]), methods named callAsFunction are special. These methods are called like a function when we use an instance of a type. In this case, we might be confused because it seems like we are just initializing the type, when in fact, we are doing more. We initialize the type and then call callAsFunction, and because the return value of callAsFunction is a view, we can put it into our SwiftUI code. SimpleHStack ( spacing : 10 ) .callAsFunction ( { If the layout had no initialization parameters, the code could be even simpler: SimpleHStack ( ) .callAsFunction ( { So you see, layout types are not views, but they produce a view when you use them in SwiftUI. This trick (callAsFunction) also allows you to switch to a different layout while maintaining the identity of the view, as described in the next section. Use AnyLayout to switch layouts Another interesting thing about layout containers is that we can modify the container’s layout, and SwiftUI will gracefully animate the transition between the two. No extra code required! That’s because the views recognize the identity and maintain it, and SwiftUI treats this behavior as a change in view, rather than two separate views. struct ContentView : View { The ternary operator (condition? result1:result2) requires both expressions to return the same type. AnyLayout comes into play here. Note: If you watched the 2022 WWDC Layout session[6], you may have seen examples used by Apple engineers, but they used VStack instead of VStackLayout and HStack instead of HStackLayout . That is outdated. After beta3, HStack and VStack no longer use layout protocols, and they added VStackLayout and HStackLayout layouts (used by HStack and VStack respectively), and they also added ZStackLayout and GridLayout. ConclusionWriting layout containers can be daunting if we stop to consider every possible scenario. Some views will use as much space as possible, some will try to fit, some will use less, and so on. And of course there are layout priorities, which can get even harder when multiple views have to compete for the same space. However, this task may not be as daunting as it seems. You may be using your own layouts, and you may know ahead of time what types of views your container will have. For example, if you plan to use your container with only square images or text views, or you know your container will have specific dimensions, or you are sure all your views will have the same priority, and so on. This information can greatly simplify the task. Even if you don't have the luxury of making these assumptions, it can be a good place to start coding, get your layout working in a few cases, and then start adding code for more complex cases. References[1] Frame behaviors in SwiftUI: https://swiftui-lab.com/frame-behaviors/ [2] Learn about planetary transits: https://exoplanets.nasa.gov/faq/31/whats-a-transit/ [3]makeCache(subviews:): https://developer.apple.com/documentation/swiftui/layout/makecache(subviews:)-23agy [4]updateCache(subviews:): https://developer.apple.com/documentation/swiftui/layout/updatecache(_:subviews:)-9hkj9 [5]SE-0253: https://github.com/apple/swift-evolution/blob/master/proposals/0253-callable.md [6]2022 WWDC Layout session: https://developer.apple.com/videos/play/wwdc2022/10056/ |
<<: The most popular iOS automation testing tools in 2022
>>: Technical Practice of Heterogeneous Hybrid Scheduling in vivo Internet
The data shows that compared with the historical ...
Do you know what happens to your body within 168 ...
In my book "Exploding User Growth ", I ...
When people reach a certain age, it is easy for t...
When it comes to marketing, IKEA has a lot to be ...
The concept of the "second half of the Inter...
Alibaba International Station: How to do cross-bo...
With the advent of the mobile network era, apps h...
When they first enter the workplace, many newcome...
Microsoft released the Surface Laptop notebook eq...
I selected some data and screenshots from my adve...
Although routers have not been very visible to th...
Leo Tolstoy once said, “All great literature can ...
Nowadays, product operation activities are divers...