software-mansion/react-native-gesture-handler

GestureDetector must be used as a descendant of GestureHandlerRootView. Otherwise the gestures will not be recognized

spthomas5 opened this issue · 11 comments

Description

I previously was not getting this error, but I recently updated many of my libraries (this includes Expo SDK 50) and now when I go on my dev build, I get this error wherever I use GestureDetector. This includes the camera, where it handles double tapping to flip the camera. It also includes all screens where posts are displayed, where it handles double tapping to like. However, my TestFlight app works fine, including the double tap gestures that I use. I'm not sure why I get this error in the first place, as I already have GestureHandlerRootView around my entry point.

Steps to reproduce

This is my index.js

import "react-native-gesture-handler";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native";
import Welcome from "./components/Welcome";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";

export default function Page() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <BottomSheetModalProvider>
        <Welcome></Welcome>
      </BottomSheetModalProvider>
    </GestureHandlerRootView>
  );
}

And this is an example of my use of GestureDetector

<GestureDetector gesture={Gesture.Exclusive(doubleTap)}>
          <View style={{ flex: 1, justifyContent: "flex-end" }}>
             # code not included, but this is just my camera and the camera buttons
          </View>
</GestureDetector>

Snack or a link to a repository

None

Gesture Handler version

2.14.0

React Native version

0.73.6

Platforms

iOS

JavaScript runtime

Hermes

Workflow

Expo managed workflow

Architecture

None

Build type

None

Device

Real device

Device model

iPhone 14 Pro, iOS 17.1.2

Acknowledgements

Yes

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?

As of now I seem to have gotten around this issue by wrapping all instances of GestureDetector with GestureHandlerRootView, but I'm not sure if this is the intended solution.

<GestureHandlerRootView style={{ flex: 1 }}>
          <GestureDetector gesture={Gesture.Exclusive(doubleTap)}>
            <Image
              source={item.source}
              style={{
                height: windowHeight / 1.8,
                width: windowWidth,
                marginTop: "5%",
                alignSelf: "center",
              }}
              transition={1000}
              contentFit="cover"
              cachePolicy={"disk"}
            />
          </GestureDetector>
</GestureHandlerRootView>

Hi @spthomas5! I can't see it directly in your snippets, but our guess is that you are using GestureDetector inside modal. Since they represent another view hierarchy, you should also wrap modal's children into GestureHandlerRootView

Also, you don't have to pass {flex: 1} into GestureHandlerRootView since this is already done by us (added in #2757).

Edit:

PR with default {flex: 1} was released yesterday, so it is possible that it might not be available in your application yet.

@m-bert Thank you for the reply. Thanks to your help, I no longer receive the error. However, now my modal (BottomSheetModal from gorhom) no longer works as it did before. I understand if you are not familiar with the library, but if you want to take a look, I have more details here.
gorhom/react-native-bottom-sheet#1779
If not, no worries. I appreciate your help!

I'm glad that you don't receive errors anymore. I'm not that familiar with react-native-bottom-sheet so probably I won't be able to help. As I see you've already created an issue in their repository, therefore I'll close this one. If you find out that the problem still lies in Gesture Handler, feel free to open new issue.

Why use GestureHandlerRootView? It's not user-friendly. For instance, when I create a custom component and I want to inform the user that this component needs to be wrapped in a GestureHandlerRootView at the top level of the element tree. If I place the root component inside the custom component, it could potentially mess up the user's layout hierarchy and styles.

Why use GestureHandlerRootView? It's not user-friendly.

In order to work properly, our gesture system requires root view on Android. It was there from the beginning and, while we understand that this API may not be the best, it is necessary. We try to make it more user-friendly with PRs like adding {flex: 1;} by default. If you have any other suggestions we will be glad to receive them.

when I create a custom component and I want to inform the user that this component needs to be wrapped in a GestureHandlerRootView

I don't understand why you should tell your users that they need to use our root view. Are you maintaining some sort of library?

If I place the root component inside the custom component, it could potentially mess up the user's layout hierarchy and styles.

Same here. Why it would mess up user's hierarchy? Root view is just View that allows gesture handler to work - you can style it as you would style normal view.

Why use GestureHandlerRootView? It's not user-friendly.

In order to work properly, our gesture system requires root view on Android. It was there from the beginning and, while we understand that this API may not be the best, it is necessary. We try to make it more user-friendly with PRs like adding {flex: 1;} by default. If you have any other suggestions we will be glad to receive them.

when I create a custom component and I want to inform the user that this component needs to be wrapped in a GestureHandlerRootView

I don't understand why you should tell your users that they need to use our root view. Are you maintaining some sort of library?

