Frame Behaviors with SwiftUI

In this post, I am going to talk about .frame(). Seriously. I know it may seem like a boring and simple topic. If you follow my blog, you’ve probably noticed that I try to post only advanced SwiftUI techniques. You may be surprised, but .frame() is far from basic. It has some interesting behaviors and options that we will explore in this article.

When I started using SwiftUI, one of the first modifiers I learned was .frame(width:height:alignment), and then moved to other (more interesting) things. A while later, I realized that I wasn’t using its full potential, so I decided to revisit it.

If you wonder how come some views like to grow, others like to shrink, and some just don’t care… you’re in the right place. We’ll explore that in detail, and we will replicate some of those behaviors in our own custom views.

What Is a Frame

We will see the term “frame” being used quite often, but defining it, is one of the most challenging tasks with SwiftUI. There’s a lot going on when we use modifiers (such as .frame()).

Modifiers in SwiftUI do not actually modify views. Most of the time, when we apply a modifier on a view, a new view is created that is wrapped around the view being “modified”. It is that wrapper view that we must think of as the “frame”.

And although views do not have frames (at least not in the UIKit/AppKit sense), they do have bounds. These bounds are not directly manipulable. They are an emergent property of the view and its parents.

The frame may or may not have an effect on the size of its child. We will see later that different views may have different ways of behaving. For example, some extend to occupy the whole frame, others won’t.

The methods we’ll explore in this post are .frame(), .fixedSize() and .layoutPriority().

Behaviors

In a previous version of this article, I used to classify views in 4 categories that I named: generous, well-behaved, selfish, and badly-behaved. It has been brought to my attention, that using these terms may lead the reader to assume that some of these types might be undesirable. That was not my intention, I just wanted to provide a fun mental picture to easily remember them. But in the sake of clarity, I’ll discard those names and just describe the behaviors:

I found that we can group views by the way they react to the offered space:

  • Views that will only take as much space as needed to fit their own content. Stack containers are one example.
  • Views that will take only what they need. And if what they’ve been offered is not enough to draw all the contents, they will do their best to respect the offered space. Text views are a mixed example: If not enough horizontal space is available they will either truncate or wrap the text. However vertically, at least one line of text will be shown, irrespective of how small the frame might be.
  • Views that will grow to fill all the space offered (but not a pixel more). Shapes are usually a good example, such as Rectangle().
  • Views that may decide to draw even outside the area offered by the parent. Some custom views can use this approach. For example, a speech balloon may draw the little tail outside its bounds.

Note that a view may behave differently on each axis. For example, a Spacer inside a VStack will probably take all it can vertically, but will not use any space horizontally. Having said that, there are cases when the behavior in one axis will be affected by the other. Such an example is the Text view, as its height may depend on the proposed width.

By using the .frame() modifier, we are not altering the behavior directly, but the context in which they work, resulting in different behavior.

The Most Basic Frame

The most basic way in which we use frame(), is with this method:

func frame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center)

The alignment parameter we analyzed already in Alignment Guides in SwiftUI. If you need information on how the alignment parameter affects layout, please refer to that article.

When we use this method, it may seem we are forcing the view’s width and height. That is usually the visual effect we achieve. However, that is not what is going on. What we are really doing, is changing the offered size. What the view will do with it, will be up to the view itself. Most views will adjust to the new offered size, which may lead us to the false assumption that we forced the size of the view. We did not.

Consider the following example. We are using .frame() to change what’s offered to the child. Still, the subview will not take it, if it is below 120. The blue border, however, does show the frame (i.e., the wrapper we talked about at the beginning of this article), is indeed forced to the new size.

frame modifier
struct ExampleView: View {
    @State private var width: CGFloat = 50
    
    var body: some View {
        VStack {
            SubView()
                .frame(width: self.width, height: 120)
                .border(Color.blue, width: 2)
            
            Text("Offered Width \(Int(width))")
            Slider(value: $width, in: 0...200, step: 1)
        }
    }
}


struct SubView: View {
    var body: some View {
        GeometryReader { proxy in
            Rectangle()
                .fill(Color.yellow.opacity(0.7))
                .frame(width: max(proxy.size.width, 120), height: max(proxy.size.height, 120))
        }
    }
}

Note that these parameters can be left unspecified or set to nil. In that case, the child will receive the original offer (for that axis) as if we didn’t call frame() at all.

Most of the time, we use this version of the frame() modifier. However, there is a much more powerful version of the same modifier that merits some explaining.

Altering Behaviors

The other frame() modifier looks like this:

func frame(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .center)

