SwiftUI Layout: The Mystery of Size

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In SwiftUI, the concept of size, which is extremely important in layout, seems to have become somewhat mystifying. Setting sizes or obtaining sizes is not as intuitive as one would expect. This article will unveil the veil covering the SwiftUI size concepts from the perspective of layout, helping you understand and master the meanings and uses of the many sizes in SwiftUI. It will also provide you with a deeper understanding of the SwiftUI layout mechanism by creating copies of the frame and fixedSize view modifiers that conform to the Layout protocol.

Size —— A Deliberately Obscured Concept

SwiftUI is a declarative framework that provides powerful automatic layout capabilities. Developers can create beautiful, exquisite, and accurate layout effects almost without involving the concept of size (or with very little involvement).

However, even today, years after the birth of SwiftUI, how to obtain the size of a view remains a hot topic on the Internet. At the same time, for many developers, using the frame modifier to set sizes for views often produces results that differ from their expectations.

This does not mean that size is unimportant in SwiftUI. On the contrary, it is precisely because size is a very complex concept in SwiftUI that Apple has hidden most of the size configurations and representations under the hood, deliberately packaging and downplaying it.

The original intention of downplaying the concept of size may be based on the following two points:

  • Guide developers to transition to declarative programming logic and change the habit of using accurate sizes
  • Conceal the complex size concepts in SwiftUI to reduce confusion for beginners

However, no matter how it is obscured or concealed, when it comes to more advanced, complex, and precise layouts, size is an indispensable link. As your understanding of SwiftUI increases, it is imperative to learn and master the many size meanings in SwiftUI.

Overview of SwiftUI Layout Process

SwiftUI’s layout is the behavior of the layout system calculating the required size and placement position for each view (rectangle) on the view tree by providing necessary information to the nodes.

Swift
struct ContentView: View {
    var body: some View {
        ZStack {
            Text("Hello world")
        }
    }
}
// ContentView
//     |
//     |———————— ZStack
//                 |
//                 |—————————— Text

Taking the code above as an example (ContentView is the root view of the app), let’s briefly describe the SwiftUI layout process (current device is iPhone 13 Pro):

  1. SwiftUI’s layout system provides a proposed size (390 x 763 which is the screen size minus safe area) to ZStack and asks for ZStack’s required size.
  2. ZStack provides proposed size (390 x 763) to Text and asks for Text’s required size.
  3. According to the proposed size provided by ZStack, Text returns its own required size (85.33 x 20.33, because the proposed size given by ZStack is greater than the actual need of Text, so Text’s required size is the complete display size without line wrap or omission).
  4. ZStack returns its required size (85.33 x 20.33) to SwiftUI’s layout system, because there is only one child view Text in ZStack, so Text’s required size is ZStack’s required size.
  5. SwiftUI’s layout system places ZStack at 152.33, 418.33 and provides it a layout size of (85.33 x 20.33).
  6. ZStack places Text at 152.33, 418.33 and provides it a layout size of (85.33 x 20.33).

The layout process is basically divided into two stages:

  • First stage - Negotiation

    In this stage, the parent view provides proposed sizes to child views, and child views return required sizes to parent (steps 1-4 above). This corresponds to the sizeThatFits method in the Layout protocol. After this negotiation phase, SwiftUI will determine the on-screen position and size for each view.

  • Second stage - Placing subviews

    In this stage, the parent view sets layout positions and sizes for child views based on the screen area provided by SwiftUI’s layout system (calculated in the first stage) (steps 5-6 above). This corresponds to the placeSubviews method in the Layout protocol. At this point, each view on the view tree is associated with a specific position on the screen.

The number of negotiations is proportional to the complexity of the view structure. The entire negotiation process may occur multiple times or even start over.

Containers and Views

When reading the SwiftUI layout series, you may be confused about some of the terms used. Sometimes it’s referred to as a parent view, other times as a layout container. What is the relationship between them? Are they the same thing?

In SwiftUI, only components that conform to the View protocol can be processed by the ViewBuilder. Therefore, any type of layout container will ultimately be wrapped and appear in the code in the form of a View.

