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
5.85k stars 954 forks source link

Android: `<PanGestureHandler>` not working in `<Modal>` #1168

Closed mrousavy closed 3 years ago

mrousavy commented 3 years ago

Description

Hi! I've read the docs about 20 times now, and I still can't seem to get my <PanGestureHandler> working on Android.

o

The View is in a Modal, but that Modal is wrapped with the gesture handler root HOC. I've also updated my MainActivity. Other PanGestureHandlers work, so it must be something with my code specifically, but I can't pinpoint it.

Code

import React, { useMemo, useEffect, useCallback, useState } from 'react';
import { View, StyleSheet, LayoutChangeEvent, LayoutRectangle, ViewStyle, StyleProp, TextInput, Platform } from 'react-native';
import { PanGestureHandler, State } from 'react-native-gesture-handler';
import { usePanGestureHandler } from 'react-native-redash';
import Reanimated, {
    useValue,
    useCode,
    cond,
    eq,
    set,
    Extrapolate,
    call,
    interpolate,
    concat,
    round,
    divide,
    neq,
    and,
    greaterThan,
    sub,
} from 'react-native-reanimated';

const THUMB_DIAMETER = Platform.OS === 'ios' ? 30 : 20;
const THUMB_RADIUS = THUMB_DIAMETER / 2;
const RAIL_HEIGHT = 3;

const GESTURE_HANDLER_ACTIVE_OFFSET_X = [-3, 3];
const GESTURE_HANDLER_FAIL_OFFSET_Y = [-5, 5];

const ReanimatedTextInput = Reanimated.createAnimatedComponent(TextInput);

export interface HighlightedSliderProps {
    style?: StyleProp<ViewStyle>;
    minValue: number;
    maxValue: number;
    value: number;
    onValueChange: (value: number) => void;
    colors: SliderColors;
    showLabel?: boolean;
    onReanimatedValueNodeChange?: (reanimatedValueNode: Reanimated.Node<number>) => void;
    textPrefix?: string;
    textSuffix?: string;
}

export interface SliderColors {
    thumbColor: string;
    activeRailColor: string;
    inactiveRailColor: string;
}

// TODO: Set reanimated values thumbX, thumbValue and offsetX if prop "value" changes.
export default function HighlightedSlider(props: HighlightedSliderProps): JSX.Element {
    const { style, minValue, maxValue, value, onValueChange, colors, textPrefix, textSuffix, onReanimatedValueNodeChange, showLabel } = props;
    const { gestureHandler, state, position } = usePanGestureHandler();
    const [layout, setLayout] = useState<LayoutRectangle>({ height: 0, width: 0, x: 0, y: 0 });

    const step = useMemo(() => (layout.width > 0 ? layout.width / (maxValue - minValue) : 1), [layout.width, maxValue, minValue]);

    const lower = useMemo(() => Math.max(minValue * step, 0), [minValue, step]);
    const upper = useMemo(() => Math.max(maxValue * step - THUMB_DIAMETER, 0), [maxValue, step]);

    const upperWidth = useMemo(() => Math.max(layout.width - THUMB_DIAMETER, 0), [layout.width]);

    //#region Animations
    const thumbX = useValue(value);
    const thumbValue = useMemo(() => {
        return interpolate(thumbX, {
            inputRange: [lower, upper],
            outputRange: [minValue, maxValue],
            extrapolate: Extrapolate.CLAMP,
        });
    }, [lower, maxValue, minValue, thumbX, upper]);
    const thumbValueString = useMemo(
        () =>
            showLabel
                ? concat(
                        textPrefix ?? '',
                        round(
                            interpolate(thumbX, {
                                inputRange: [lower, upper],
                                outputRange: [minValue, maxValue],
                                extrapolate: Extrapolate.CLAMP,
                            }),
                        ),
                        textSuffix ?? '',
                  )
                : undefined,
        [showLabel, lower, maxValue, minValue, textPrefix, textSuffix, thumbX, upper],
    );

    useCode(
        () => [
            cond(greaterThan(layout.width, 1), [
                cond(and(neq(state, State.ACTIVE), neq(state, State.END), neq(round(thumbValue), round(value))), [
                    set(
                        thumbX,
                        interpolate(value, {
                            inputRange: [minValue, maxValue],
                            outputRange: [lower, upper],
                            extrapolate: Extrapolate.CLAMP,
                        }),
                    ),
                ]),
                cond(eq(state, State.ACTIVE), [
                    set(
                        thumbX,
                        interpolate(sub(position.x, THUMB_RADIUS), {
                            inputRange: [0, upperWidth],
                            outputRange: [lower, upper],
                            extrapolate: Extrapolate.CLAMP,
                        }),
                    ),
                ]),
                cond(eq(state, State.END), [call([thumbValue], ([_thumbValue]) => onValueChange(_thumbValue))]),
            ]),
        ],
        [layout.width, lower, maxValue, minValue, onValueChange, position.x, state, thumbValue, thumbX, upper, upperWidth, value],
    );
    const activeRailScaleX = useMemo(() => {
        return Reanimated.interpolate(thumbX, {
            inputRange: [lower, upper],
            outputRange: [0, layout.width],
        });
    }, [thumbX, lower, upper, layout.width]);
    const activeRailTranslateX = useMemo(() => {
        return Reanimated.interpolate(thumbX, {
            inputRange: [lower, upper],
            outputRange: [0, divide(layout.width, 2, activeRailScaleX)],
        });
    }, [activeRailScaleX, layout.width, lower, thumbX, upper]);
    //#endregion

    //#region Memos
    const inactiveRailStyle = useMemo(() => [styles.inactiveRail, { backgroundColor: colors.inactiveRailColor }], [colors.inactiveRailColor]);
    const activeRailStyle = useMemo(
        () => [
            styles.activeRail,
            { backgroundColor: colors.activeRailColor, transform: [{ scaleX: activeRailScaleX }, { translateX: activeRailTranslateX }] },
        ],
        [activeRailScaleX, activeRailTranslateX, colors.activeRailColor],
    );
    const thumbStyle = useMemo(
        () => [styles.thumb, { shadowColor: colors.thumbColor, backgroundColor: colors.thumbColor, transform: [{ translateX: thumbX }] }],
        [colors.thumbColor, thumbX],
    );
    const viewStyle = useMemo(() => [styles.slider, style], [style]);
    //#endregion

    //#region Callbacks
    const onViewLayout = useCallback(
        ({ nativeEvent }: LayoutChangeEvent) => {
            if (JSON.stringify(layout) !== JSON.stringify(nativeEvent.layout)) setLayout(nativeEvent.layout);
        },
        [layout],
    );
    //#endregion

    //#region Effects
    useEffect(() => {
        if (onReanimatedValueNodeChange != null) onReanimatedValueNodeChange(thumbValue);
    }, [onReanimatedValueNodeChange, thumbValue]);
    //#endregion

    return (
        <PanGestureHandler {...gestureHandler} activeOffsetX={GESTURE_HANDLER_ACTIVE_OFFSET_X} failOffsetY={GESTURE_HANDLER_FAIL_OFFSET_Y}>
            <Reanimated.View style={viewStyle} onLayout={onViewLayout}>
                {showLabel && <ReanimatedTextInput style={styles.text} text={thumbValueString} editable={false} underlineColorAndroid="transparent" />}
                <View style={inactiveRailStyle}>
                    <Reanimated.View style={activeRailStyle} />
                    <Reanimated.View style={thumbStyle} />
                </View>
            </Reanimated.View>
        </PanGestureHandler>
    );
}

