I have a card View
which needs to be responsive in size to changes in contents (e.g. text, DynamicType).
At a lower level, the card view contains a Canvas
view. The Canvas
view occupies the full area of the card.
At a higher level, the card view contains an arbitrarily placed Button
.
Ultimately, the design calls for particle effects to be drawn in the Canvas
, emitting from the location of the Button
, relative to the Canvas
.
Core Problem: how to get the coordinates of the Button
, in the underlying Canvas
view's coordinate system?
My approach: I'm using GeometryReaders
and PreferenceKeys
to store the frames of the Button
and Canvas
in global coordinates. When it comes time to draw in the Canvas
, I calculate the button's position in local, Canvas
coordinates, and then draw my particles. (Represented in this simplified example by a green box.)
This example code features a button. When pressed, it will populate the contents of the card with random text, forcing a geometry change. This is done to test view responsiveness.
Problem: on the first Canvas
draw, both the stored canvasRect
and buttonRect
do not change from their default values of .zero
. Only after the button is pressed will these values be updated, and become valid for drawing.
(I am able to force an update by updating the card's contents in .onAppear() but would prefer not to have to do this in a substantially more complex, real-world solution.)
Before button press:
After button press:
Question: how can I force the PreferenceKeys
to update correctly?
struct CanvasFrameKey: PreferenceKey {
static var defaultValue = CGRect.zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
struct ButtonFrameKey: PreferenceKey {
static var defaultValue = CGRect.zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
struct ResponsiveCanvasView: View {
@State private var title: String = "Title"
@State private var subtitle: String = "Subtitle"
@State private var buttonFrame: CGRect = .zero
@State private var canvasFrame: CGRect = .zero
private func randomString() -> String {
return (0..<Int.random(in: 1...5))
.map { _ in UUID().uuidString }
.joined(separator: " ")
}
var body: some View {
VStack(alignment: .leading, spacing: 22) {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
.foregroundColor(.primary)
Text(subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
}
// Push out to fill the available width regardless of contents.
.frame(maxWidth: .infinity, alignment: .leading)
VStack {
Button("Press Me") {
withAnimation(nil) { subtitle = randomString() }
}
.tint(.orange)
.background {
GeometryReader { geo in
Color.clear
.preference(key: ButtonFrameKey.self, value: geo.frame(in: .global))
}
}
.onPreferenceChange(ButtonFrameKey.self) { newFrame in
buttonFrame = newFrame
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
.padding()
.onAppear {
// FIXME: We must force a geometry update by setting the subtitle to a new value
// otherwise canvasRect and buttonRect will remain .zero!
// subtitle = randomString()
}
.background {
Canvas { context, canvasSize in
// FIXME: unless we force a geometry update in onAppear, buttonRect and canvasRect will be .zero
// at this point.
// Convert the global button frame to local canvas coords.
let x = buttonFrame.origin.x - canvasFrame.origin.x
let y = buttonFrame.origin.y - canvasFrame.origin.y
let origin = CGPoint(x: x, y: y)
let rect = CGRect(origin: origin, size: buttonFrame.size).insetBy(dx: -6, dy: -6)
let path = Path(roundedRect: rect, cornerRadius: 0)
context.stroke(path, with: .color(.green))
}
.background {
GeometryReader { geo in
Color.clear
.preference(key: CanvasFrameKey.self, value: geo.frame(in: .global))
}
}
.onPreferenceChange(CanvasFrameKey.self) { newFrame in
canvasFrame = newFrame
}
}
.background(Color(uiColor: .secondarySystemBackground), in: .rect(cornerRadius: 8))
}
}
#Preview {
ResponsiveCanvasView()
}
My guess is that the SwiftUI update mechanisms don't work properly for content drawn in a Canvas
. So although the frame sizes are held in state variables, SwiftUI doesn't detect a visible change when the values are initially set. This is why there is no repaint.
To fix, try adding the state variables as captured variables to the canvas closure:
Canvas { [buttonFrame, canvasFrame] context, canvasSize in
// ...
}
Btw, there is no need to be using PreferenceKey
s for measuring the frames. If your target is iOS 16 or later, use .onGeometryChange
instead. Then just update the variables directly. For example:
Canvas { [buttonFrame, canvasFrame] context, canvasSize in
// ...
}
.onGeometryChange(for: CGRect.self) { geo in
geo.frame(in: .global)
} action: { rect in
canvasFrame = rect
}
Even if you need to support older iOS versions, you can just update the state variables directly without needing to go via a PreferenceKey
:
Canvas { [buttonFrame, canvasFrame] context, canvasSize in
// ...
}
.background {
GeometryReader { geo in
let rect = geo.frame(in: .global)
Color.clear
.onAppear {
canvasFrame = rect
}
// Old syntax for iOS versions < 17
.onChange(of: rect) { newVal in
canvasFrame = rect
}
}
}