For example, here is the constructor of VStack, where the content is passed to the actual layout container _VStackLayout for layout:

Swift
public struct VStack<Content>: SwiftUI.View where Content: View {
    internal var _tree: _VariadicView.Tree<_VStackLayout, Content>
    public init(alignment: SwiftUI.HorizontalAlignment = .center, spacing: CoreFoundation.CGFloat? = nil, @SwiftUI.ViewBuilder content: () -> Content) {
        _tree = .init(
            root: _VStackLayout(alignment: alignment, spacing: spacing), content: content()
        )
    }
    public typealias Body = Swift.Never
}

In addition to familiar layout views such as VStack, ZStack, and List, there are many layout containers in SwiftUI that exist in the form of view modifiers. For example, here is the definition of frame in SwiftUI:

Swift
public extension SwiftUI.View {
    func frame(width: CoreFoundation.CGFloat? = nil, height: CoreFoundation.CGFloat? = nil, alignment: SwiftUI.Alignment = .center) -> some SwiftUI.View {
        return modifier(
            _FrameLayout(width: width, height: height, alignment: alignment))
    }
}

public struct _FrameLayout {
    let width: CoreFoundation.CGFloat?
    let height: CoreFoundation.CGFloat?
    init(width: CoreFoundation.CGFloat?, height: CoreFoundation.CGFloat?, alignment: SwiftUI.Alignment)
    public typealias Body = Swift.Never
}

_FrameLayout is wrapped as a view modifier and applied to the given view.

Swift
Text("Hi")
    .frame(width: 100,height: 100)

// Can be considered as

_FrameLayout(width: 100,height: 100,alignment: .center) {
    Text("Hi")
}

At this point, _FrameLayout is both the parent view of Text and the layout container.

For views that do not contain child views (such as element views like Text), they also provide interfaces for the parent view to call in order to pass proposed sizes and obtain required sizes. Although most views in SwiftUI currently do not adhere to the Layout protocol, the layout system of SwiftUI has been following the layout process provided by the Layout protocol since its inception. The Layout protocol simply wraps the internal implementation process into callable interfaces for developers, making it easier for us to develop custom layout containers.

Therefore, to simplify the text, in the article, we will equate parent views with layout-capable containers.

However, it is important to note that in SwiftUI, there is a type of view that appears as a parent view in the view tree but does not have layout capabilities. Representative examples of these views include Group and ForEach. The main purposes of these views are:

  • Breaking the limit on the number of ViewBuilder blocks
  • Conveniently applying view modifiers to a group of views
  • Facilitating code management
  • Other special applications, such as ForEach supporting a dynamic number of child views

For example, in the initial example of this article, SwiftUI treats ContentView as a presence similar to Group. These views themselves do not participate in layout, and SwiftUI’s layout system automatically ignores them during layout, allowing their child views to be directly connected to layout-capable ancestor views.

Sizes in SwiftUI

As mentioned above, in the layout process of SwiftUI, the concept of size is constantly changing at different stages and for different purposes. This section will provide a more detailed introduction to the sizes involved in the layout process, with reference to the Layout protocol in SwiftUI 4.0.

Even if you are not familiar with the Layout protocol or cannot use SwiftUI 4.0 in the short term, it will not affect your reading and understanding of the following text. Although the main purpose of the Layout protocol is to allow developers to create custom layout containers, and only a few views in SwiftUI conform to this protocol, the layout mechanism of SwiftUI views has been basically consistent with the process implemented by the Layout protocol since SwiftUI 1.0. It can be said that the Layout protocol is an excellent tool for observing and verifying the operation principles of SwiftUI layout.

Proposed Size

The layout in SwiftUI is performed from the outside to the inside. The first step in the layout process is for the parent view to provide a proposed size to the child view. As the name suggests, the proposed size is a suggestion provided by the parent view to the child view. Whether the child view considers the proposed size when calculating its required size depends entirely on its own behavior settings.

