1

Simplest Way to Set Specific Width Ratios for Child Elements in an HStack in SwiftUI

Hello,

I am working with SwiftUI and wondering if there's a straightforward method to set the widths of child elements within an HStack to specific ratios, regardless of the number of characters. For example, I would like the widths of Text1, Text2, and Text3 to have a ratio of 1:2:1 respectively.

Here is the current implementation:

HStack {
    Text("Text1")
    Text("Text2")
    Text("Text3")
}

I understand that using GeometryReader can solve this issue, but I'm looking for a more elegant solution—something similar to applying weights to the frames, such as frame(maxWidth: .infinity, weight: 2)—to smartly distribute the widths among the child elements.

Has anyone found an effective and simpler approach to achieve this?

It’s fine even if using Grid!

Thank you for your assistance!

2 Answers 2

3

A custom Layout can be used for this.

To pass a parameter to a custom layout, you need to define a LayoutValueKey. Let's do this for the layout weight. As a convenience, a view extension can also be defined, for applying the layout weight.

private struct LayoutWeight: LayoutValueKey {
    static let defaultValue = 1
}

extension View {
    func layoutWeight(_ weight: Int) -> some View {
        layoutValue(key: LayoutWeight.self, value: weight)
    }
}

Here is an example Layout implementation which works as follows:

  • A cache is used to save the layout weights of the subviews. The maximum ideal height is also cached.

  • The function sizeThatFits computes the widths for the subviews by dividing the container width in accordance with the weights of the subviews. The size returned by this function is always the full container width, but the height is the maximum height required by the subviews.

  • The function placeSubviews works similarly. Each subview is positioned at the center of the space available to it. There is no spacing between subviews.

struct WeightedHStack: Layout {
    typealias Cache = ViewInfo

    struct ViewInfo {
        let weights: [Int]
        let idealMaxHeight: CGFloat
        var isEmpty: Bool {
            weights.isEmpty
        }
        var nWeights: Int {
            weights.count
        }
        var sumOfWeights: Int {
            weights.reduce(0) { $0 + $1 }
        }
    }

    func makeCache(subviews: Subviews) -> ViewInfo {
        var weights = [Int]()
        var idealMaxHeight = CGFloat.zero
        for subview in subviews {
            let idealViewSize = subview.sizeThatFits(.unspecified)
            idealMaxHeight = max(idealMaxHeight, idealViewSize.height)
            weights.append(subview[LayoutWeight.self])
        }
        return ViewInfo(weights: weights, idealMaxHeight: idealMaxHeight)
    }

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ViewInfo) -> CGSize {
        var maxHeight = cache.idealMaxHeight
        if !cache.isEmpty, subviews.count == cache.nWeights, let containerWidth = proposal.width {
            let unitWidth = containerWidth / CGFloat(cache.sumOfWeights)
            for (index, subview) in subviews.enumerated() {
                let viewWeight = cache.weights[index]
                let w = CGFloat(viewWeight) * unitWidth
                let viewSize = subview.sizeThatFits(ProposedViewSize(width: w, height: nil))
                maxHeight = max(maxHeight, viewSize.height)
            }
        }
        return CGSize(width: proposal.width ?? 10, height: maxHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ViewInfo) {
        if !cache.isEmpty, subviews.count == cache.nWeights {
            let unitWidth = bounds.width / CGFloat(cache.sumOfWeights)
            var minX = bounds.minX
            for (index, subview) in subviews.enumerated() {
                let viewWeight = cache.weights[index]
                let w = CGFloat(viewWeight) * unitWidth
                let viewSize = subview.sizeThatFits(ProposedViewSize(width: w, height: bounds.height))
                let h = viewSize.height
                let x = minX + ((w - viewSize.width) / 2)
                let y = bounds.minY + ((bounds.height - h) / 2)
                subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(width: w, height: h))
                minX += w
            }
        }
    }
}

Some examples of use:

1. Three simple Text views

WeightedHStack {
    Text("Text1").layoutWeight(1).background(.yellow)
    Text("Text2").layoutWeight(3).background(.orange)
    Text("Text3").layoutWeight(1).background(.yellow)
}
.border(.red)

Screenshot

2. Three text views, each with padding and maximum width

WeightedHStack {
    Text("Text1")
        .padding()
        .frame(maxWidth: .infinity)
        .layoutWeight(1)
        .background(.yellow)
    Text("Text2")
        .padding()
        .frame(maxWidth: .infinity)
        .layoutWeight(3)
        .background(.orange)
    Text("Text3")
        .padding()
        .frame(maxWidth: .infinity)
        .layoutWeight(1)
        .background(.yellow)
}
.border(.red)

Screenshot

3. As above, but using a larger text in the middle

let loremIpsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."

Screenshot

Sign up to request clarification or add additional context in comments.

1 Comment

Interesting approach, very different from the one I also just posted. I haven't used Layout before. Saved as a bookmark for later digestion.
0

To calculate the width of a Text child view as a percentage of the HStack parent view, you need to know or access the width of the parent view.

A simple way to get the width of the parent would be to wrap the HStack in a GeometryReader and use the geometry values to calculate the percentage directly in a .frame modifier applied to each child view:

struct WidthPercentageTest: View {
    var body: some View {
        GeometryReader { geometry in
            
            HStack {
                Text("Text1")
                    .frame(width: 0.25 * geometry.size.width ) // 25% of HStack width
                    .border(.red)
                
                Text("Text2")
                    .frame(width: 0.5 * geometry.size.width ) // 50% of HStack width - debug overlay enabled
                    .border(.blue)
                
                Text("Text3")
                    .frame(width: 0.25 * geometry.size.width ) // 25% of HStack width
                    .border(.green) 
            }
        }
    }
}

