software-mansion / react-native-gesture-handler

Declarative API exposing platform native touch and gesture system to React Native.
https://docs.swmansion.com/react-native-gesture-handler/
MIT License
6.15k stars 984 forks source link

Pan Gesture stops calling event callbacks after inactivity #3251

Open Krupskis opened 3 days ago

Krupskis commented 3 days ago

Description

I am using Pan Gesture to build a vertical pager for 2 pages. Each page is a flatlist of items. The idea is to activate pan gesture when either at the bottom of the top list or at the top of the bottom list and animated transition between pages, pages are just views of SCREEN_HEIGHT contained within a view of 2 * SCREEN_HEIGHT.

The code is fairly simple, the issue is after some time of inactivity in the app onBegin and onUpdate and onEnd callbacks won't get called anymore making the pager not functional. When that happens I see isPagerEnabled is true, but no logs from the callbacks are coming through. Refresh of the app fixes the issue.

I've tried to stress test the pager by switching pager rapidly, or cancelling animation mid page switch, but couldn't replicate the issue - it all happens pretty randomly.

const { height: SCREEN_HEIGHT } = Dimensions.get("window");

export const VerticalPager = () => {
  const { setHeading: setNavbarHeading } = useTopNavbarContext();

  const topListRef = useRef<FlatList>(null);

  const translateY = useSharedValue(-SCREEN_HEIGHT);
  const startY = useSharedValue(-SCREEN_HEIGHT);
  const isPagerEnabled = useSharedValue(false);

  const flatListTopGesture = useRef(Gesture.Native()).current;
  const flatListBottomGesture = useRef(Gesture.Native()).current;

  const setHeading = useCallback(
    (heading: string) => {
      setNavbarHeading(heading);
    },
    [setNavbarHeading]
  );

  const panGesture = useMemo(() => {
    return Gesture.Pan()
      .enabled(true)
      .minDistance(0)
      .activeOffsetY([-10, 10])
      .onBegin(() => {
        console.log("on begin called");
        startY.value = translateY.value;
      })
      .onUpdate((event) => {
        console.log("on update aclled");
        if (!isPagerEnabled.value) {
          return;
        }
        translateY.value = startY.value + event.translationY;
        translateY.value = Math.max(Math.min(translateY.value, 0), -SCREEN_HEIGHT);
      })
      .onEnd((event) => {
        const velocity = event.velocityY;
        const threshold = SCREEN_HEIGHT / 6;
        let destination: number;

        // translateY.value is how much has been swiped between pages.
        const aboveThreshold = Math.abs(startY.value - translateY.value) > threshold;
        const firstScreen = Math.abs(startY.value - 0) < Math.abs(startY.value - -SCREEN_HEIGHT);

        // condition when to switch pages
        if (aboveThreshold) {
          // Higher velocity threshold
          destination = velocity > 0 ? 0 : -SCREEN_HEIGHT;
        } else {
          // Don't remove this, this brings screen back if condition is not met
          destination = firstScreen ? 0 : -SCREEN_HEIGHT;
        }

        runOnJS(setHeading)(destination === 0 ? "" : "Today");

        translateY.value = withSpring(destination, {
          velocity: velocity,
          stiffness: 80, // Higher stiffness on Android
          damping: 10, // Higher damping on Android
          mass: 0.8,
          restSpeedThreshold: 0.3,
        });
      })
      .simultaneousWithExternalGesture(flatListTopGesture, flatListBottomGesture);
  }, [setHeading]);

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: translateY.value }],
  }));

  const onScrollUpperList = useCallback((event) => {
    const offsetY = event.nativeEvent.contentOffset.y;
    const isAtBottom = offsetY <= 1;

    if (isAtBottom !== isPagerEnabled.value) {
      isPagerEnabled.value = isAtBottom;
    }
  }, []);

  const onScrollLowerList = useCallback((event) => {
    const offsetY = event.nativeEvent.contentOffset.y;
    const contentHeight = event.nativeEvent.contentSize.height;
    const scrollViewHeight = event.nativeEvent.layoutMeasurement.height;

    const isAtTop = offsetY + scrollViewHeight - contentHeight > -1;

    if (isAtTop !== isPagerEnabled.value) {
      isPagerEnabled.value = isAtTop;
    }
  }, []);

  return (
      <GestureDetector gesture={panGesture}>
        <Animated.View
          style={[
            {
              height: SCREEN_HEIGHT * 2,
              backfaceVisibility: "hidden",
              position: "absolute",
              left: 0,
              right: 0,
            },
            animatedStyle,
          ]}
        >
          {/* Inverted flatlist */}
          <TopList
            flatListTopGesture={flatListTopGesture}
            onScrollUpperList={onScrollUpperList}
            topListRef={topListRef}
          />
          {/* Inverted flatlist */}
          <BottomList
            onScrollLowerList={onScrollLowerList}
            flatListBottomGesture={flatListBottomGesture}
          />
        </Animated.View>
      </GestureDetector>
  );
};

Steps to reproduce

There is no clear reproduction steps, the behavior seems flaky, happens after some time of inactivity.

Snack or a link to a repository

can't really reproduce this, issue is flaky

Gesture Handler version

2.21.2

React Native version

0.76.2

Platforms

iOS

JavaScript runtime

None

Workflow

React Native (without Expo)

Architecture

Fabric (New Architecture)

Build type

Debug mode

Device

Real device

Device model

Iphone 15 pro

Acknowledgements

Yes

github-actions[bot] commented 3 days ago

Hey! 👋

The issue doesn't seem to contain a minimal reproduction.

Could you provide a snack or a link to a GitHub repository under your username that reproduces the problem?

Krupskis commented 2 days ago

Did some more investigation, seems like Pan Gesture stops working after navigation to other screens. We wrote our own Tab Navigation and use display: "none" to hide inactive screens, rewrote to use opacity: 0 instead and seems to work now.

m-bert commented 2 days ago

Hi! Could you share a copy-pastable reproduction? This one misses some of the functions masking it harder to find out cause of this bug.