Taking a child view as an example of a custom layout container that conforms to the Layout protocol, the parent view provides a proposed size to the child view by calling the child view’s sizeThatFits method. The type of the proposed size is ProposedViewSize, with both width and height being of type Optional<CGFloat>. The custom layout container then provides a proposed size to its child view delegate (Subviews, the representation of child views in the Layout protocol) by calling the sizeThatFits method of its child view delegate in its own sizeThatFits method. The proposed size is provided in both stages of the layout process (negotiation and placement of subviews), but we usually only need to use it in the first stage (we can use catch to save intermediate calculation data in the first stage, reducing the computational load in the second stage).

Swift
// Code from My_ZStackLayout

// The parent view (parent container) of the container will obtain the required size of the container by calling the container's sizeThatFits method. This method is usually called multiple times and provides different proposed sizes.
func sizeThatFits(
    proposal: ProposedViewSize, // The proposed size provided by the parent view (parent container) of the container
    subviews: Subviews, // The proxies of all subviews in the current container
    cache: inout CacheInfo // Cache data, used in this example to store the required sizes returned by the subviews to reduce the number of calls
) -> CGSize {
    cache = .init() // Clear the cache
    for subview in subviews {
        // Provide a proposed size for the subview and obtain its required size (ViewDimensions)
        let viewDimension = subview.dimensions(in: proposal)
        // Obtain the alignmentGuide of the subview based on the alignment setting of MyZStack
        let alignmentGuide: CGPoint = .init(
            x: viewDimension[alignment.horizontal],
            y: viewDimension[alignment.vertical]
        )
        // Create a CGRect for the subview with the alignmentGuide as (0,0) in the virtual canvas
        let bounds: CGRect = .init(
            origin: .init(x: -alignmentGuide.x, y: -alignmentGuide.y),
            size: .init(width: viewDimension.width, height: viewDimension.height)
        )
        // Save the data of the subview in the virtual canvas
        cache.subviewInfo.append(.init(viewDimension: viewDimension, bounds: bounds))
    }

    // Generate the CGRect of MyZStack based on the data of all subviews in the virtual canvas
    cache.cropBounds = cache.subviewInfo.map(\.bounds).cropBounds()
    // Return the ideal size of the current container, which will be used by the parent view of the container to position it internally
    return cache.cropBounds.size
} 

According to the different content of the proposed size, we can divide the proposed size into four proposed modes. In SwiftUI, the parent view will provide the appropriate proposed mode to the child view based on its own requirements. Since different modes can be chosen for width and height separately, the proposed mode specifically refers to the proposed content provided in one dimension.

  • Minimized Mode

    The proposed size in this dimension is 0. ProposedViewSize.zero represents a proposed size with both dimensions in minimized mode. Some layout containers (such as VStack, HStack) provide the minimized mode proposed size to their subview proxies to obtain the minimum required size of the subviews in a specific dimension (e.g., using the minWidth setting).

  • Maximized Mode

    The proposed size in this mode is CGFloat.infinity. ProposedViewSize.infinity represents a proposed size with both dimensions in maximized mode. When the parent view wants to obtain the required size of the subview in the maximum mode, it provides this mode of proposed size.

  • Explicit Size Mode

    A non-zero or non-infinity value. For example, in the example above, ZStack provides a proposed size of 390 x 763 for Text.

  • Unspecified Mode

    nil, no value is set. ProposedViewSize.unspecified represents a proposed size with both dimensions in unspecified mode.

The purpose of providing different proposed modes to the subviews is to obtain the required size of the subviews in that mode. The specific mode used depends entirely on the behavior settings of the parent view. For example, ZStack directly forwards the proposed mode provided by its parent view to its subviews, while VStack and HStack require the subviews to return the required sizes in all modes to determine if the subviews are dynamic views (can dynamically adjust size in a specific dimension).

In SwiftUI, there are many scenarios where secondary layout is performed by setting or adjusting the proposed mode. Some commonly used methods include frame and fixedSize. For example, in the following code, frame ignores the proposed size provided by VStack and forcefully provides a proposed size of 50 x 50 for Text.

