In Swift, how can I matchedGeometryEffect for a clipped() image without it glitching?

87 Views Asked by At

I'm familiar with how matched geometry works on normal objects or elements but it becomes glitchy and confusing when we focus on images. The code below is as far as I've gotten. It performs well when swiping out of the image but the issue is that I need the image cropped (using clipped()) for the specified frame. When i clip it, there's a glitch in the animation (toggling showing).

import SwiftUI

struct View: View {
    
    @Namespace var nm
    @State var showing = false
    
    @State private var offset: CGSize = .zero
    
    var body: some View {
        GeometryReader { geo in
            ZStack {
                if !showing {
                    VStack {
                        HStack {
                            Spacer()
                            Image("im1")
                                .resizable()
                                .matchedGeometryEffect(id: "im", in: nm)
                                .aspectRatio(contentMode: .fill)
                                .containerRelativeFrame(.horizontal) { size, axis in
                                    size * 0.224
                                }
                                .containerRelativeFrame(.vertical) { size, axis in
                                    size * 0.111
                                }
.clipped()
                                 .onTapGesture {
                                    withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
                                        showing = true
                                    }
                                }
                            Spacer()
                        }
                    }
                    .padding(.top)
                } else {
                    VStack {
                        Spacer()
                        Image("im1")
                            .resizable()
                            .matchedGeometryEffect(id: "im", in: nm)
                            .aspectRatio(contentMode: .fit)
                            .onTapGesture {
                                withAnimation(.spring(response: 0.4, dampingFraction: 0.8))  {
                                    showing = false
                                }
                            }
                            .offset(x: offset.width, y: offset.height)
                            .gesture (
                                DragGesture()
                                    .onChanged { value in
                                        if value.translation.height > 0 {
                                            withAnimation(.spring(response: 0.1, dampingFraction: 0.95)) {
                                                offset = value.translation
                                            }
                                        }
                                    }
                                    .onEnded { value in
                                        if offset.height > 100 {
                                            withAnimation(.spring(response: 0.4, dampingFraction: 0.8)){
                                                offset = .zero
                                                showing = false
                                            }
                                        } else {
                                            withAnimation(.spring(response: 0.4, dampingFraction: 0.8)){
                                                offset = .zero
                                            }
                                        }
                                    }
                            )
                        Spacer()
                    }
                }
            }
        }
    }
}

I've tried alternating between normal frame() and this containerRelativeFrame and they seem to be producing the same result. I've also tried changing the aspectRatio but still has not worked.

I am placing the .clipped() after the frame is set on the image.

To be clear, the animation still works. I just notice that you can see the border of the clipped image before it fills the space. If you were to play the animation slowly you would see what I mean.

1

There are 1 best solutions below

1
On

The way you are using .matchedGeometryEffect is to perform a transition between two images with different sizes and different aspect ratios. Due to the different aspect ratios, it is noticeable how one image fades out while the other fades in.

To avoid the fade-out/fade-in, you could try using a single image which is moving between invisible placeholders:

  • The placeholders can simply be Color.clear. They are used as the source for the .matchedGeometryEffect, defining the size and position for the image.
  • The image itself is a separate layer in the ZStack, with isSource: false.
  • The tap and drag gestures are still applied to the image, not the placeholders.
  • The GeometryReader that you had around the ZStack was redundant, so it can be dropped. However, the ZStack needs alignment: .top to work in the same way.
  • It seems that .clipped() doesn't work when the frame is being applied by .matchedGeometryEffect. As a workaround, the image can be shown as an overlay over Color.clear. This combination then needs to be clipped before applying matchedGeometryEffect.
struct MyView: View {

    @Namespace var nm
    @State var showing = false
    @State private var offset: CGSize = .zero

    var body: some View {
        ZStack(alignment: .top) {
            Color.clear
                .containerRelativeFrame(.horizontal) { size, axis in
                    size * 0.224
                }
                .containerRelativeFrame(.vertical) { size, axis in
                    size * 0.111
                }
                .matchedGeometryEffect(id: "base", in: nm, isSource: true)

            Color.clear
                .matchedGeometryEffect(id: "enlarged", in: nm, isSource: true)

            Color.clear
                .overlay {
                    Image("im1")
                        .resizable()
                        .aspectRatio(contentMode: showing ? .fit : .fill)
                }
                .offset(x: offset.width, y: offset.height)
                .clipped()
                .matchedGeometryEffect(
                    id: showing ? "enlarged" : "base",
                    in: nm,
                    isSource: false
                )
                .onTapGesture {
                    withAnimation(.spring(
                        response: showing ? 0.4 : 0.3,
                        dampingFraction: showing ? 0.8 : 0.7)
                    ) {
                        showing.toggle()
                    }
                }
                .gesture (
                    DragGesture()
                        .onChanged { value in
                            if showing && value.translation.height > 0 {
                                withAnimation(.spring(response: 0.1, dampingFraction: 0.95)) {
                                    offset = value.translation
                                }
                            }
                        }
                        .onEnded { value in
                            withAnimation(.spring(response: 0.4, dampingFraction: 0.8)){
                                showing = showing && offset.height < 100
                                offset = .zero
                            }
                        }
                )
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

Animation