swiftuiswiftui-layout

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


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!


Solution

  • 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:

    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