Swift
VStack {
    Text("Hi")
       .frame(width: 50,height: 50)
}

Required Size

After receiving the proposed size from the parent view, the child view will return its required size based on the proposed mode and its own behavior characteristics. The type of the required size is CGSize. In most cases, the final required size returned by a custom layout container (conforming to the Layout protocol) in the first phase of layout is consistent with the size of the screen area (CGRect) passed to it by the second phase of the SwiftUI layout system.

Swift
// Code from FixedSizeLayout
// Return required size based on proposed size.
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    guard subviews.count == 1, let content = subviews.first else {
        fatalError("Can't use MyFixedSizeLayout directly")
    }
    let width = horizontal ? nil : proposal.width
    let height = vertical ? nil : proposal.height
    // Obtaining the required size of a subview
    let size = content.sizeThatFits(.init(width: width, height: height))
    return size
}

For example, here are the results of Rectangle() returning the required size in four proposed modes, using the same mode for both dimensions:

  • Minimized Mode

    The required size is 0 x 0.

  • Maximized Mode

    The required size is infinity x infinity.

  • Explicit Size Mode

    The required size is the proposed size.

  • Unspecified Mode

    The required size is 10 x 10 (the reason why it is 10 x 10 will be explained in more detail in the following section).

Text("Hello world") behaves differently from Rectangle when calculating the required size in the four proposed modes:

  • Minimized Mode

    When any dimension is in minimized mode, the required size is 0 x 0.

  • Maximized Mode

    The required size is the actual display size of the Text (without line wrapping or truncation), which is 85.33 x 20.33 (the size mentioned in the previous example).

  • Explicit Size Mode

    If the proposed width is greater than the width needed for single-line display, the required width returns the width needed for single-line display, which is 85.33. If the proposed width is smaller than the width needed for single-line display, the required width returns the proposed width. If the proposed height is smaller than the height needed for single-line display, the required height returns the height needed for single-line display, which is 20.33. If the proposed height is greater than the height needed for single-line display and the width is greater than the width needed for single-line display, the required height returns the height needed for single-line display, 20.33, and so on.

  • Unspecified Mode

    When both dimensions are in unspecified mode, the required size is the width and height needed for complete single-line display, which is 85.33 x 20.33.

It is a fact that different views return different required sizes under the same proposed mode and size, which is both a feature and a potentially confusing aspect of SwiftUI. However, don’t worry too much, as there are generally patterns to follow when determining the required size:

  • Shape

    Except for the unspecified mode, the required size is the same as the proposed size.

  • Text

    The calculation of the required size is more complex and depends on the proposed size and the actual size needed for complete display.

  • Layout containers (ZStack, HStack, VStack, etc.)

    The required size is the total size of the subviews in the container after aligning them according to the specified alignment guides (with dynamic size views already processed). For more details, please refer to Alignment in SwiftUI: Everything You Need To Know.

  • Other controls such as TextField, TextEditor, Picker, etc.

    The required size depends on the proposed size and the actual display size.

In SwiftUI, frame(minWidth:,maxWidth:,minHeight:,maxHeight:) is a typical application for adjusting the required size of a child view.

Layout Size

In the second stage of layout, when the layout system of SwiftUI calls the placeSubviews method of the layout container (conforming to the Layout protocol), the layout container places each subview in the given screen area (the size is usually the same as the required size of the layout container) and sets the layout size for the subview.

Swift
// Code from FixedSizeLayout
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    guard subviews.count == 1, let content = subviews.first else {
        fatalError("Can't use MyFixedSizeLayout directly")
    }

    // Set the layout position and size.
    content.place(at: .init(x: bounds.minX, y: bounds.minY), anchor: .topLeading, proposal: .init(width: bounds.width, height: bounds.height))
}

