12
votes

Dave Abrahams explained some of the mechanics of SwiftUI layouts in his WWDC19 talk about Custom Views, but he left out some bits and I have trouble getting my views properly sized.

Is there a way for a View to tell its container that it is not making any space demands, but it will use all the space it is given? Another way to say it is that the container should hug its subviews.

Concrete example, I want something like c:

Some Texts inside a VStack

If you have some Texts inside a VStack like in a), the VStack will adopt it's width to the widest subview.

If you add a Rectangle though as in b), it will expand as much as it can, until the VStack fills its container.

This indicates that Texts and Rectangles are in different categories when it comes to layout, Text has a fixed size and a Rectangle is greedy. But how can I communicate this to my container if I'm making my own View?

The result I actually want to achieve is c). VStack should ignore Rectangle (or my custom view) when it determines its size, and then once it has done that, then it should tell Rectangle, or my custom view, how much space it can have.

Given that SwiftUI seems to layout bottom-up, maybe this is impossible, but it seems that there should be some way to achieve this.

2
I think you can get it to work by getting all text view widths, calculating the biggest and use it to set the frame width of the rectangle. Let me see if I can make it work and I'll post the answer.kontiki
Yeah using your trick it should be possible. I would even put the textviews as Content in a CustomView and let the CustomView add the rectangle. But it seems like there should be some modifier or something to tell views to behave in this way!Gusutafu

2 Answers

8
votes

There is no modifier (AFAIK) to accomplish this, so here's my approach. If this is something you are going to use too often, it could be worth creating your own modifier.

Also note that here I am using standard preferences, but anchor preferences are even better. It is a heavy topic to explain here. I've written an article that you can check here: https://swiftui-lab.com/communicating-with-the-view-tree-part-1/

You can use the code below to accomplish what you are looking for.

import SwiftUI

struct MyRectPreference: PreferenceKey {
    typealias Value = [CGRect]

    static var defaultValue: [CGRect] = []

    static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) {
        value.append(contentsOf: nextValue())
    }
}

struct ContentView : View {
    @State private var widestText: CGFloat = 0

    var body: some View {
        VStack {
            Text("Hello").background(RectGetter())
            Text("Wonderful World!").background(RectGetter())
            Rectangle().fill(Color.blue).frame(width: widestText, height: 30)
            }.onPreferenceChange(MyRectPreference.self, perform: { prefs in
                for p in prefs {
                    self.widestText = max(self.widestText, p.size.width)
                }
            })
    }
}

struct RectGetter: View {

    var body: some View {
        GeometryReader { geometry in
            Rectangle()
                .fill(Color.clear)
                .preference(key: MyRectPreference.self, value: [geometry.frame(in: .global)])
        }
    }
}
5
votes

So I actually found a way to do this. First I tried putting Spacers around the views in various configurations, to try to push it together, but that didn't work. Then I realised I could perhaps use the .background modifier, and that actually did work. It seems to let the owning view calculate its size first, and then just takes that as its frame, which is exactly what I want.

This is just an example with some hacks to get the right height, but that is a small detail, and in my particular use case it is not needed. Probably not here either if you're clever enough.

var body: some View {
    VStack(spacing: 10) {
        Text("Short").background(Color.green)
        Text("A longer text").background(Color.green)
        Text("Dummy").opacity(0)
    }
    .background(backgroundView)
    .background(Color.red)
    .padding()
    .background(Color.blue)
}

var backgroundView: some View {
    VStack(spacing: 10) {
        Spacer()
        Spacer()
        Rectangle().fill(Color.yellow)
    }
}

The blue view and all the color backgrounds are of course just to make it easier to see. This code produces this:

enter image description here