animationswiftuimatchedgeometryeffect

Trying to make an image rotate as it scales/moves between two different hierarchies with matchedGeometryEffect


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

enter image description here


Solution

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

    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
    }
    

    Animation


    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.