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? :(
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:
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
.
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).
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).
Immediately after starting the animation, the new canvas size is saved. This size is the larger base size.
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.
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.
This can be done with just one state variable that records the size of the base view. This size can then be applied to the overlay, with animation.
A GeometryEffect
is not needed for this to work.
To read the size of an area, you can use a GeometryReader
, as you were doing before. However, if you want to measure the size of a particular view then the modifier .onGeometryChange
may be a more convenient way to do it. Although not apparent from its name, this modifier also reports the size on initial show.
The size of the base view depends on the size of the screen. A GeometryReader
is still a suitable way to measure the screen size.
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()
}
}
}