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):
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.
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?
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.
ZStack
to superimpose all numbers over each other. The ZStack
adopts the dimension of the largest number.GeometryReader
is used in the background of the ZStack
. The width measured by the GeometryReader
is applied as minHeight
to the ZStack
.Circle
in this frame, it will fill the frame.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)
)
}
}
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)
)
}
}