const styles = StyleSheet.create({
    slider: {
        width: '100%',
        alignItems: 'center',
    },
    text: {
        paddingVertical: 0,
        fontSize: 12,
        marginBottom: THUMB_RADIUS + 7,
        fontWeight: 'bold',
        color: 'black',
    },
    thumb: {
        height: THUMB_DIAMETER,
        width: THUMB_DIAMETER,
        borderRadius: THUMB_RADIUS,
        top: -THUMB_RADIUS + 1,
        position: 'absolute',
        shadowOffset: {
            height: 1,
            width: 0,
        },
        shadowOpacity: 0.7,
        shadowRadius: 2,
    },
    inactiveRail: {
        width: '100%',
        height: RAIL_HEIGHT,
        borderRadius: RAIL_HEIGHT,
    },
    activeRail: {
        width: 1,
        height: RAIL_HEIGHT,
        borderRadius: RAIL_HEIGHT,
        position: 'absolute',
        left: 0,
    },
});

Package versions

mrousavy commented 3 years ago

I've added debug statements everywhere and noticed that the <PanGestureHandler>'s onHandlerStateChange and onGestureEvent callbacks never get called. The gesture never gets recognized. Anyone know why?

mrousavy commented 3 years ago

I'm pretty sure it has something to do with pointerEvents, not sure why that would get overriden though. I've played around with the property and got it recognizing my gesture once, but it hasn't worked since then. Is this a bug?

Maybe because my thumb has position: 'absolute'?

co2nut commented 3 years ago

same thing happened to me for React Native 0.63.2 it was fine on React Native 0.62

co2nut commented 3 years ago

same thing happened to me for React Native 0.63.2 it was fine on React Native 0.62

I missed the installation step whereby I need to update MainAcivity.java.

It works ok now.

mrousavy commented 3 years ago

I didn't miss the installation step in MainActivity.java, but it still doesn't work. It sometimes works on my HomeScreen, and it never works in my Modal.

jakub-gonet commented 3 years ago

Because RNGH doesn't work in modals at the moment.

evanc commented 3 years ago

If you do an Animated.duration(myValue, {toValue: 0, duration: 0}) as soon as you mount the component does it start working?

