denistepp
denistepp

Reputation: 530

TapGestureHandler Animation is Active while Scrolling ScrollView in React Native

My <ScrollView> has multiple children with custom animated component that is based on <TapGestureHandler. The problem is that the animation turns on (scale and ripple) even when I scroll and it shouldn't. I want to block the animation on scrolling

I have tried:

  1. passing state from onScrollBeginDrag and onScrollEndDrag - not liable, causes performance drop and too much duplication
  2. using Touchables instead of gesture handlers (such as delayPressIn) - does not work
  3. setting timeout inside an event - an error as the calls are async inside gesture hooks

example of the component:

const tapGestureEvent
    = useAnimatedGestureHandler<TapGestureHandlerGestureEvent>({
      onStart: (tapEvent) => {
        const layout = measure(aRef)
        width.value = layout.width
        height.value = layout.height

        centerX.value = tapEvent.x
        centerY.value = tapEvent.y

        pressed.value = true
        rippleOpacity.value = 1
        scale.value = 0
        scale.value = withTiming(1, { duration: RIPPLE_DURATION })
      },
      onActive: () => {
        runOnJS(onPressTap)()
      },
      onFinish: () => {
        rippleOpacity.value = withTiming(0)
        pressed.value = false
      },
    })

return (
    <GestureHandlerRootView>
      <Animated.View ref={aRef} style={disabledScaling ? {} : animatedStyle}>
        <LinearGradient
          start={LINEAR_GRADIENT_START}
          end={LINEAR_GRADIENT_END}
          colors={gradientColors}
          style={[s.linearGradient, gradientStyle]}
        >

          <TapGestureHandler enabled={!disabled} onGestureEvent={tapGestureEvent}>
            <Animated.View style={[style, s.animated]}>
              {children}
              <Animated.View style={rStyle} />
            </Animated.View>
          </TapGestureHandler>
        </LinearGradient>
      </Animated.View>
    </GestureHandlerRootView>
  )

I was looking for some kind of prop that could handle the scroll somehow, but did not find any solution for this.

Upvotes: 0

Views: 973

Answers (1)

Taxidermic
Taxidermic

Reputation: 88

Gestures are indeed very tricky to configure! Here are few suggestions on how I would solve this.

  1. The reason for the current behavior is that onStart triggers immediately after the user starts interacting with a Touchable. If you want to show animations only when user explicitly selects the item, you can adjust the config for touchable this way:

  const tapGestureEvent
    = useAnimatedGestureHandler<TapGestureHandlerGestureEvent>({
      onEnd: (tapEvent) => {         // <=========== use onEnd
        const layout = measure(aRef)
        width.value = layout.width
        height.value = layout.height

        centerX.value = tapEvent.x
        centerY.value = tapEvent.y

        pressed.value = true
        rippleOpacity.value = 1
        scale.value = 0
        scale.value = withTiming(1, { duration: RIPPLE_DURATION })

        /* moved logic from onFinish here, because onFinish is also a general event */
        rippleOpacity.value = withTiming(0) 
        pressed.value = false

        /* same for onActive  */
        runOnJS(onPressTap)()
      },
    })

onEnd triggers when the action is finished and the responder is marked as the main receiver, there is a nice explanation of the whole interaction flow here: https://docs.swmansion.com/react-native-gesture-handler/docs/under-the-hood/states-events#state-flows

  1. Another approach would be using onResponder... props of react-native View. RN has a full description in docs here: https://reactnative.dev/docs/gesture-responder-system Like this:

  const showPressIn = (x1: number, y1: number, cb: () => void) => {
    aRef.current?.measure((layoutX, layoutY, layoutWidth, layouthHeight) => {
      width.value = layoutWidth
      height.value = layouthHeight

      centerX.value = layoutX
      centerY.value = layoutY

      pressed.value = true
      rippleOpacity.value = 1
      scale.value = 0
      scale.value = withTiming(1, { duration: RIPPLE_DURATION })
      cb()
    })
  }

  const showPressOut = () => {
    const timer = setTimeout(() => {
      InteractionManager.runAfterInteractions(() => {
        rippleOpacity.value = withTiming(0)
        pressed.value = false
        onTap()
        clearTimeout(timer)
      })
    }, 200)
  }

  const onResponderRelease = (e: GestureResponderEvent) => {
    showPressIn(e.nativeEvent.locationX, e.nativeEvent.locationY, showPressOut)
  }

  const ViewProps: ViewProps = {
    onResponderRelease,                    // <=== this called on interaction finish
    onStartShouldSetResponder: () => true, // <=== this enables responding in the <View/>
  }

  return (
    <Animated.View collapsable={false} ref={aRef} style={disabledScaling ? {} : animatedStyle}>
      <LinearGradient
        start={LINEAR_GRADIENT_START}
        end={LINEAR_GRADIENT_END}
        colors={gradientColors}
        style={[s.linearGradient, gradientStyle]}
      >
        <View {...ViewProps}>
          <Animated.View style={[style, s.animated]}>
            {children}
            <Animated.View style={rStyle} />
          </Animated.View>
        </View>
      </LinearGradient>
    </Animated.View>
  )

But this solution is more buggy since the coordinates do not match the real ones. So your animation will not be centered in the right position, here is an issue that's closed but not solved: https://github.com/facebook/react-native/issues/31945. There are some screenshots of the current behavior in RN. Also, it'll require hacks in measuring the View and timeouts for animations, InteractionManager won't give the desired behavior, since those animations are not really the events it's built for.

Upvotes: 1

Related Questions