2

I have a two Text components inside a VStack and I'm using GeometryReader to match their widths.

The problem is if I apply padding AFTER setting frame to the Text with the shorter content, I run into an infinite loop.

I want to understand why this is happening. My expectation is that it should just take the larger width and then apply padding to it.


import SwiftUI

struct ContentView: View {
    @State private var containerWidth: CGFloat?

    var body: some View {
        VStack(alignment: .center) {
            Text("Shorter Text")
                .frame(width: containerWidth, alignment: .center)
                .background { Color.green }
                // this is `padding` is causing an infinite loop
                .padding(.leading, 5)

            Text("Really really long text")
                .background {
                    Color.yellow
                }
        }
        .background {
            Color.red
        }
        .readSize { size in
            containerWidth = size.width
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

extension View {
    func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
        background(
            GeometryReader { geometryProxy in
                Color.clear
                    .preference(key: SizePreferenceKey.self, value: geometryProxy.size)
            }
        )
        .onPreferenceChange(SizePreferenceKey.self, perform: onChange)
    }
}

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

I tried switching around the application of padding. This prevents the infinite recursion of width calculation but does not add the padding.

Marcy
  • 4,611
  • 2
  • 34
  • 52
meowmeowmeow
  • 713
  • 7
  • 17

3 Answers3

1

Let's breakdown why it hangs:

  1. At first 1st Text width = nil, which means it has its own width
  2. 'readSize' sets the width, which forces VStack to resize
  3. VStack got resized, so 'readSize' sets width again

This is how I would do it:

struct ContentView: View {
    @State private var containerWidth: CGFloat?

    var body: some View {
        VStack {
            Text("Shorter Text")
                .readSize { storeWidth($0.width) }
                .frame(width: containerWidth)
                .background { Color.green }

            Text("Really really long text")
                .readSize { storeWidth($0.width) }
                .frame(width: containerWidth)
                .background { Color.yellow }
        }
        .background { Color.red }
    }

    func storeWidth(_ value: CGFloat) {
        containerWidth = max(value, containerWidth ?? -.infinity)
    }
}

Like this 'readSize' will execute once, because Texts don't change their widths. Order of modifiers is important, that's why frame(width:) is put after 'readSize'

Arutyun Enfendzhyan
  • 1,612
  • 1
  • 12
  • 15
1

iOS 16+ Layout Protocol

This may not be the simplest answer to code from scratch but the following is Apple's recommended solution for these types of problems.

"I run into an infinite loop. I want to understand why this is happening."

In WWDC22's "Custom Layout with SwiftUI" (18:18) that is explained:

Geometry Reader is designed to measure its container view and report to its subview. The information flows downward. Sending the information up the view hierarchy is bypassing the Layout engine which can result in a loop. The reader measures the layout and changes the frame, which might change the layout, which could require another measurement and so on.

The Layout Protocol is recommended for this type of problem. Apple provides a solution to a nearly identical problem in the video above starting at 7:40.

The following is an example closely based on their provided code. A struct called MyEqualWidthVStack adopts the Layout Protocol. This is how that layout struct would be used in your example. Notice that each text frame is set to a max width that will fill the available space. MyEqualWidthVStacke will calculate the available space for each Text as the width of the widest text field:

struct LayoutView: View {
    var body: some View {
            MyEqualWidthVStack {
                Text("Shorter Text")
                    .frame(maxWidth: .infinity)
                    .background { Color.green }

                Text("Really really long text")
                    .frame(maxWidth: .infinity)
                    .background { Color.yellow }
            }
            .background { Color.red }
    }
}

The Layout Protocol requires two methods, sizeThatFits and placeSubviews. The sizeThatFits method returns a size that the layout container needs to arrange its subviews. The placeSubviews method places the subviews, in this case, in a vertical stack:

struct MyEqualWidthVStack: Layout {
    /// One of two required methods for the Layout Protocol
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
        guard !subviews.isEmpty else { return .zero }
        
        let maxSize = maxSize(subviews: subviews)
        let spacing = spacing(subviews: subviews)
        let totalSpacing = spacing.reduce(0) { $0 + $1 }
        
        return CGSize(
            width: maxSize.width,
            height: maxSize.height * CGFloat(subviews.count) + totalSpacing)
    }
    
        /// Second of two required methods for the Layout Protocol
    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Void
    ) {
        guard !subviews.isEmpty else { return }
        
        let maxSize = maxSize(subviews: subviews)
        let spacing = spacing(subviews: subviews)
        
        let placementProposal = ProposedViewSize(width: maxSize.width, height: bounds.height)
        var nextY = bounds.minY + maxSize.height / 2
        
        for index in subviews.indices {
            subviews[index].place(
                at: CGPoint(x: bounds.midX, y: nextY),
                anchor: .center,
                proposal: placementProposal)
            nextY += maxSize.height + spacing[index]
        }
    }
    
        /// Finds the largest ideal size of the subviews.
    private func maxSize(subviews: Subviews) -> CGSize {
        let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
        let maxSize: CGSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in
            CGSize(
                width: max(currentMax.width, subviewSize.width),
                height: max(currentMax.height, subviewSize.height))
        }
        
        return maxSize
    }
    
        /// Gets an array of preferred spacing sizes between subviews in the
        /// vertical dimension.
    private func spacing(subviews: Subviews) -> [CGFloat] {
        subviews.indices.map { index in
            guard index < subviews.count - 1 else { return 0 }
            
            return subviews[index].spacing.distance(
                to: subviews[index + 1].spacing,
                along: .vertical)
        }
    }
}

Result:

enter image description here

Marcy
  • 4,611
  • 2
  • 34
  • 52
1

To make two views the same width in a VStack (or other container), there's no need for a GeometryReader, just use

.fixedSize(horizontal: true, vertical: false)

on the container. In the code below I've added .frame(maxWidth: .infinity) modifiers to each Text to make sure they take up all available space.

struct ContentView: View {
    
    var body: some View {
        VStack {
            Text("Shorter Text")
                .frame(maxWidth: .infinity)
                .background(.green)
                .padding(.leading, 5)
            Text("Really really long text")
                .frame(maxWidth: .infinity)
                .background(.yellow)
        }
        .fixedSize(horizontal: true, vertical: false)
        .background(.red)
    }
}

enter image description here

Here's the same but with .padding(.leading, 5) before the .frame modifier

enter image description here

Ashley Mills
  • 50,474
  • 16
  • 129
  • 160
  • 1
    Such an elegant solution and seems to work well with other UI types, orientations and all iOS13+ versions. Kudos. – Marcy Feb 23 '23 at 17:42