In SwiftUI, is there a simple way to size the children with non-fixed size as a factor of remaining space available? (Flex Factor)

Viewed 224

Here's what I need to do: I have a parent view with a fixed width and 4 children. 2 of the children (1st and 3rd) have fixed width. The others (2nd and 4th) have flexible width. The default interpretation for flexible is that they would share equally the space available, but I don't want this, instead, I want to tell each flexible View how much of the remaining space it should take, for instance, the first should take 2/3 and the second 1/3.

For people familiar with the Flex Layout, this is equivalent to say I want my 2nd View to have flex: 2 and my 4th view to have flex: 1. See the example below:

visual example

In the example above, the parent view has the fixed width of 600px. The first child has 100px width, the third has 140px. The remaining space is 360px, so the second child gets 240px (2/3) and the fourth child gets 120px(1/3). If I change the width of the parent view, the size of the second and fourth children will adapt accordingly.

It is extremely simple to do this for the Web, see the code below:

<div style="width: 600; display: flex; flex-direction: row;">
  <div style="width: 100; height: 100; background-color: red;"></div>
  <div style="flex: 2; height: 100; background-color: green;"></div>
  <div style="width: 140; height: 100; background-color: blue;"></div>
  <div style="flex: 1; height: 100; background-color: purple;"></div>
</div>

It is also super-simple to do it in Flutter:

Row(
  children: [
    Container(height: 50, width: 50, color: Colors.red),
    Flexible(flex: 2, child: Container(height: 50, color: Colors.green)),
    Container(height: 50, width: 80, color: Colors.blue),
    Flexible(flex: 1, child: Container(height: 50, color: Colors.purple)),
  ]
)

It's very similar in Jetpack Compose (Android):

Row {
  Row(modifier = Modifier.height(50.dp).width(50.dp).background(color = Color.Red)) {}
  Row(modifier = Modifier.height(50.dp).weight(2F).background(color = Color.Green)) {}
  Row(modifier = Modifier.height(50.dp).width(80.dp).background(color = Color.Blue)) {}
  Row(modifier = Modifier.height(50.dp).weight(1F).background(color = Color.Black)) {}
}

I tried a lot of things, but I couldn't find any simple way of achieving this behavior in SwiftUI. From my understanding, to size the children, SwiftUI first calculates the size of every child that can have its size calculated, i.e. every child that has a fixed size, after that it calculates the remaining space and divide it by the number of children with an undefined size telling each of them this is the maximum size they can occupy (this is the size we read when using a GeometryReader). Is there a way to tell SwiftUI to not divide the space available equally among the children with undefined size, but instead divide it according to a factor specified by each one of them?

Is there a different approach to this in a way I can obtain the same result as the other platforms I mentioned here?

For now, my strategy is much more complex than I'd like it to be. I render the first child with width = 50, the second child with width = 0 (should be flexible), the third child with width = 80, the fourth child with width = 0 (should be flexible) and an additional 5th child with width = .infinity. After the first render, I measure the size of the 5th child and update a state with this value, which represents the remaining space after the children with fixed size have been placed. After the state is updated, I can calculate the sizes of the flexible children and render everything a second time, now with the correct sizes. See the code below:

struct SizePreferenceKey: PreferenceKey {
  static var defaultValue: CGSize = .zero
  static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}

struct ContentView: View {
    private let totalFlexFactor = 3
    @State private var freeSpace: CGSize? = nil
    
    func getFlexibleWidth(parentWidth: CGFloat, factor: Int) -> CGFloat {
        return (CGFloat(factor) / CGFloat(totalFlexFactor)) * (freeSpace?.width ?? 0)
    }
    
    var body: some View {
        GeometryReader { parentGeometry in
            HStack(spacing: 0) {
                HStack() {}
                    .frame(maxWidth: 50, maxHeight: .infinity)
                    .background(Color.red)

                HStack() {}
                    .frame(maxWidth: getFlexibleWidth(parentWidth: parentGeometry.size.width, factor: 2), maxHeight: .infinity)
                    .background(Color.green)
                
                HStack() {}
                    .frame(maxWidth: 80, maxHeight: .infinity)
                    .background(Color.blue)
                
                HStack() {}
                    .frame(maxWidth: getFlexibleWidth(parentWidth: parentGeometry.size.width, factor: 1), maxHeight: .infinity)
                    .background(Color.purple)
                
                if (freeSpace == nil) {
                    HStack() {}
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                        .background(
                          GeometryReader { geometryProxy in
                            Color.clear
                                  .preference(key: SizePreferenceKey.self, value: geometryProxy.size)
                          }
                        )
                }
            }
            .frame(maxWidth: .infinity, maxHeight: 50)
            .background(Color.gray)
            .onPreferenceChange(SizePreferenceKey.self) { newSize in
                if (freeSpace == nil) {
                    freeSpace = newSize
                }
            }
        }
    }
}