According to the parent view’s behavior characteristics and the reference child view’s required size, the parent view calculates the layout size for the child view. For example:

  • In ZStack, the layout size set by ZStack for the child view is the same as the child view’s required size.
  • In VStack, the layout size for the child view is calculated based on the proposed size provided by the parent view, whether the child view is an expandable view, and the view priority of the child view, etc. For example, if the total height of the fixed-height child views exceeds the proposed height obtained by VStack, then Spacer can only obtain a layout size of 0 in height.

In most cases, the layout size is the same as the final display size (view size) of the child view, but not always.

SwiftUI does not provide a way to directly manipulate layout sizes in the view (except for the Layout protocol). Generally, we influence the layout size by adjusting the proposed size and required size.

View Size

The size of the view that is presented on the screen after rendering is a frequently asked question - how to obtain the size referred to in the view’s dimensions.

In a view, you can use GeometryReader to obtain the size and position of a specific view.

Swift
extension View {
    func printSizeInfo(_ label: String = "") -> some View {
        background(
            GeometryReader { proxy in
                Color.clear
                    .task(id: proxy.size) {
                        print(label, proxy.size)
                    }
            }
        )
    }
}

VStack {
    Text("Hello world")
        .printSizeInfo() // Print View Size
}

Additionally, we can use the border view modifier to visually compare the sizes of different levels of views.

Swift
VStack {
    Text("Hello world")
        .border(.red)
        .frame(width: 100, height: 100, alignment: .bottomLeading)
        .border(.blue)
        .padding()
}
.border(.green)

https://cdn.fatbobman.com/image-20220711134423997.png

The view size is the result of the layout process. Before the Layout protocol, developers could only implement custom layouts by obtaining the view size of the current view and its subviews. This approach not only had poor performance, but also could cause the view to refresh repeatedly and result in program crashes if the design was incorrect. With the Layout protocol, developers can stand at the perspective of God and calmly layout using information such as proposed size, required size, and layout size.

Ideal Size

The ideal size refers to the required size returned in the unspecified mode for the proposed size. For example, in the previous text, the default ideal size for all shapes set by SwiftUI is 10 x 10, and the default ideal size for Text is the size required to display all content in a single line.

We can use frame(idealWidth:CGFloat, idealHeight:CGFloat) to set the ideal size for a view, and use fixedSize to provide the proposed size in the unspecified mode for a specific dimension of the view to make the ideal size the required size in that dimension.

Before writing this article, I tweeted asking everyone about their understanding of fixedSize.

https://cdn.fatbobman.com/image-20220711140418269.png

https://cdn.fatbobman.com/FW9GLjJVsAAmDXX.jpeg

Swift
Text("Hello world")
    .border(.red)
    .frame(idealWidth: 100, idealHeight: 100)
    .fixedSize()
    .border(.green)

https://cdn.fatbobman.com/image-20220711140000421.png

After understanding the ideal size, I think everyone should be able to infer the layout results of the tweet and the code above.

Application of Sizes

In the previous text, we have already mentioned many tools and methods for setting or obtaining sizes in views. Here is a summary:

  • frame(width: 50, height: 50)

    Provides a proposed size of 50 x 50 for the subview, and returns 50 x 50 as the required size to the parent view.

  • fixedSize()

    Provides a proposed size with an unspecified mode for the subview.

  • frame(minWidth: 100, maxWidth: 300)

    Limits the required size of the subview within the specified range, and returns the adjusted size as the required size to the parent view.

  • frame(idealWidth: 100, idealHeight: 100)

    If the current view receives a proposed size with an unspecified mode, returns a required size of 100 x 100.

  • GeometryReader

    Directly returns the proposed size as the required size (fills the entire available area).

Next

In this article, we introduced various size concepts in SwiftUI. In the next article: Cracking the Size Code, we will further enhance your understanding and mastery of the different size concepts in SwiftUI by creating replicas of frame and fixedSize.

In the article SwiftUI Layout: Cracking the Size Code, we will further enhance everyone’s understanding and mastery of the various size concepts in SwiftUI by creating replicas of frame and fixedSize.

You can get the code for the next section here to get an idea of the content in advance.

Explore weekly Swift highlights with developers worldwide

Buy me a if you found this article helpful