The Issue in My Project:
In my SwiftUI project, I have a red rectangle whose size and position I want to control dynamically. The rectangle's size is represented by a rect state variable, and its position is managed using an offset state variable. When I increase or decrease the rectangle's width using buttons, the origin of the rectangle changes due to the size modification.
To maintain the rectangle's visual position on the screen, I manually adjust the offset in the button actions. For example, if I add 20 points to the rectangle's width, I also increase the offset.width to counteract the shift in its origin.
What I Want to Achieve:
I want to simplify this logic by automatically calculating the necessary offset adjustment within the .onChange modifier of the GeometryReader. The goal is to dynamically compute and update the offset whenever the frame of the rectangle changes, eliminating the need to manually adjust the offset in the button actions. This ensures that the rectangle stays visually stationary on the screen regardless of changes to its size.
Original code(iOS: 15.0, macOS: 12.0):
import SwiftUI
struct ContentView: View {
@State private var rect: CGRect = CGRect(origin: .zero, size: CGSize(width: 200.0, height: 50.0))
@State private var offset: CGSize = CGSize()
var body: some View {
VStack {
Spacer()
Rectangle()
.fill(Color.blue)
.frame(width: 200.0, height: 50.0)
Rectangle()
.fill(Color.red)
.frame(width: rect.width, height: rect.height)
.overlay(Text(String(describing: rect.origin) + String(describing: rect.size)).bold())
.background(
GeometryReader { geometryValue in
Color.clear
.onAppear {
rect = geometryValue.frame(in: .global)
}
.onChange(of: geometryValue.frame(in: .global)) { newValue in
rect = newValue
}
}
)
.offset(offset)
.animation(Animation.default, value: offset)
.animation(Animation.default, value: rect.size.width)
Spacer()
HStack {
Button("add width") {
rect.size.width += 20.0
offset.width += 10.0
}
Button("subtract width") {
rect.size.width -= 20.0
offset.width -= 10.0
}
}
}
.padding()
}
}
Attempt to solve the issue(iOS: 15.0, macOS: 12.0):
The attempted approach does not solve the issue and also introduces a new animation problem. I prefer to use the animation modifier instead of withAnimation.
import SwiftUI
struct ContentView: View {
@State private var rect: CGRect = CGRect(origin: .zero, size: CGSize(width: 200.0, height: 50.0))
@State private var previousRect: CGRect = CGRect(origin: .zero, size: CGSize(width: 200.0, height: 50.0))
@State private var offset: CGSize = CGSize()
var body: some View {
VStack {
Spacer()
Rectangle()
.fill(Color.blue)
.frame(width: 200.0, height: 50.0)
Rectangle()
.fill(Color.red)
.frame(width: rect.width, height: rect.height)
.overlay(Text(String(describing: rect.origin) + String(describing: rect.size)).bold())
.background(
GeometryReader { geometryValue in
Color.clear
.onAppear {
rect = geometryValue.frame(in: .global)
previousRect = rect
}
.onChange(of: geometryValue.frame(in: .global)) { newValue in
let deltaX: CGFloat = newValue.origin.x - previousRect.origin.x
let deltaY: CGFloat = newValue.origin.y - previousRect.origin.y
offset.width -= deltaX
offset.height -= deltaY
previousRect = newValue
rect = newValue
}
}
)
.offset(offset)
.animation(Animation.default, value: offset)
.animation(Animation.default, value: rect.size.width)
Spacer()
HStack {
Button("add width") {
rect.size.width += 20.0
}
Button("subtract width") {
rect.size.width -= 20.0
}
}
}
.padding()
}
}
Another attempt to solve the issue(iOS: 15.0, macOS: 12.0): The issue with this attempt is that the red rectangle is bouncy and does not remain in its position during the process. import SwiftUI
struct ContentView: View {
@State private var rect: CGRect = CGRect(origin: .zero, size: CGSize(width: 200.0, height: 50.0))
@State private var offset: CGSize = CGSize()
@State private var oldSize: CGSize = CGSize(width: 200.0, height: 50.0)
var body: some View {
VStack {
Spacer()
Rectangle()
.fill(Color.blue)
.frame(width: 200.0, height: 50.0)
Rectangle()
.fill(Color.red)
.frame(width: rect.width, height: rect.height)
.overlay(Text(String(describing: rect.origin) + String(describing: rect.size)).bold())
.background(
GeometryReader { geometryValue in
Color.clear
.onAppear {
rect = geometryValue.frame(in: .global)
}
.onChange(of: geometryValue.frame(in: .global)) { newValue in
if (oldSize != newValue.size) {
print(newValue.width - oldSize.width)
offset.width += (newValue.width - oldSize.width)/2.0
}
rect = newValue
oldSize = newValue.size
}
}
)
.offset(offset)
.animation(Animation.default, value: offset)
.animation(Animation.default, value: rect.size.width)
Spacer()
HStack {
Button("add width") {
rect.size.width += 20.0
}
Button("subtract width") {
rect.size.width -= 20.0
}
}
}
.padding()
}
}
Your attempt doesn't work because by using .onChange(of: geometryValue.frame(in: .global))
, you cause onChange
to be called again, after you adjust the offset. This second invocation of onChange
sets offset
back to (0, 0). Here is the sequence of events:
rect
changes from (0, 0, 200, 50) to (0, 0, 220, 50)onChanged
is called.offset
is changed. This causes the frame to be changed again.onChange
gets called again, and offset
is set back to (0, 0).onChange
is trying to update multiple times per frame.If you are only interested in the size, consider using onChange(of: geometryValue.size)
instead. Note that the offset you need to add is half of the difference between the new and old size.
// I have renamed previousRect to previousSize
.onChange(of: geometryValue.size) { newSize in
let deltaX: CGFloat = (newSize.width - previousSize.width) / 2
let deltaY: CGFloat = (newSize.height - previousSize.height) / 2
offset.width += deltaX
offset.height += deltaY
previousSize = newSize
}
// if you also want the text to show the correct origin...
.onChange(of: geometryValue.frame(in: .global).origin) { newValue in
rect.origin = newValue
}
In modern versions, you should use onGeometryChange
instead of a GeometryReader
as the background
. Here is an example of just monitoring the size.
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { previousSize, newSize in
let deltaX: CGFloat = (newSize.width - previousSize.width) / 2
let deltaY: CGFloat = (newSize.height - previousSize.height) / 2
offset = CGSize(width: offset.width + deltaX, height: offset.height + deltaY)
}