Credits to Federico Zanetello for the code that measures the size of the children.

As I said before, I don't see this as a very elegant solution, mainly if we compare it to the other platforms. I'd appreciate any suggestion! Just keep in mind the example I gave: fixed sized views intercalated with flexibly sized views.


Edit: I'm trying to build a component called Flexbox. In my example, I set the widths of each view, but it's just for simplicity. In fact, inside the Flexbox component, I don't know the size of any of the children, that's why, in my solution, I had to measure them.

Example: I might call:

Flexbox {
  ViewWith100Width()
  Flexible(flex: 2) { ViewWithFlexibleSize() }
  ViewWith80Width()
  Flexible(flex: 1) { AnotherViewWithFlexibleSize() }
}

But I could also call:

Flexbox {
  ViewWith40Width()
  Flexible(flex: 2) { ViewWithFlexibleSize() }
  ViewWith60Width()
  Flexible(flex: 1) { AnotherViewWithFlexibleSize() }
}

The component Flexbox must be agnostic of the size of whichever child is passed to it. That's why I had to measure each non-flexible child.

I haven't mentioned this in the original formulation of the question because I didn't want to overcomplicate it, since I just want to know if there's an easier way to simulate the Flexbox behavior of web/flutter/compose in swiftui.

2 Answers

While I think PreferenceKeys are a great tool to have and use, this is not a case where they are needed. Your plan was overly complicated. This comes down to a simple math problem, but first there is a coding issue:

.frame(maxWidth: 50, maxHeight: .infinity)

gives you a flexible, not a fixed width. What you have told ViewBuilder to do is assign to the width of the frame any value up to 50, not a value of 50. I understand what you were doing, as you wanted to expand the height to the size of the view container. There are two alternatives:

.frame(minWidth: 50, maxWidth: 50, maxHeight: .infinity)

or

.frame(width: 50)
.frame(maxHeight: .infinity)

I chose the latter as it makes your intention very clear. Since they were fixed, I also assigned them to let constants to be used anywhere I needed them.

Also, while I did not test this, I suspect the PreferenceKey couldn't handle the disappearing/reappearing view. Since they were unnecessary, it didn't delve into it.

At this point, you can find your flexible space simply by parentGeometry less the two fixed widths. No PreferenceKeys needed. in the end, the code simplifies to:

struct ContentView: View {
    private let totalFlexFactor: CGFloat = 3
    @State private var freeSpace: CGSize? = nil
    private let width1: CGFloat = 100
    private let width2: CGFloat = 80
    
    private func getFlexibleWidth(parentWidth: CGFloat, factor: CGFloat) -> CGFloat {
        ((parentWidth - (width1 + width2)) / totalFlexFactor) * factor
    }
    
    var body: some View {
        GeometryReader { parentGeometry in
            HStack(spacing: 0) {
                HStack {
                    Text(width1.description)
                }
                    .frame(width: width1)
                    .frame(maxHeight: .infinity)
                    .background(Color.red)
                
                HStack {
                    Text(getFlexibleWidth(parentWidth: parentGeometry.size.width, factor: 2).description)
                }
                    .frame(maxWidth: getFlexibleWidth(parentWidth: parentGeometry.size.width, factor: 2), maxHeight: .infinity)
                    .background(Color.green)
                
                HStack {
                    Text(width2.description)
                }
                    .frame(width: width2)
                    .frame(maxHeight: .infinity)
                    .background(Color.blue)
                
                HStack {
                    Text(getFlexibleWidth(parentWidth: parentGeometry.size.width, factor: 1).description)
                }
                    .frame(maxWidth: getFlexibleWidth(parentWidth: parentGeometry.size.width, factor: 1), maxHeight: .infinity)
                    .background(Color.purple)
                
            }
            .frame(maxWidth: .infinity, maxHeight: 50)
            .background(Color.gray)
        }
    }
}

I also displayed the values for the widths of the various HStacks in the HStacks.

Also, with regard to SwiftUI syntax, it is not necessary to write HStack() unless you intend to use the optional values of alignment: and spacing:, otherwise we omit the ().

The main idea in to layout is where to use needed containers. Here (fixed-flex-fixed-flex) GeometryReader can be used only for three.

Tested with Xcode 13.3 / iOS 15.4

demo

Main idea part is:

GeometryReader { gp in
    HStack (spacing: 0) {
        Color.green
            .frame(width: (gp.size.width - fixed2) * 2/3)
        Color.blue
            .frame(width: fixed2)

Completed findings and report is here

Related