iosswiftanimationswiftui

Make tap animation on item inside ScrollView


I want the items inside LazyVGrid to have a tap modifier (change their scale) and at the same time be able to scroll. I set up a tap modifier for each element and it really works, but the ability to scroll the content disappears, but if I disable my custom tap effect, then scrolling becomes available again. How can I make a click effect and the ability to scroll the content at the same time?

struct ScaledTappable: ViewModifier {
    
    @State var state = false
    var tapHandler: () -> Void
    
    func body(content: Content) -> some View {
        content
            .scaleEffect(state ? 0.9 : 1)
            .gesture(
                DragGesture(minimumDistance: 0)
                    .onChanged({ value in
                        withAnimation(.smooth(duration: 0.2)) {
                            state = true
                        }
                    })
                    .onEnded({ value in
                        withAnimation(.bouncy(duration: 0.5)) {
                            state = false
                            tapHandler()
                        }
                    })
            )
    }
}

extension View {
    
    @ViewBuilder
    func tappable(enabled: Bool = true, onTap: @escaping () -> Void) -> some View {
        if enabled {
            self.modifier(ScaledTappable(tapHandler: onTap))
        } else {
            self.opacity(0.3)
        }
    }
}

Solution

  • Instead of using a DragGesture to intercept taps, try using .onTapGesture. Then, use a completion callback on the animation to perform the follow-on action (requires iOS 17):

    // ScaledTappable
    
    func body(content: Content) -> some View {
        content
            .scaleEffect(state ? 0.9 : 1)
            .onTapGesture {
                withAnimation(.smooth(duration: 0.2)) {
                    state = true
                } completion: {
                    withAnimation(.bouncy(duration: 0.5)) {
                        state = false
                        tapHandler()
                    }
                }
            }
    }
    

    Another way to perform the animation would be to use .phaseAnimator (also requires iOS 17):

    struct ScaledTappable: ViewModifier {
        @State private var trigger = 0
        var tapHandler: () -> Void
    
        func body(content: Content) -> some View {
            content
                .onTapGesture {
                    trigger += 1
                    tapHandler()
                }
                .phaseAnimator([false, true], trigger: trigger) { content, phase in
                    content
                        .scaleEffect(phase ? 0.9 : 1)
                } animation: { phase in
                    phase ? .smooth(duration: 0.2) : .bouncy(duration: 0.5)
                }
        }
    }
    

    For earlier iOS versions, consider using an Animatable ViewModifier for performing the follow-on action after the first part of the animation has completed. See this answer for a generic implementation.

    Example of doing it this way:

    struct ScaledTappable: ViewModifier {
        @State private var scalingFactor: CGFloat = 1
        var tapHandler: () -> Void
    
        func body(content: Content) -> some View {
            content
                .scaleEffect(scalingFactor)
                .onTapGesture {
                    withAnimation(.smooth(duration: 0.2)) {
                        scalingFactor = 0.9
                    }
                }
                // See https://stackoverflow.com/a/76969841/20386264
                .modifier(AnimationCompletionCallback(animatedValue: scalingFactor) {
                    if scalingFactor < 1 {
                        withAnimation(.bouncy(duration: 0.5)) {
                            scalingFactor = 1
                            tapHandler()
                        }
                    }
                })
        }
    }
    

    Example use (same for all implementation variants):

    ScrollView {
        LazyVStack {
            ForEach(1..<100) { i in
                Text("Row \(i)")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background {
                        RoundedRectangle(cornerRadius: 10)
                            .fill(.yellow)
                    }
                    .tappable {
                        print("row \(i) tapped")
                    }
            }
        }
        .padding()
    }
    

    Animation