swiftanimationswiftui

Animating SwiftUI views with GeometryEffect


I'm trying to animate a Rectangle View size change on device rotation. Try the buggy example:

import SwiftUI

@main
struct QuestionApp: App {
    var body: some Scene {
        WindowGroup {
            PortraitLandscapeRotation()
        }
    }
}

struct PortraitLandscapeRotation: View {
    @State private var currentCanvasWidth: CGFloat = 0.0
    @State private var currentCanvasHeight: CGFloat = 0.0
    @State private var canvasScaleWidth: CGFloat = 1.0
    @State private var canvasScaleHeight: CGFloat = 1.0
    
    var body: some View {
        GeometryReader { geo in
            Rectangle()
                .aspectRatio(1, contentMode: .fit)
                .frame(maxWidth: geo.size.width * 0.5)
                .border(Color.red, width: 2)
            
                .overlay {
                    GeometryReader { innerGeo in
                        Rectangle()
                            .fill(Color.yellow.opacity(0.2))
                            //HERE:
                            .modifier(ScaleEffect(scaleX: canvasScaleWidth, scaleY: canvasScaleHeight))
                            .onAppear {
                                currentCanvasWidth = innerGeo.size.width
                                currentCanvasHeight = innerGeo.size.height
                                print("Default size is \(innerGeo.size.width) x \(innerGeo.size.height)")
                            }
                            .onChange(of: innerGeo.size) { newSize in
                                //HERE: 
                                withAnimation(.easeOut(duration: 2.0)) {
                                    canvasScaleWidth = newSize.width / currentCanvasWidth
                                    canvasScaleHeight = newSize.height / currentCanvasHeight
                                    print("NEW scale is \(canvasScaleWidth)x\(canvasScaleHeight)")
                                }
                                currentCanvasWidth = newSize.width
                                currentCanvasHeight = newSize.height
                            }
                    }
                }
                .padding()
        }
    }
}

//And here:
struct ScaleEffect: GeometryEffect {
    var scaleX: CGFloat
    var scaleY: CGFloat

    var animatableData: AnimatablePair<CGFloat, CGFloat> {
        get { AnimatablePair(scaleX, scaleY) }
        set {
            scaleX = newValue.first
            scaleY = newValue.second
        }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        print("Transforming to \(scaleX)x\(scaleY)")
        return ProjectionTransform(CGAffineTransform(scaleX: scaleX, y: scaleY))
    }
}

I want a smooth animation of size change when the device rotates.

Everything should work, but I get a strange bug - the inner view adapts its size quickly and then does the transformation animation again. Which results in a double size change like this: wrong size

I don't know how to approach it. I'm only learning. Should I use MatchedGeometryEffect instead? Or both ways are wrong? :(


Solution

  • The reason why a change of orientation performs a double change is because the size of the overlay automatically adopts the size of the base view without any assistance. A scaling factor > 1 therefore causes the overlay to grow larger than the base view and a factor < 1 causes it to grow smaller than the base view.

    Also, scaling a view is just a visual effect. It does not change the size of the view in the layout. So when you measure the view's size, you get the unscaled size.

    Let's go through the process step by step:

    1. Starting in portrait orientation on an iPhone 16, the scaling values canvasScaleWidth and canvasScaleHeight will both have a value of 1.0. The yellow overlay automatically adopts the size of the underlying view. In .onAppear, this size is saved to currentCanvasWidth and currentCanvasHeight.

    2. If the orientation is switched to landscape then the base view gets larger. As before, the yellow overlay automatically adopts the size of the underlying view so this gets larger too (matching the base view).

    3. Due to the change of size, the .onChange handler kicks in. This computes a scaling factor based on the original size of the base view, let's say this is approximately 2. This change is applied withAnimation. The yellow overlay is therefore seen to grow by the scaling factor (so, to about twice the size of the base view).

    4. Immediately after starting the animation, the new canvas size is saved. This size is the larger base size.

    5. When the orientation is switched back to portrait, the base view gets smaller. As before, the yellow overlay automatically adopts the size of the underlying view. However, the scaling factor from before is still in effect, so the yellow overlay is initially larger than the base.

    6. The .onChange handler kicks in again and computes a new scaling factor. This scaling factor is going to be approximately 0.5, because it is based on the previous large size. In other words, the scaling factor goes from about 2.0 to about 0.5. This change is applied with animation, so we see the yellow overlay shrink to a size about half the size of the base.

    Btw, in landscape orientation on an iPhone 16, the size of the base view is actually determined by the height of the screen, not the width (due to the aspect ratio of 1:1). This is why the red border around the base view is a bit wider than the black square.


    If I understand correctly, what you want to see happen is for the yellow overlay to change to the new size of the base square, with animation.

    struct PortraitLandscapeRotation: View {
        @State private var baseSize = CGSize.zero
    
        var body: some View {
            GeometryReader { geo in
                Rectangle()
                    .aspectRatio(1, contentMode: .fit)
                    .onGeometryChange(for: CGSize.self) { proxy in
                        proxy.size
                    } action: { size in
                        withAnimation(.easeOut(duration: baseSize == .zero ? 0 : 2)) {
                            baseSize = size
                        }
                    }
                    .overlay(alignment: .topLeading) {
                        Color.yellow
                            .opacity(0.2)
                            .frame(width: baseSize.width, height: baseSize.height)
                    }
                    .frame(maxWidth: geo.size.width * 0.5, alignment: .topLeading)
                    .border(Color.red, width: 2)
                    .padding()
            }
        }
    }
    

    Animation