Search code examples
swiftuilayoutfillhstackfulfillment

Add equal spacing to inner HStack elements to fill the available space in SwiftUI?


I start with the following code:

import SwiftUI

struct ContentView: View {
    var body: some View {
        HStack(spacing: 20) {
            ExtractedView(text: "Energy")
            ExtractedView(text: "Breath Control")
                
        }
        .padding(.horizontal, 20)
    }
}

#Preview {
    ContentView()
}

struct ExtractedView: View {
    let text: String
    
    var body: some View {
        Button {
            
        } label: {
            HStack(spacing: 8) {
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundStyle(.tint)
                Text(text)
                    .lineLimit(1)
                    .font(.system(size: 18, weight: .bold))
            }
            .padding(.horizontal, 8)
            .padding(.vertical, 8)
            .background {
                Color.yellow
            }
        }
    }
}

enter image description here

The approximate result I want to achieve: enter image description here

In other words I need to add equal spacing inside each element after text but I don't know how to do that. Tried different code but the button size becomes equal or iOS adds newline to the second label or tries to shorten the second label even when there is enough space.


Solution

  • One way to solve is to use a custom Layout:

    • The ideal width is based on the ideal size of the views.
    • Any excess width is shared equally between the views in the container.

    Here is an example implementation that works this way:

    struct PaddedToFill: Layout {
        typealias Cache = IdealSizes
        let spacing: CGFloat
    
        struct IdealSizes {
            let idealWidths: [CGFloat]
            let idealMaxHeight: CGFloat
    
            var isEmpty: Bool { idealWidths.isEmpty }
            var nWidths: Int { idealWidths.count }
        }
    
        func makeCache(subviews: Subviews) -> IdealSizes {
            var idealWidths = [CGFloat]()
            var idealMaxHeight = CGFloat.zero
            for subview in subviews {
                let idealViewSize = subview.sizeThatFits(.unspecified)
                idealWidths.append(idealViewSize.width)
                idealMaxHeight = max(idealMaxHeight, idealViewSize.height)
            }
            return IdealSizes(idealWidths: idealWidths, idealMaxHeight: idealMaxHeight)
        }
    
        func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout IdealSizes) -> CGSize {
    
            // Consume all the width available
            CGSize(width: proposal.width ?? 10, height: cache.idealMaxHeight)
        }
    
        func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout IdealSizes) {
            if !cache.isEmpty, subviews.count == cache.nWidths {
                let idealContainerWidth = cache.idealWidths.reduce(0) { $0 + $1 } + (CGFloat(cache.nWidths - 1) * spacing)
                let excessWidth = max(0, bounds.width - idealContainerWidth)
                let paddingPerView = excessWidth / CGFloat(cache.nWidths)
                var minX = bounds.minX
                for (index, subview) in subviews.enumerated() {
                    let w = cache.idealWidths[index] + paddingPerView
                    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 + spacing
                }
            }
        }
    }
    

    A change is also needed to ExtractedView, so that is expands to fill the available width before the yellow background is added:

    // ExtractedView
    
    Button {
    
    } label: {
        HStack(spacing: 8) {
            // ...
        }
        .padding(.horizontal, 8)
        .padding(.vertical, 8)
        .frame(maxWidth: .infinity) // 👈 added
        .background {
            Color.yellow
        }
    }
    

    To use, just replace the HStack in your original code with PaddedToFill:

    PaddedToFill(spacing: 20) {
        ExtractedView(text: "Energy")
        ExtractedView(text: "Breath Control")
    }
    .padding(.horizontal, 20)
    

    Screenshot


    In your screenshot of the approximate result, the extra padding was always on the trailing side of each button. To achieve this result, just add an alignment parameter when setting the maxWidth in ExtractedView:

    HStack(spacing: 8) {
        // ...
    }
    .padding(.horizontal, 8)
    .padding(.vertical, 8)
    .frame(maxWidth: .infinity, alignment: .leading) // 👈 + alignment
    .background {
        Color.yellow
    }
    

    Screenshot


    EDIT Layout was introduced in iOS 16. If you still need to support iOS 15 then you will need a fallback solution for this version. One way would be to measure the size of the container using a GeometryReader, then share the excess width between the buttons.

    Here is an example of how it can be solved this way. The easiest way to pad the buttons is to allow the size of the extra padding to be passed as a parameter to ExtractedView:

    struct ExtractedView: View {
        let text: String
        var extraHorizontalPadding = CGFloat.zero // 👈 added
    
        var body: some View {
            Button {
    
            } label: {
                HStack(spacing: 8) {
                    // ...
                }
                .padding(.horizontal, 8)
                .padding(.horizontal, extraHorizontalPadding) // 👈 added
                .padding(.vertical, 8)
                .frame(maxWidth: .infinity)
                .background {
                    Color.yellow
                }
            }
        }
    }
    

    The size of the extra padding is computed in the same way as the custom Layout was doing it, based on the ideal size of the container.

    • The ideal size is found by adding a hidden version of the HStack to the background and using a GeometryReader to measure its size.
    • The available width is measured by wrapping the visible HStack with another GeometryReader.
    struct ContentView: View {
        let spacing: CGFloat = 20
        @State private var idealContainerSize: CGSize?
    
        @ViewBuilder
        private var buttons: some View {
            ExtractedView(text: "Energy")
            ExtractedView(text: "Breath Control")
        }
    
        var body: some View {
            if #available(iOS 16.0, *) {
                PaddedToFill(spacing: spacing) {
                    buttons
                }
                .padding(.horizontal, spacing)
            } else {
                legacyLayout
            }
        }
    
        private var legacyLayout: some View {
            GeometryReader { outer in
                let actualContainerWidth = outer.size.width
                let excessWidth: CGFloat = max(0, actualContainerWidth - (idealContainerSize?.width ?? actualContainerWidth))
                let paddingPerView = excessWidth / 2
                HStack(spacing: spacing) {
                    ExtractedView(text: "Energy", extraHorizontalPadding: paddingPerView / 2)
                        .fixedSize()
                    ExtractedView(text: "Breath Control", extraHorizontalPadding: paddingPerView / 2)
                        .fixedSize()
                }
                .frame(maxWidth: .infinity)
                .background {
                    HStack(spacing: spacing) {
                        buttons
                    }
                    .fixedSize()
                    .hidden()
                    .background {
                        GeometryReader { inner in
                            Color.clear
                                .onAppear {
                                    idealContainerSize = inner.size
                                }
                        }
                    }
                }
            }
            .frame(maxHeight: idealContainerSize?.height)
            .padding(.horizontal, spacing)
        }
    }
    
    @available(iOS 16.0, *)
    struct PaddedToFill: Layout {
        // ... as before
    }
    

    EDIT 2 The custom Layout gets more complicated if the text labels should wrap when they don't fit on one line. You could try these changes:

    1. Remove the .lineLimit in ExtractedView:
    // ExtractedView
    
    Text(text)
        // .lineLimit(1)
        .font(.system(size: 18, weight: .bold))
    
    1. Change the function sizeThatFits to request twice the height if the proposed width is less than the ideal width:
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout IdealSizes) -> CGSize {
        let idealContainerWidth = cache.idealWidths.reduce(0) { $0 + $1 } + (CGFloat(cache.nWidths - 1) * spacing)
        let proposalWidth = proposal.width ?? 10
        return CGSize(
            width: proposalWidth,
            height: proposalWidth >= idealContainerWidth ? cache.idealMaxHeight : 2 * cache.idealMaxHeight
        )
    }
    

    This is a fairly rough approximation of the height that will actually be needed and it only allows the text to wrap onto one additional line. A more rigorous implementation would need to call through to the subviews with a reduced width proposal, computed exactly as it is being done in placeSubviews.

    1. Allow the excess width to go negative in placeSubviews
    // let excessWidth = max(0, bounds.width - idealContainerWidth)
    let excessWidth = bounds.width - idealContainerWidth