skip to Main Content

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

2

Answers


  1. This is is caused because your DragGesture is interfering with the scrolling behavior. You need to wrap your DragGesture code into SimultaneousGesture to make it work. So instead of using gesture modifier, use simultaneousGesture to combine the scrolling with tap gesture.

    struct ScaledTappable: ViewModifier {
        
        @State var state = false
        var tapHandler: () -> Void
        
        func body(content: Content) -> some View {
            content
                .scaleEffect(state ? 0.9 : 1)
                .simultaneousGesture( // change this
                    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)
            }
        }
    }
    
    Login or Signup to reply.
  2. 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 {
                    scalingFactor = 0.9
                }
                // See https://stackoverflow.com/a/76969841/20386264
                .modifier(AnimationCompletionCallback(animatedValue: scalingFactor) {
                    if scalingFactor < 1 {
                        scalingFactor = 1
                        tapHandler()
                    }
                })
                .animation(
                    scalingFactor < 1 ? .smooth(duration: 0.2) : .bouncy(duration: 0.5),
                    value: scalingFactor
                )
        }
    }
    

    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

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search