mrousavy commented 3 years ago

@evanc I'm sorry, what?

Property 'duration' does not exist on type 'typeof Animated'

evanc commented 3 years ago

oops, sorry, I meant Animated.timing. You'll also need to set the property useNativeDriver or it will warn.

basically just do a throwaway animation on the value. I have seen a similar issue where the value wouldn't work until it had been Animated at least once.

mrousavy commented 3 years ago

@evanc Oh I see.

I've added:

    useEffect(() => {
        Reanimated.timing(thumbX, { toValue: 0, duration: 0, easing: Easing.linear });
    }, [thumbX]);

And it still didn't work.

EDIT: What do you mean with useNativeDriver? Do you mean react-native's Animated library instead of Reanimated?

christophemenager commented 3 years ago

I have a similar issue, can't make a TouchableOpacity work if wrapped in a PanGestureHandler. No issue on iOS. Does someone have a clue on this ?

MrGVSV commented 3 years ago

Same issue here even after following the installation instructions. PanGestureHandler works fine on iOS but doesn't even fire any events on Android.

jakub-gonet commented 3 years ago

@MrGVSV, please provide some repro showing this problem.

elliscwc commented 3 years ago

this works for me on Android, it's an age slider

const { width } = Dimensions.get('window');

export default function AgeEditPage(props) {
  const [rangeMeter, setRangeMeter] = useState(100);
  const [maxAge, setMaxAge] = useState(18);

  useEffect(() => {
    updateRange(100);
  }, []);

  const updateRange = (meterRange) => {
    const meterPercentage = (meterRange - 90) / (width - 150);
    const meterAge = Math.floor(18 + meterPercentage * 62);
    setMaxAge(meterAge);
    setRangeMeter(meterRange);
  };

  const updateDelta = (event) => {
    const { absoluteX } = event.nativeEvent;
    const meterRange = clamp(absoluteX, 90, width - 60);
    updateRange(meterRange);
  };

  return (
    <ScrollView style={styles.container} contentContainerStyle={styles.content}>
      <View style={styles.info}>
        <Text style={styles.title}>I'm {maxAge} years old</Text>
        <PanGestureHandler onGestureEvent={updateDelta} activeOffsetX={[-10, 10]}>
          <View style={[styles.range, { width: rangeMeter }]}>
            <Text style={styles.rangetext}>Age</Text>
          </View>
        </PanGestureHandler>
      </View>
    </ScrollView>
  );
}
jakub-gonet commented 3 years ago

@mrousavy, is this issue still relevant?

(I mean if it isn't connected to the modal issue I linked. We're transferring issues to discussions and I want to close anything that may be outdated by now)

mrousavy commented 3 years ago

@jakub-gonet not for me, since I'm not using <Modal> components anymore. In a react-native-navigation Modal screen everything works as expected. And we can definitely close this one since it's a duplicate of #139 (my bad) 👍

jakub-gonet commented 3 years ago

Thanks for the fast follow-up!

raugustinas commented 3 years ago

Adding activeOffsetX={[0, 0]} to PanGestureHandler fixed it for me. :)

shoki61 commented 2 years ago
<Modal transparent> 
   <GestureHandlerRootView style={{flex:1}}> 
      <PanGestureHandler>
         <Animated.View>
            {/* Your components */}
         </Animated.View>
      </PanGestureHandler>
   </GestureHandlerRootView>
</Modal>

it worked for me when i did it like this

arbnorhaxhiu94 commented 2 years ago

I had the problem of moving the Animated.View on a Modal on Android (on iOS, no problem), after I tried @shoki61's answer, it worked, so just wrap the component with

rikkeidangvh commented 1 year ago

@shoki61 work for me, thanks for your contribution!

itrcz commented 1 year ago
<Modal transparent> 
   <GestureHandlerRootView style={{flex:1}}> 
      <PanGestureHandler>
         <Animated.View>
            {/* Your components */}
         </Animated.View>
      </PanGestureHandler>
   </GestureHandlerRootView>
</Modal>

it worked for me when i did it like this

Timesaver! GestureHandlerRootView helps

leonardorib commented 1 year ago
<Modal transparent> 
   <GestureHandlerRootView style={{flex:1}}> 
      <PanGestureHandler>
         <Animated.View>
            {/* Your components */}
         </Animated.View>
      </PanGestureHandler>
   </GestureHandlerRootView>
</Modal>

it worked for me when i did it like this

Just to complement @shoki61 solution if anyone needs more clarification, this is mentioned on documentation: https://docs.swmansion.com/react-native-gesture-handler/docs/installation#usage-with-modals-on-android

They recommend using gestureHandlerRootHOC, which is the equivalent of wrapping with <GestureHandlerRootView style={{ flex: 1 }}>. You can check here: https://github.com/software-mansion/react-native-gesture-handler/blob/main/src/gestureHandlerRootHOC.tsx