But, I wouldn't consider this to be very elegant.

A slightly more elegant way would be pass the width as a parameter to a custom modifier applied to each child view:

struct WidthPercentageTest: View {
    var body: some View {
        GeometryReader { geometry in
            
            //Parent width
            let stackWidth = geometry.size.width
            
            HStack {
                Text("Text1")
                    .customPercentage(0.25, of: stackWidth) // 25% of HStack width
                    .border(.red)
                
                Text("Text2")
                    .customPercentage(0.5, of: stackWidth) // 50% of HStack width - debug overlay enabled
                    .border(.blue)
  
                Text("Text3")
                    .customPercentage(0.25, of: stackWidth) // 25% of HStack width
                    .border(.green)
            }
        }
    }
}

//View extension
extension View {
    func customPercentage(_ percentage: CGFloat, of parentWidth: CGFloat) -> some View {
        self
            .frame(width: percentage * parentWidth )
    }
}


#Preview {
    WidthPercentageTest()
}

But then again, I wouldn't consider this to be very elegant, since it requires adding the Geometry Reader still.

Ideally, a truly elegant solution would consist of:

  • Not having to manually add a GeometryReader.
  • Not having to pass the stack width as a parameter to the child view modifier.
  • Having the option to add bells and whistles as needed.

Maybe something like this:

EnhancedHStack { 
    Text("Text1")
        .widthPercentage(0.25) // 25% of HStack width
    Text("Text2")
        .widthPercentage(0.5, debug: true) // 50% of HStack width - debug overlay enabled
    Text("Text3")
        .widthPercentage(0.25) // 25% of HStack width
}

To achieve something in this style, there needs to be a mechanism to automatically apply a GeometryReader to the parent view and pass down its values to the child views via the environment.

This can be achieved by using a custom ViewModifier, a custom wrapper for HStack and a supporting custom environment key.

Here's the full code:

import SwiftUI

//Main demo view
struct WidthPercentageDemo: View {
    
    var body: some View {
        
        EnhancedHStack { // <- custom wrapper for HStack
            Text("Text1")
                .widthPercentage(0.25) // 25% of HStack width
            Text("Text2")
                .widthPercentage(0.5, debug: true) // 50% of HStack width - debug overlay enabled
            Text("Text3")
                .widthPercentage(0.25) // 25% of HStack width
        }
    }
}

//Custom wrapper for HStack
struct EnhancedHStack<Content: View>: View {
    
    @ViewBuilder let content: Content //accept child views

    //Body
    var body: some View {
        
        HStack(spacing: 0) { //forcing 0 spacing, otherwise additional logic would be required to calculate percentages
            content
        }
        // Automatically apply modifier that reads available width
        .modifier(ParentWidthReader())
        
    }
}

//View extension
extension View {
    func widthPercentage(_ percentage: CGFloat, debug: Bool = false) -> some View {
        self.modifier(WidthPercentageModifier(percentage: percentage, debug: debug))
    }
}

//Modifier for view extension
struct WidthPercentageModifier: ViewModifier {
    let percentage: CGFloat
    var debug: Bool = false
    
    // The parent width will be passed down here for width calculation
    @Environment(\.parentWidth) var parentWidth: CGFloat
    
    func body(content: Content) -> some View {
        
        //Helper constant
        let adjustedWidth = parentWidth * percentage
        
        content
            .frame(width: adjustedWidth)
        
            //Debug overlay that shows width values
            .overlay {
                if debug {
                    
                    //Get a random color
                    let color = randomColor()
                    
                    Rectangle()
                        .stroke(color)
                        .overlay(alignment: .bottom) {
                            VStack {
                                Text("W: \(String(format: "%.2f", adjustedWidth))")
                                    .font(.caption2)
                                    .foregroundStyle(.white)
                            }
                            .padding(3)
                            .background(color)
                            .offset(y: 20)
                        }
                }
            }
    }
    
    // Helper function to generate a random color to use with debug overlay
    private func randomColor() -> Color {
        let red = Double.random(in: 0.2...0.8)
        let green = Double.random(in: 0.2...0.8)
        let blue = Double.random(in: 0.2...0.8)
        return Color(red: red, green: green, blue: blue)
    }
}

//View modifier to read available width and pass it as environment value
struct ParentWidthReader: ViewModifier {
    @State private var parentWidth: CGFloat = 0
    
    func body(content: Content) -> some View {
        GeometryReader { geometry in
            content
                .onAppear {
                    // Update the parent width whenever the view appears or changes
                    self.parentWidth = geometry.size.width
                }
                .environment(\.parentWidth, parentWidth) // Pass down parentWidth to children
        }
    }
}

//Custom environment key
extension EnvironmentValues {
    var parentWidth: CGFloat {
        get { self[ParentWidthKey.self] }
        set { self[ParentWidthKey.self] = newValue }
    }
}

private struct ParentWidthKey: EnvironmentKey {
    static var defaultValue: CGFloat = 0
}

//Preview
#Preview {
    WidthPercentageDemo()
}

Some notes:

  • A zero spacing value is forced for the HStack, since any custom spacing would affect the calculation of the percentages based on the number of child views and the total value of spacing between them.

  • For convenience, a debug: Bool parameter can be set to show the calculated width value.

  • Note this has not been extensively tested other than for the purposes of answering this question.

enter image description here

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.