Search code examples
animationswiftuimatchedgeometryeffect

SwiftUI move image between pages animation


I've been trying for days but I couldn't find a solution. My goal is to move the Image from one location to another. But I don't want the image to only move up and down while moving it. I want it to look like the image is sliding while the page is sliding. I'm sorry if I didn't explain it clearly. When you run the code on your computer, you will understand the animation I am trying to make. I ask for your help


struct SwiftUIView: View {
    @State private var showFirstView = true
    @Namespace var namespace
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                if showFirstView {
                    FirstView()
                        .transition(.move(edge: .trailing))
                        .frame(width: geometry.size.width, height: geometry.size.height)
                } else {
                    SecondView()
                        .transition(.move(edge: .leading))
                        .frame(width: geometry.size.width, height: geometry.size.height)
                }
            }
            .animation(.easeInOut(duration: 0.5), value: showFirstView)
        }
        .overlay(alignment: .bottom) {
            Button(action: {
                withAnimation {
                    showFirstView.toggle()
                }
            }) {
                Text("Switch View")
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            }
            .padding()
        }
    }
    
    func FirstView() -> some View {
        ZStack{
            Color.red
                .ignoresSafeArea()
            cusview()
        }
    }
    
    func SecondView() -> some View {
        ZStack {
            Color.green
                .ignoresSafeArea()
            cusview()
                .offset(y: -150)
        }
    }
    
    func cusview() -> some View {
        Image(.image10)
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(width: 140, height: 100)
            .matchedGeometryEffect(id: "one", in: namespace)
    }
}
 
#Preview {
    SwiftUIView()
}

Solution

  • When I tried your code in a simulator (iPhone 15 running iOS 17.5), the animation of the image was disconnected:

    • the image in the appearing view moves horizontally and vertically, so it comes in diagonally
    • the image in the disappearing view only moves vertically.

    You said in your post:

    I don't want the image to only move up and down

    so I was asking in a comment, how exactly you do want it to move? I would suggest, there would be two conceivable ways to animate the image, if the movement should not be vertical-only:

    1. The image in the appearing view could stay as you have it (diagonal movement), but the image in the disappearing view could also move diagonally, instead of only vertically.

      This would mean, when one image moves off to the right, the new image comes in from the left at the same height. This would still look disconnected, but at least the image would have a consistent direction of movement.

      I tried to find a way to implement it this way, but wasn't successful, sorry.

    2. The animation would be less disconnected if the image would move smoothly from one position to the other. If you don't want the animation to be vertical-only, then the image would need to move in some kind of curve instead.

    This second style of animation can be achieved by showing the image as another layer in the ZStack and have it move between placeholder positions in the two child views.

    • The placeholders use matchedGeometryPostion with isSource: true (which is in fact the default), so these determine the positions being moved between.

    • The image itself uses matchedGeometryPosition with isSource: false, so that it is matched to the source positions.

    • By default, matchedGeometryEffect will set the size of the image from the active placeholder, so there is no need to set a separate frame on the image. However, it's best if the image is shown in an overlay over the ZStack, instead of as a regular layer. This ensures that the image at its natural size does not cause the size of the ZStack to bloat, for the case of when the image size is larger than the screen size.

    A curved path can be achieved by using different animation durations for the transitions and the matchedGeometryEffect:

    • if the matchedGeometryEffect is faster than the transition, the image follows a curved path on the side of the disappearing view
    • if the matchedGeometryEffect is slower than the transition, the image follows a curved path on the side of the appearing view
    • if in fact the animation durations are the same, the image just moves vertically (which you said you didn't want).

    The updated example below shows it working this way. Other changes and suggestions:

    • The two child views already occupy all the space available, because they both contain a Color, which is greedy. This means, there is no need to set a frame on these views, so the GeometryReader is not needed.

    • A GeometryReader would not be needed, even if the child views did not contain a Color. If you want to enlarge a view to maximum size, set a frame with maxWidth: .infinity, maxHeight: .infinity.

    • If you have an .animation modifier, the flag does not need to be toggled withAnimation. In fact, using separate .animation modifiers instead of withAnimation is the key to being able to control the way the image moves.

    • The modifier .scaledToFill is a simpler way of applying .aspectRatio(contentMode: .fill).

    • The modifier .foregroundColor is deprecated, use .foregroundStyle instead.

    • The modifier .cornerRadius is deprecated too, use .clipShape instead, or just show a RoundedRectangle in the background.

    • The button style .borderedProminent is actually an easier way to achieve the same button styling. To make it look exactly like you had it before, you just need to add a little padding to the label.

    • Both the action and the label for the button can be supplied as trailing closures, which avoids a set of parentheses. Doing it this way means labelling the one for the label, instead of the one for the action.

    • The button is being shown in an overlay as a way of positioning it at the bottom of the ZStack. This is fine, but you could also consider making it another layer of the ZStack and positioning by setting maxHeight: .infinity with alignment: .bottom. The result is the same, so it's just down to personal preference.

    struct SwiftUIView: View {
        @State private var showFirstView = true
        @Namespace var namespace
    
        var body: some View {
            ZStack {
                if showFirstView {
                    FirstView()
                        .transition(.move(edge: .trailing))
                } else {
                    SecondView()
                        .transition(.move(edge: .leading))
                }
            }
            .animation(.easeInOut(duration: 0.8), value: showFirstView)
            .overlay {
                cusview()
    
                    // Try adjusting the duration to get different movements
                    .animation(.easeInOut(duration: 0.7), value: showFirstView)
            }
            .overlay(alignment: .bottom) {
                Button {
                    showFirstView.toggle()
                } label: {
                    Text("Switch View")
                        .padding(.horizontal, 4)
                        .padding(.vertical, 9)
                }
                .buttonStyle(.borderedProminent)
                .padding()
            }
        }
    
        func FirstView() -> some View {
            ZStack{
                Color.red
                    .ignoresSafeArea()
                imagePlaceholder
            }
        }
    
        func SecondView() -> some View {
            ZStack {
                Color.green
                    .ignoresSafeArea()
                imagePlaceholder
                    .offset(y: -150)
            }
        }
    
        private var imagePlaceholder: some View {
            Color.clear
                .frame(width: 140, height: 100)
                .matchedGeometryEffect(id: "one", in: namespace, isSource: true)
        }
    
        func cusview() -> some View {
            Image(.image10)
                .resizable()
                .scaledToFill()
                .matchedGeometryEffect(id: "one", in: namespace, isSource: false)
        }
    }
    

    Animation