Let’s break it down. We have three groups of parameters. Those that affect width, those that affect height, and the alignment parameter. Again, information about the alignment can be found in my other article: Alignment Guides in SwiftUI.

Of the other two groups, one deals with width and the other with height. Also note that parameters can be left unspecified, in which case we will observe different behaviors.

Each group has three parameters: minimum, ideal, and maximum. These must be given values in ascending order or left unspecified. That is, the minimum parameter cannot be larger than the ideal parameter, and the ideal parameter cannot be larger than the maximum parameter. In the past, failing to follow these guidelines would cause a crash. Now SwiftUI will produce some “unspecified and reasonable” results, but will still log a message (“Contradictory frame constraints specified“), to let you know you did something wrong.

So, what does this method do? It positions the view within an invisible frame with the specified width and height constraints. In detail:

  • The size proposed to the frame’s child, will be the size proposed to the frame, limited by any constraints specified, with any unspecified dimensions in the proposal replaced by corresponding ideal dimensions, if specified.
  • The size of the frame in any dimension follows this logic:
Frame Flow Chart

Fixed Size Views

The .fixedSize() method exists in two versions:

func fixedSize() -> some View
func fixedSize(horizontal: Bool, vertical: Bool) -> some View

The first option is just a quick way of calling:

fixedSize(horizontal: true, vertical: true)

When we set fixed size for a given axis, the view will be offered its ideal size (i.e., the dimension specified in the idealWidth/idealHeight parameters of the .frame() modifier).

Note that views always have an ideal dimension. However, you may call .frame(idealWidth:idealHeight), in order to create a similar view, but with different ideal dimensions.

One view with interesting ideal dimensions is the Text view. The Text view uses the text string and font, in order to come up with the ideal size.

Let see an example. The following code will truncate the text because the parent is not big enough. The green border shows the bounds of the frame, and the blue border shows the bounds of the text.

struct ExampleView: View {
    var body: some View {
        Text("hello there, this is a long line that won't fit parent's size.")
            .border(Color.blue)
            .frame(width: 200, height: 100)
            .border(Color.green)
            .font(.title)
    }
}

However, if we let our text view use as much width as needed, this is the result:

struct ExampleView: View {
    var body: some View {
        Text("hello there, this is a long line that won't fit parent's size.")
            .fixedSize(horizontal: true, vertical: false)
            .border(Color.blue)
            .frame(width: 200, height: 100)
            .border(Color.green)
            .font(.title)
    }
}

This animation shows the difference between using fixedSize or leaving it unspecified. When we set the size as fixed, the Text view can expand and show all its glory.

fixedSize animation

A Practical Case

In the following example, we are going to replicate the Text behavior, but with our own custom view.

We will call it LittleSquares and it will receive a parameter with the number of squares to draw in a single row. All squares will be 20×20 in size and green in color. However, when the width offered by the parent constraints the view, we want to only draw as many squares as possible, and change the color to red, to indicate there are squares missing. This is the equivalent of truncating a text view and showing the ellipses (…) character at the end.

Additionally, we want our LittleSquares view not to grow beyond what is actually necessary.

And finally, our view must set its own ideal width, so when the parent uses .fixedSize(), SwiftUI knows how much it needs to grow.

In order to solve all those problems, we simply need to make sure we use GeometryReader to get the size of what has been offered. With the proxy width we know how many squares we can draw. If there are less than total, we paint them in red, or green otherwise.

If you used GeometryReader before, you know that it likes to use all space available to it. In more proper terms: it becomes infinitely resizable in both directions. That is the reason why we need to constrain it.

Note that ideal and maximum width will be equal. We don’t want our view to grow beyond its ideal size.

struct LittleSquares: View {
    let total: Int
    
    var body: some View {
        GeometryReader { proxy in

            // draw our view here

        }.frame(idealWidth: ???, maxWidth: ???)
    }
}

I encourage you to pause here and try to write the implementation yourself. That is the best way to learn, but if you are in a hurry, here’s the full code:

struct ExampleView: View {
    @State private var width: CGFloat = 150
    @State private var fixedSize: Bool = true
    
    var body: some View {
        GeometryReader { proxy in
            
            VStack {
                Spacer()
                
                VStack {
                    LittleSquares(total: 7)
                        .border(Color.green)
                        .fixedSize(horizontal: self.fixedSize, vertical: false)
                }
                .frame(width: self.width)
                .border(Color.primary)
                .background(MyGradient())
                
                Spacer()
                
                Form {
                    Slider(value: self.$width, in: 0...proxy.size.width)
                    Toggle(isOn: self.$fixedSize) { Text("Fixed Width") }
                }
            }
        }.padding(.top, 140)
    }
}