If I place the root component inside the custom component, it could potentially mess up the user's layout hierarchy and styles.

Same here. Why it would mess up user's hierarchy? Root view is just View that allows gesture handler to work - you can style it as you would style normal view.

Thanks for your reply! Yesterday, I was working on a component called Movable. It is a container that allows anything inside it to be dragged. However, I discovered that if I don't place a fullscreen GestureHandlerRootView on top of it, the Movable component won't function correctly.
So, I attempted to add a GestureHandlerRootView with position: absolute styles. Unfortunately, it ended up blocking any other components from being able to be touched underneath it.
So. I come out this question...

import React, { PropsWithChildren } from 'react'
import { LayoutChangeEvent, Dimensions, StyleSheet } from 'react-native'
import isEqual from 'lodash.isequal'
import Animated, {
  clamp,
  runOnJS,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import snapPoint from './utils/snapPoint'

const defaultProps = {
  padding: 20,
  initPosition: { left: 0, top: 0 },
}

const styles = StyleSheet.create({
  container: {
    position: 'absolute',
  },
})

const _safeAreaBottom = 100
const _safeAreaTop = 0

export type Props = {
  initPosition: { left?: number; top?: number; bottom?: number; right?: number }
  padding?: number
  paddingTop?: number
  paddingBottom?: number
  paddingRight?: number
  paddingLeft?: number
  safeAreaBottom?: number
  safeAreaTop?: number
}

const windowWidth = Dimensions.get('screen').width
const windowHeight = Dimensions.get('screen').height

const Movable: React.FC<PropsWithChildren<Props>> = (props) => {
  const {
    padding = defaultProps.padding,
    initPosition = defaultProps.initPosition,
    safeAreaBottom = _safeAreaBottom,
    safeAreaTop = _safeAreaTop,
  } = props

  const paddingTop = props.paddingTop || padding
  const paddingBottom = props.paddingBottom || padding
  const paddingRight = props.paddingRight || padding
  const paddingLeft = props.paddingLeft || padding

  const contentWidth = useSharedValue<number>(0)
  const contentHeight = useSharedValue<number>(0)
  const initX =
    paddingRight + (initPosition.left || defaultProps.initPosition.left)
  const initY =
    paddingBottom + (initPosition.top || defaultProps.initPosition.top)

  const visible = useSharedValue(0)
  const offsetX = useSharedValue(initX)
  const offsetY = useSharedValue(initY)
  const transX = useSharedValue(initX)
  const transY = useSharedValue(initY)

  const handleLayout = (event: LayoutChangeEvent) => {
    const { width, height } = event.nativeEvent.layout
    contentWidth.value = width
    contentHeight.value = height
    visible.value = 1
    transX.value = offsetX.value = windowWidth - width - paddingRight
    transY.value = offsetY.value =
      windowHeight - height - paddingBottom - safeAreaBottom
  }

  const onEnd = (event: any, currentX: number) => {
    const toX = snapPoint(currentX, event.velocityX, [
      0 + paddingLeft,
      windowWidth - contentWidth.value - paddingRight,
    ])
    transX.value = withTiming(toX, { duration: 200 }, () => {
      offsetX.value = transX.value
      offsetY.value = transY.value
    })
  }

  const pan = Gesture.Pan()
    .onStart(() => {
      transX.value = offsetX.value
      transY.value = offsetY.value
    })
    .onUpdate((event) => {
      transX.value = offsetX.value + event.translationX
      transY.value = clamp(
        offsetY.value + event.translationY + safeAreaTop,
        0 + paddingTop,
        windowHeight - contentHeight.value - paddingBottom - safeAreaBottom,
      )
    })
    .onFinalize((event) => {
      runOnJS(onEnd)(event, transX.value)
    })

  const transformStyles = useAnimatedStyle(() => {
    return {
      opacity: visible.value,
      transform: [
        {
          translateX: transX.value,
        },
        {
          translateY: transY.value,
        },
      ],
    }
  })

  return (
    <GestureDetector gesture={pan}>
      <Animated.View
        onLayout={handleLayout}
        style={[styles.container, transformStyles]}>
        {props.children}
      </Animated.View>
    </GestureDetector>
  )
}

export default React.memo(Movable, isEqual)

I've played a bit with your example and I believe that it works ok. First of all, I had to make some modifications to run it (for example I had no access to snapPoint function). You can check my code here:

Modified code
import React, { PropsWithChildren } from 'react';
import { View, LayoutChangeEvent, Dimensions, StyleSheet } from 'react-native';
import Animated, {
  runOnJS,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';
import {
  Gesture,
  GestureDetector,
  GestureHandlerRootView,
} from 'react-native-gesture-handler';

const defaultProps = {
  padding: 20,
  initPosition: { left: 0, top: 0 },
};

const styles = StyleSheet.create({
  container: {
    position: 'absolute',
    borderWidth: 2,
  },
});

const _safeAreaBottom = 100;
const _safeAreaTop = 0;

export type Props = {
  initPosition: {
    left?: number;
    top?: number;
    bottom?: number;
    right?: number;
  };
  padding?: number;
  paddingTop?: number;
  paddingBottom?: number;
  paddingRight?: number;
  paddingLeft?: number;
  safeAreaBottom?: number;
  safeAreaTop?: number;
};

const windowWidth = Dimensions.get('screen').width;
const windowHeight = Dimensions.get('screen').height;

const Movable: React.FC<PropsWithChildren<Props>> = (props) => {
  const {
    padding = defaultProps.padding,
    initPosition = defaultProps.initPosition,
    safeAreaBottom = _safeAreaBottom,
    safeAreaTop = _safeAreaTop,
  } = props;

  const paddingTop = props.paddingTop || padding;
  const paddingBottom = props.paddingBottom || padding;
  const paddingRight = props.paddingRight || padding;
  const paddingLeft = props.paddingLeft || padding;

  const contentWidth = useSharedValue<number>(0);
  const contentHeight = useSharedValue<number>(0);
  const initX =
    paddingRight + (initPosition.left || defaultProps.initPosition.left);
  const initY =
    paddingBottom + (initPosition.top || defaultProps.initPosition.top);

  const visible = useSharedValue(0);
  const offsetX = useSharedValue(initX);
  const offsetY = useSharedValue(initY);
  const transX = useSharedValue(initX);
  const transY = useSharedValue(initY);

  const handleLayout = (event: LayoutChangeEvent) => {
    const { width, height } = event.nativeEvent.layout;
    contentWidth.value = width;
    contentHeight.value = height;
    visible.value = 1;
    transX.value = offsetX.value = windowWidth - width - paddingRight;
    transY.value = offsetY.value =
      windowHeight - height - paddingBottom - safeAreaBottom;
  };

  const onEnd = (event: any, currentX: number) => {
    transX.value = withTiming(150, { duration: 200 }, () => {
      offsetX.value = transX.value;
      offsetY.value = transY.value;
    });
  };

  const pan = Gesture.Pan()
    .onStart(() => {
      transX.value = offsetX.value;
      transY.value = offsetY.value;
    })
    .onUpdate((event) => {
      transX.value = offsetX.value + event.translationX;
      transY.value = offsetY.value + event.translationY;
    })
    .onFinalize((event) => {
      runOnJS(onEnd)(event, transX.value);
    });

  const transformStyles = useAnimatedStyle(() => {
    return {
      opacity: visible.value,
      transform: [
        {
          translateX: transX.value,
        },
        {
          translateY: transY.value,
        },
      ],
    };
  });

  return (
    <GestureDetector gesture={pan}>
      <Animated.View
        onLayout={handleLayout}
        style={[styles.container, transformStyles]}>
        {props.children}
      </Animated.View>
    </GestureDetector>
  );
};

export default function Example() {
  return (
    <GestureHandlerRootView>
      <Movable initPosition={{ left: 0, top: 0 }}>
        <View style={{ width: 100, height: 100, backgroundColor: 'lime' }} />
        <View style={{ width: 100, height: 100, backgroundColor: 'crimson' }} />
      </Movable>
    </GestureHandlerRootView>
  );
}

This is how it works:

Nagranie.z.ekranu.2024-05-7.o.13.52.38.mov

Note that you can also move GestureHandlerRootView into your Movable component. So instead of doing:

    <GestureHandlerRootView>
      <Movable initPosition={{ left: 0, top: 0 }}>
        <View style={{ width: 100, height: 100, backgroundColor: 'lime' }} />
        <View style={{ width: 100, height: 100, backgroundColor: 'crimson' }} />
      </Movable>
    </GestureHandlerRootView>

You can do:

    <GestureHandlerRootView>
      <GestureDetector gesture={pan}>
        <Animated.View
          onLayout={handleLayout}
          style={[styles.container, transformStyles]}>
          {props.children}
        </Animated.View>
      </GestureDetector>
    </GestureHandlerRootView>

which would be better if you export your component to other users.

I think the problem comes from the fact that you try to use {position: absolute;} on GestureHandlerRootView. Gestures work only on area occupied by GestureHandlerRootView, therefore if some of its children are outside of its bounds, their gestures won't work.

I've also decided to add this information to our docs since it wasn't mentioned there and I believe it could be a bit confusing 😅