swiftswiftui

How to Automatically Adjust Offset in SwiftUI When Resizing a Rectangle to Maintain Its Original Position?


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()
        
    }
}

Solution

  • 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:

    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)
    }