struct LittleSquares: View {
    let sqSize: CGFloat = 20
    let total: Int
    
    var body: some View {
        GeometryReader { proxy in
            HStack(spacing: 5) {
                ForEach(0..<self.maxSquares(proxy), id: \.self) { _ in
                    RoundedRectangle(cornerRadius: 5).frame(width: self.sqSize, height: self.sqSize)
                        .foregroundColor(self.allFit(proxy) ? .green : .red)
                }
            }
        }.frame(idealWidth: (5 + self.sqSize) * CGFloat(self.total), maxWidth: (5 + self.sqSize) * CGFloat(self.total))
    }

    func maxSquares(_ proxy: GeometryProxy) -> Int {
        return min(Int(proxy.size.width / (sqSize + 5)), total)
    }
    
    func allFit(_ proxy: GeometryProxy) -> Bool {
        return maxSquares(proxy) == total
    }
}

struct MyGradient: View {
    var body: some View {
        LinearGradient(gradient: Gradient(colors: [Color.red.opacity(0.1), Color.green.opacity(0.1)]), startPoint: UnitPoint(x: 0, y: 0), endPoint: UnitPoint(x: 1, y: 1))
    }
}

Layout Priority

You may have used the .layourPriority() method before. Most of the time we use it as a quick fix when things do not go our way. But let’s pause, and analyze what it does and how it does it.

When multiple siblings are competing for space, the parent will divide the available space by the number of siblings and offer it to the first child, then it will deduct what it took, and continue to the next.

This simple approach can be altered, by changing siblings’ priorities, using the .layoutPriority() method. It has a single parameter, that determines how the parent will prioritize space.

The default layout priority of all views is zero.

To calculate how much space a child will be offered, the parent will follow a logic similar to the one before, but grouping views by priorities and distributing space first to the views with a higher priority number.

In Summary

I hope you found this article useful. The frame method is one of the first modifier we use with SwiftUI, but because of that, we usually do not understand it fully. Once you have gained some experience, It is worth coming back to it and understanding every parameter it has to offer.

Feel free to leave a comment below, and follow me on twitter if you would like to be notified when new articles get published. Until next time.

14 thoughts on “Frame Behaviors with SwiftUI”

  1. Beautiful job as usual, Javier. I must admit my solution got stuck on producing the squares with something like:
    ForEach(0.. CGFloat {
    let tf = CGFloat(total)
    let totalSquareWidth = tf * squareWidth
    let totalSpaceWidth = (tf – 1) * spacing
    return totalSquareWidth + totalSpaceWidth
    }
    Keep it up–I never miss a post!

    Reply
    • Thanks. Yes, that part is tricky. The problem is, since beta 4 or 5, ranges are only supported if they are static. So you can only use them if you know the size of your range will never change. It is somewhere in the release notes. That is why I created an inline array instead. 😉

      Reply
      • ForEach currently has 3 constructors. The one that only takes a Range assumes the range is constant. But there is also one that takes a RandomAccessCollection and an id Keypath that will reevaluate when the collection changes. That’s the one that you use with the Array.init, but it will allocate an array every time the geometry changes. However, integer ranges are also RandomAccessCollections, so you can just write

        ForEach(0..<self.maxSquares(proxy), id: \.self) { … }

        Just make sure you don't forget the id. It's an unfortunate API design.

        Anyways, great series of articles, Javier!

        Reply
  2. Hello Javier, Thanks for your great article. But for me it’s too hard.

    I just wonder about first example(Yellow Rectangle and blue border).

    I couldn’t understand what is going on exactly.

    Why do blue borders and yellow rectangle react differently to the same width value?

    Reply
    • The example is showcasing the fact that the child (e.g., SubView) can ignore what’s being offered by the parent. In the example, the yellow rectangle is the child view and the blue border is what has been offered. For values greater than 120 the child honors the parent’s wishes, but for values lower than 120, the child ignores it. That is what creates the discrepancy between the two views.

      Note that the .border() method is called AFTER the .frame() call, that is how you manage to “see” both sizes.

      Reply
  3. Amazing article! And I liked the task it featured a lot! Spent more than hour trying to do it (instead of playing The Last of Us Part II). It was challenging, but I have learned a lot! My solution was very close to yours (after second iteration). Thanks! 🤜🤛

    Reply

Leave a Comment

By continuing to use the site, you agree to the use of cookies. more information

The cookie settings on this website are set to "allow cookies" to give you the best browsing experience possible. If you continue to use this website without changing your cookie settings or you click "Accept" below then you are consenting to this.

Close