iosswiftuigeometrygeometryreaderhstack

Equally sizing child views within a stack view SwiftUI


I'm trying to create a view where I have Text views in an HStack with circular backgrounds where each child is sized based off of the largest child in the stack. The goal is to have all of the views have the same width and height, across all size categories.

I have an implementation that is 90% of the way there, but I'm hitting some very odd behavior when trying to set both the width and heigh of the views.

It's worth noting I cannot use .infinity for the width due to this view needing to have a fixed width. Additionally, I need to target iOS 13 with this, so any solution that involves newer APIs for iOS 14+ I cannot use.

Here is the code I have:

import SwiftUI

// MARK: - Testview

struct Testview: View {
    // MARK: Internal

    var body: some View {
        HStack {
            CircleView(number: "2", maxSize: maxSize)
            CircleView(number: "24", maxSize: maxSize)
            CircleView(number: "246", maxSize: maxSize)
        }
        .background(Color.orange)
        .onPreferenceChange(CircleSizePreferenceKey.self, perform: { value in
            maxSize = value
        })
    }

    // MARK: Private

    @State private var maxSize: CGSize = .zero
}

#Preview {
    Testview()
        .environment(\.sizeCategory, .extraLarge)
}

// MARK: - CircleView

struct CircleView: View {
    // MARK: Lifecycle

    init(number: String?, maxSize: CGSize) {
        if
            let number,
            !number.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            self.number = number
        } else {
            self.number = " " // To preserve the size since an empty string results in a default sized circle (16.0 pts)
        }

        self.maxSize = maxSize
    }

    // MARK: Internal

    var number: String
    var maxSize: CGSize

    var body: some View {
        Text(number)
            .padding(8.0)
            .background(
                GeometryReader { geometry in
                    Color.clear
                        .preference(
                            key: CircleSizePreferenceKey.self,
                            value: geometry.size
                        )
                }
            )
            .frame(width: maxSize.width)
            .background(
                Circle()
                    .fill(Color.white)
                    .frame(width: maxSize.width, height: maxSize.height)
                    .background(
                        Circle()
                            .stroke(.black.opacity(0.15), lineWidth: 1.5)
                    )
            )
    }
}

// MARK: - CircleSizePreferenceKey

private struct CircleSizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero

    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        let newSize = nextValue()
        let maxSize = max(value.width, value.height, newSize.width, newSize.height)

        value = CGSize(width: maxSize, height: maxSize)
    }
}

The above implementation is working great for the width (see below):

width only

The issue with this is the parent doesn't know how tall it needs to be, so the top and bottom of the circle views extend beyond the bounds of the parent.

As soon as I set the height for the frame in the CircleView, the circle views shrink down to 16 points.

width and height

I've debugged the sizing of the views, and with the above, when providing the width only, the maxSize comes back as (110.666, 110.666), but when providing the height, the size comes back as (16.0, 16.0).

Can someone help with this and let me know if there is anything I'm doing incorrectly here or if there is a better way to achieve what I'm trying to achieve?


Solution

  • Your existing code works if you change the .frame being applied to the Text in CircleView:

    Text(number)
        .padding(8.0)
        .background(
            // ...
        )
        // .frame(width: maxSize.width)
        .frame(minWidth: maxSize.width, minHeight: maxSize.height) // 👈 HERE
        .background(
            // ...
        )
    

    However, you were asking if there is a better way to achieve what you're trying to achieve.

    There is certainly another way, which does not require the use of a PreferenceKey. This is to use a hidden placeholder to define the footprint for a CircleView. Then show the CircleView as an overlay over the footprint.

    struct Testview: View {
        private let numbers = ["2", "24", "246"]
        @State private var maxSize: CGFloat = .zero
    
        private var footprint: some View {
            ZStack {
                ForEach(Array(numbers.enumerated()), id: \.offset) { offset, numberString in
                    Text(numberString)
                }
            }
            .padding(8.0)
            .background(
                GeometryReader { proxy in
                    Color(white: 1, opacity: 0)
                        .onAppear { maxSize = proxy.size.width }
                }
            )
            .frame(minHeight: maxSize)
            .hidden()
        }
    
        var body: some View {
            HStack {
                ForEach(Array(numbers.enumerated()), id: \.offset) { offset, numberString in
                    footprint
                        .overlay(
                            CircleView(number: numberString)
                        )
                }
            }
            .background(Color.orange)
        }
    }
    
    struct CircleView: View {
        let number: String
    
        init(number: String?) {
            if let number,
               !number.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                self.number = number
            } else {
                self.number = " " // To preserve the size since an empty string results in a default sized circle (16.0 pts)
            }
        }
    
        var body: some View {
            Circle()
                .fill(Color.white)
                .stroke(.black.opacity(0.15), lineWidth: 1.5)
                .overlay(
                    Text(number)
                )
        }
    }
    

    Screenshot

    If it's not practical to build the footprint in this way then you could stick with the approach of passing maxSize to CircleView, but without needing to use a PreferenceKey:

    struct Testview: View {
        @State private var maxSize: CGFloat = .zero
    
        private var sizeDetector: some View {
            GeometryReader { proxy in
                Color(white: 1, opacity: 0)
                    .onAppear {
                        maxSize = max(maxSize, proxy.size.width)
                    }
            }
        }
    
        var body: some View {
            HStack {
                CircleView(number: "2", maxSize: maxSize)
                    .background(sizeDetector)
                CircleView(number: "24", maxSize: maxSize)
                    .background(sizeDetector)
                CircleView(number: "246", maxSize: maxSize)
                    .background(sizeDetector)
            }
            .background(Color.orange)
        }
    }
    
    struct CircleView: View {
        private let number: String
        private let maxSize: CGFloat
    
        init(number: String?, maxSize: CGFloat) {
            if let number,
               !number.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                self.number = number
            } else {
                self.number = " " // To preserve the size since an empty string results in a default sized circle (16.0 pts)
            }
            self.maxSize = maxSize
        }
    
        var body: some View {
            Text(number)
                .padding(8.0)
                .frame(minWidth: maxSize, minHeight: maxSize)
                .background(
                    Circle()
                        .fill(Color.white)
                        .stroke(.black.opacity(0.15), lineWidth: 1.5)
                )
        }
    }