Divide empty space in HStack/VStack proportionally

I have a HStack with Spacers and a Text.

HStack {
    Spacer()
    Text("Some Text")
    Spacer()
}

Now I want the first Spacer to take X% of the available space (excluding the space taken by Text) and the bottom to take the rest.

If I use Geometry Reader, and do something like below to the first Spacer -

Spacer()
    .frame(width: geometry.size.width * (X/100))

It doesn't take into account the space occupied by the Text.

Is there any way to divide the "available" space between spacers?


What you are looking for are PreferenceKeys. Essentially, they keep track of the sizes of certain views, and then you can use those sizes to compute what you need. They are most commonly used to keep multiple views the same size, even though they would otherwise have different sizes. I am giving you a long and short solution on this:

Long solution:

struct SpacerPrefKeyView: View {
    
    @State private var textWidth: CGFloat = 10
    @State private var hStackWidth: CGFloat = 10
    
    let X: CGFloat = 20

    var body: some View {
        HStack {
            Spacer()
                .frame(width: (hStackWidth - textWidth) * (X/100))
            Text("Hello, World!")
                .background(GeometryReader { geometry in
                    Color.clear.preference(
                        //This sets the preference key value with the width of the background view
                        key: TextWidthPrefKey.self,
                        value: geometry.size.width)
                })

            Spacer()
        }
        .background(GeometryReader { geometry in
            Color.clear.preference(
                //This sets the preference key value with the width of the background view
                key: HStackWidthPrefKey.self,
                value: geometry.size.width)
        })
        .onPreferenceChange(TextWidthPrefKey.self) {
            // This keeps track of the change of the size
            textWidth = $0
        }
        .onPreferenceChange(HStackWidthPrefKey.self) {
            // This keeps track of the change of the size
            hStackWidth = $0
       }
    }
}

private extension SpacerPrefKeyView {
    struct TextWidthPrefKey: PreferenceKey {
        static let defaultValue: CGFloat = 0
        
        static func reduce(value: inout CGFloat,
                           nextValue: () -> CGFloat) {
            value = max(value, nextValue())
        }
    }
    
    struct HStackWidthPrefKey: PreferenceKey {
        static let defaultValue: CGFloat = 0
        
        static func reduce(value: inout CGFloat,
                           nextValue: () -> CGFloat) {
            value = max(value, nextValue())
        }
    }
}

The Short Solution thanks to FiveStarBlog and @ramzesenok on Twitter:

struct SpacerPrefKeyView: View {
    
    @State private var textSize: CGSize = CGSize(width: 10, height: 10)
    @State private var hStackSize: CGSize = CGSize(width: 10, height: 10)
    
    let X: CGFloat = 20

    var body: some View {
        HStack {
            Spacer()
                .frame(width: (hStackSize.width - textSize.width) * (X/100))
            Text("Hello, World!")
                .copySize(to: $textSize)
            Spacer()
        }
        .copySize(to: $hStackSize)
    }
}

And use the extension:

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)
    }
    
    func copySize(to binding: Binding<CGSize>) -> some View {
        self.readSize { size in
            binding.wrappedValue = size
        }
    }
}

They do the same thing, but the extension handles it very neatly without the extra code in your view. I posted it so that you could see how PreferenceKeys work.


Here is a decent solution that requires just one GeometryReader.

For more information, check this article.

struct MyView: View {
    @State private var offset: CGFloat?

    var body: some View {
        HStack {
            Spacer()
            Text("Some Text")
                .anchorPreference(key: BoundsPreference.self, value: .bounds) { $0 }
            Spacer(minLength: offset)
        }
        .backgroundPreferenceValue(BoundsPreference.self) { preferences in
            GeometryReader { g in
                preferences.map {
                    Color.clear.preference(key: OffsetPreference.self, value: offset(with: g.size.width - g[$0].width))
                }
            }
        }
        .onPreferenceChange(OffsetPreference.self) {
            offset = $0
        }
    }

    private func offset(with widthRemainder: CGFloat) -> CGFloat {
        widthRemainder * (100.0 - X) / 100.0
    }
}

private struct BoundsPreference: PreferenceKey {
    typealias Value = Anchor<CGRect>?
    static var defaultValue: Value = nil
    
    static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
        value = nextValue()
    }
}

private struct OffsetPreference: PreferenceKey {
    typealias Value = CGFloat?
    static var defaultValue: Value = nil
    
    static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
        value = nextValue()
    }
}