Is this even theoretically possible?
The goal is for the globe to spin in 3D space as it transitions from one point to the other. The problem is that the way the views are, it would be arduous and hacky to have them somehow displayed in the same hierarchy (hence matchedGeometryEffect
).
I have tried a few tricks and had no luck. I think ultimately, even if I were to have both View1 and View2 "visible" at all times, there is no smooth animation between the two rotation values as far as matchedGeometryEffect
is concerned when it comes to a transition (works for an animation).
If one must, the globe is the foremost object in the scene. If the subviews could communicate the frame where the globe is laid out, it is possible to write a view that lies on top of the entire app and does this transition from one frame to another without matchedGeometryEffect
.
struct ContentView: View {
@State var mode: Bool = true
@Namespace private var compassAnimation
@State var rotation: CGFloat = 0
var body: some View {
ZStack {
if !mode {
View1(namespace: compassAnimation, rotation: $rotation)
.disabled(true)
} else {
View2(namespace: compassAnimation, rotation: $rotation)
.disabled(true)
}
HStack {
VStack {
Text("Goal:")
Text("(Globe spins like this)")
}
Image(systemName: "globe")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 80)
.rotation3DEffect(.degrees(rotation), axis: (x: 0, y: 1, z: 0), perspective: 0)
}
.padding()
.background(Color.green)
.clipShape(Capsule())
.padding(.bottom, 200)
Button {
withAnimation(.easeInOut(duration: 0.8)) {
mode.toggle()
if mode {
rotation = 0
} else {
rotation = 360
}
}
} label: {
Text("Switch \(mode)")
}
.buttonStyle(BorderedProminentButtonStyle())
}
}
}
struct View1: View {
let namespace: Namespace.ID
@Binding var rotation: CGFloat
var body: some View {
ZStack(alignment: .bottomLeading) {
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
Image(systemName: "globe")
.resizable()
.rotation3DEffect(.degrees(rotation), axis: (x: 0, y: 1, z: 0))
.matchedGeometryEffect(id: "globe", in: namespace)
.frame(width: 100, height: 100)
.padding()
}
.background(Color.purple)
.transition(.scale(scale: 1))
}
}
struct View2: View {
let namespace: Namespace.ID
@Binding var rotation: CGFloat
var body: some View {
ZStack(alignment: .center) {
Color
.gray
.frame(width: 300, height: 200)
.overlay(alignment: .bottomLeading) {
Image(systemName: "globe")
.resizable()
.rotation3DEffect(.degrees(rotation), axis: (x: 0, y: 1, z: 0))
.matchedGeometryEffect(id: "globe", in: namespace)
.frame(width: 200, height: 200)
.padding()
.offset(x: -75, y: 75)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Color.blue)
.ignoresSafeArea()
.transition(.scale(scale: 1))
}
}
If the views being switched needed to be rotated in their entirety as the switch happens then a custom Transition
could be used. However, the globe is contained inside the views that are being switched, so a different approach is necessary.
You are already applying .scale(scale: 1)
as the transition, so this ensures that the appearing view appears imediately and the disappearing view also disappears immediately.
.matchedGeometryEffect
takes care of animating the change in the globe's geometry.onAppear
.Here is how the two views can be updated to work this way:
struct View1: View {
let namespace: Namespace.ID
@State private var rotation = CGFloat.zero // π local state variable instead of binding
var body: some View {
ZStack(alignment: .bottomLeading) {
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
Image(systemName: "globe")
.resizable()
.rotation3DEffect(.degrees(rotation), axis: (x: 0, y: 1, z: 0))
.matchedGeometryEffect(id: "globe", in: namespace)
.frame(width: 100, height: 100)
.padding()
.animation(.easeInOut(duration: 0.8), value: rotation) // π added
}
.background(Color.purple)
.onAppear { rotation = 360 } // π added
.transition(.scale(scale: 1))
}
}
struct View2: View {
let namespace: Namespace.ID
@State private var rotation = CGFloat.zero // π
var body: some View {
ZStack(alignment: .center) {
Color
.gray
.frame(width: 300, height: 200)
.overlay(alignment: .bottomLeading) {
Image(systemName: "globe")
.resizable()
.rotation3DEffect(.degrees(rotation), axis: (x: 0, y: 1, z: 0))
.matchedGeometryEffect(id: "globe", in: namespace)
.frame(width: 200, height: 200)
.padding()
.offset(x: -75, y: 75)
.animation(.easeInOut(duration: 0.8), value: rotation) // π
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Color.blue)
.ignoresSafeArea()
.onAppear { rotation = 360 } // π
.transition(.scale(scale: 1))
}
}
The parent ContentView
is essentially the same, except the angle of rotation is no longer passed as a binding to the child views:
ZStack {
if !mode {
View1(namespace: compassAnimation)
.disabled(true)
} else {
View2(namespace: compassAnimation)
.disabled(true)
}
// ... other content as before
}
The only slight issue with this approach is that the globe spins when the parent view is shown for the first time. A way to prevent this is to pass a flag to the view that is shown first (this being View2
) to indicate whether it is the initial show or not. This flag can be a state variable in ContentView
that gets updated in .onAppear
. Something like:
// Content View
@State private var isShowing = false
// ...
ZStack {
if !mode {
View1(namespace: compassAnimation)
.disabled(true)
} else {
View2(namespace: compassAnimation, isInitialShow: !isShowing)
.disabled(true)
}
// ...
}
.onAppear { isShowing = true }
// ...
// View2
let isInitialShow: Bool
// ...
ZStack(alignment: .center) {
// ... as before
}
.background(Color.blue)
.ignoresSafeArea()
.onAppear {
if !isInitialShow {
rotation = 360
}
}
.transition(.scale(scale: 1))
// ...
Of course, if it is possible that either view may be shown first then the flag can be passed to both of them.