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.04k stars 971 forks source link

Android passes clicks through after transform #1000

Open rogerkerse opened 4 years ago

rogerkerse commented 4 years ago

This is expected behaviour (video how this library works on iOS):

ezgif-2-509119990420

On Android after player is dragged down, it doesn't accept any presses. Every press on player gets passed to list underneath instead. This is clearly wrong behaviour.

Code is following: package.json

"dependencies": {
    "react": "16.9.0",
    "react-native": "0.61.5",
    "react-native-gesture-handler": "^1.6.0"
  },

index.js/App.js

import React from 'react';
import {
    SafeAreaView,
    StyleSheet,
    View,
    Text,
    StatusBar,
    FlatList,
    TouchableOpacity,
    Alert,
} from 'react-native';

import PipLayout from './PipLayout'

const App = () => {
    const fakeData = Array(50).fill(0).map((value, index) => value + index)

    const onItemPress = (item) => {
        Alert.alert('Item pressed', `Item #${item}`)
    }

    const onPlayPress = () => {
        Alert.alert('Play press')
    }

    const renderItem = ({ item }) => {
        return (
            <TouchableOpacity onPress={() => onItemPress(item)}>
                <Text style={styles.row}>{`Item #${item}`}</Text>
            </TouchableOpacity>
        )
    }

    const renderPlayButton = () => {
        return (
            <View style={styles.player}>
                <TouchableOpacity onPress={onPlayPress}>
                    <View style={styles.playButton} />
                </TouchableOpacity>
            </View>
        )
    }

    return (
        <>
            <StatusBar barStyle="dark-content" />
            <SafeAreaView>
                <FlatList
                    data={fakeData}
                    renderItem={renderItem}
                    ItemSeparatorComponent={() => <View style={styles.separator} />}
                    keyExtractor={item => item.toString()}
                />
            </SafeAreaView>
            <PipLayout player={renderPlayButton()} />
        </>
    );
};

const styles = StyleSheet.create({
    row: {
        padding: 20,
        backgroundColor: 'lightblue',
    },
    separator: {
        height: 1,
        color: 'white',
    },
    player: {
        width: '100%',
        aspectRatio: 16 / 9,
        backgroundColor: 'red',
        justifyContent: 'center',
        alignItems: 'center',
    },
    playButton: {
        width: 40,
        height: 40,
        backgroundColor: 'blue',
    },
});

export default App;

PipLayout.js

import React, { Component, ReactNode } from 'react';
import { Animated, Dimensions, LayoutChangeEvent, StyleSheet, TouchableWithoutFeedback, View, SafeAreaView } from 'react-native';
import { PanGestureHandler, PanGestureHandlerGestureEvent, State as PanState } from 'react-native-gesture-handler';

const AnimatedSafeAreView = Animated.createAnimatedComponent(SafeAreaView);

const PAN_RESPOND_THRESHOLD = 20;
const PICTURE_IN_PICTURE_PLAYER_HEIGHT_PERCENTAGE = 0.12;
const PICTURE_IN_PICTURE_PLAYER_PADDING = 5;
const SAFE_AREA_OPACITY_DROP_OFF_PERCENTAGE = 0.2;

const ANIMATION_LENGTH = 250;
const PICTURE_IN_PICTURE_TRANSITION_THRESHOLD_PERCENTAGE = 0.2;
const SWIPE_AWAY_THRESHOLD_PERCENTAGE = 0.75;
const SWIPE_AWAY_OPACITY_DROP_OFF_MULTIPLIER = 2;
const SWIPE_AWAY_SPEED_MULTIPLIER = 2;

const VISIBLE = 1;
const INVISIBLE = 0;

const styles = StyleSheet.create({
    bodyContainer: {
        backgroundColor: 'gray',
        flex: 1,
    },
    container: {
        ...StyleSheet.absoluteFillObject,
    },
    movingContent: {
        flex: 1,
    },
    safeAreaContainer: {
        flex: 1,
    },
    topSafeArea: {
        backgroundColor: 'black',
    },
});

type Props = {
    player: ReactNode,
}
type State = {
    isDraggingEnabled: boolean,
    isFullDetails: boolean,
    playerSize: {
        width: number,
        height: number,
    },
}

export default class PipLayout extends Component<Props, State> {
    touchOnPlayerX = new Animated.Value(0);
    touchOnPlayerY = new Animated.Value(0);

    onPlayerVerticalDrag = Animated.event(
        [{
            nativeEvent: { translationY: this.touchOnPlayerY },
        }],
        {
            useNativeDriver: true,
        },
    );

    onPlayerSwipeAway = Animated.event(
        [{
            nativeEvent: { translationX: this.touchOnPlayerX },
        }],
        {
            useNativeDriver: true,
        },
    );

    constructor(props: Props) {
        super(props);

        this.state = {
            isDraggingEnabled: true,
            isFullDetails: true,
            playerSize: {
                height: 0,
                width: 0,
            },
        };

        this.onPlayerVerticalDragStateChange = this.onPlayerVerticalDragStateChange.bind(this);
        this.onPlayerSwipeAwayStateChange = this.onPlayerSwipeAwayStateChange.bind(this);
        this.setShowFullDetails = this.setShowFullDetails.bind(this);
        this.showFullDetails = this.showFullDetails.bind(this);
        this.showPictureInPicture = this.showPictureInPicture.bind(this);
        this.onPlayerLayout = this.onPlayerLayout.bind(this);
    }

    showFullDetails() {
        this.setShowFullDetails(true);
    }

    showPictureInPicture() {
        this.setShowFullDetails(false);
    }

    render() {
        const { player } = this.props;
        const { isFullDetails, isDraggingEnabled } = this.state;

        const containerPointerEvents = isFullDetails ? 'auto' : 'box-none';

        return (
            <View style={styles.container} pointerEvents={containerPointerEvents}>
                <AnimatedSafeAreView style={this.topSafeAreaStyle} pointerEvents={containerPointerEvents} />
                <Animated.View style={styles.movingContent} pointerEvents={containerPointerEvents}>
                    <PanGestureHandler
                        onGestureEvent={this.onPlayerVerticalDrag}
                        onHandlerStateChange={this.onPlayerVerticalDragStateChange}
                        enabled={this.state.isDraggingEnabled}
                        activeOffsetY={[-PAN_RESPOND_THRESHOLD, PAN_RESPOND_THRESHOLD]}
                    >
                        <Animated.View
                            style={this.playerSwipeAwayStyle}
                            pointerEvents={containerPointerEvents}
                        >
                            <PanGestureHandler
                                onGestureEvent={this.onPlayerSwipeAway}
                                onHandlerStateChange={this.onPlayerSwipeAwayStateChange}
                                enabled={!isFullDetails && isDraggingEnabled}
                                activeOffsetX={[-PAN_RESPOND_THRESHOLD, PAN_RESPOND_THRESHOLD]}
                            >
                                <Animated.View
                                    style={this.playerAnimatedStyle}
                                    pointerEvents={containerPointerEvents}
                                    onLayout={this.onPlayerLayout}
                                >
                                    <TouchableWithoutFeedback
                                        onPress={this.showFullDetails}
                                        disabled={isFullDetails || !isDraggingEnabled}
                                    >
                                        <View pointerEvents={isFullDetails ? 'auto' : 'box-only'}>
                                            {player}
                                        </View>
                                    </TouchableWithoutFeedback>
                                </Animated.View>
                            </PanGestureHandler>
                        </Animated.View>
                    </PanGestureHandler>
                    <Animated.View
                        style={[styles.bodyContainer, this.bodyAnimatedStyle]}
                        pointerEvents={isFullDetails ? 'auto' : 'none'}
                    />
                </Animated.View>
            </View>
        );
    }

    get pictureInPicturePlayerSize() {
        const { playerSize } = this.state;
        const { height } = Dimensions.get('window');
        const minPlayerHeight = height * PICTURE_IN_PICTURE_PLAYER_HEIGHT_PERCENTAGE;
        // Initially there is no player height. That is why we have a fallback
        const aspectRatio = playerSize.height ? playerSize.width / playerSize.height : 0;
        return {
            height: minPlayerHeight,
            width: minPlayerHeight * aspectRatio,
        };
    }

    get playerMaximumTopOffset() {
        const { height } = Dimensions.get('window');
        const bottomPlayerPadding = PICTURE_IN_PICTURE_PLAYER_PADDING + 100;

        return height - this.pictureInPicturePlayerSize.height - bottomPlayerPadding;
    }

    get playerMaximumLeftOffset() {
        const { width } = Dimensions.get('window');
        return width - this.pictureInPicturePlayerSize.width - PICTURE_IN_PICTURE_PLAYER_PADDING;
    }

    get playerPictureInPictureScale() {
        const { width } = Dimensions.get('window');
        return this.pictureInPicturePlayerSize.width / width;
    }

    get playerDragYPosition() {
        const { isFullDetails } = this.state;
        return Animated.add(
            this.touchOnPlayerY,
            new Animated.Value(isFullDetails ? 0 : this.playerMaximumTopOffset),
        );
    }

    get playerSwipeAwayStyle() {
        const smallPlayerWidth = this.pictureInPicturePlayerSize.width;

        return {
            opacity: this.touchOnPlayerX.interpolate({
                extrapolate: 'clamp',
                inputRange: [
                    -smallPlayerWidth * SWIPE_AWAY_OPACITY_DROP_OFF_MULTIPLIER,
                    0,
                    smallPlayerWidth * SWIPE_AWAY_OPACITY_DROP_OFF_MULTIPLIER,
                ],
                outputRange: [INVISIBLE, VISIBLE, INVISIBLE],
            }),
            transform: [
                {
                    translateX: this.touchOnPlayerX.interpolate({
                        inputRange: [-smallPlayerWidth, 0, smallPlayerWidth],
                        outputRange: [
                            -(smallPlayerWidth * this.playerPictureInPictureScale),
                            0,
                            smallPlayerWidth * this.playerPictureInPictureScale,
                        ],
                    }),
                },
            ],
        };
    }

    get playerPositionOffsetBecauseOfScale() {
        const { playerSize } = this.state;
        return {
            x: (playerSize.width * this.playerPictureInPictureScale - playerSize.width) / 2,
            y: (playerSize.height * this.playerPictureInPictureScale - playerSize.height) / 2,
        };
    }

    get playerAnimatedStyle() {
        return {
            transform: [
                {
                    translateX: this.playerDragYPosition.interpolate({
                        extrapolate: 'clamp',
                        inputRange: [0, this.playerMaximumTopOffset],
                        outputRange: [0, this.playerMaximumLeftOffset + this.playerPositionOffsetBecauseOfScale.x],
                    }),
                },
                {
                    translateY: this.playerDragYPosition.interpolate({
                        extrapolate: 'clamp',
                        inputRange: [0, this.playerMaximumTopOffset],
                        outputRange: [0, this.playerMaximumTopOffset + this.playerPositionOffsetBecauseOfScale.y],
                    }),
                },
                {
                    scale: this.playerDragYPosition.interpolate({
                        extrapolate: 'clamp',
                        inputRange: [0, this.playerMaximumTopOffset],
                        outputRange: [1, this.playerPictureInPictureScale],
                    }),
                },
            ],
        };
    }

    get bodyAnimatedStyle() {
        const { playerSize } = this.state;
        const playerSizeDifferenceAfterScale = playerSize.height - this.pictureInPicturePlayerSize.height;

        return {
            opacity: this.playerDragYPosition.interpolate({
                extrapolate: 'clamp',
                inputRange: [0, this.playerMaximumTopOffset],
                outputRange: [VISIBLE, INVISIBLE],
            }),
            transform: [
                {
                    translateY: this.playerDragYPosition.interpolate({
                        extrapolate: 'clamp',
                        inputRange: [0, this.playerMaximumTopOffset],
                        outputRange: [0, this.playerMaximumTopOffset - playerSizeDifferenceAfterScale],
                    }),
                },
                {
                    translateX: this.playerDragYPosition.interpolate({
                        extrapolate: 'clamp',
                        inputRange: [0, this.playerMaximumTopOffset],
                        outputRange: [0, this.playerMaximumLeftOffset],
                    }),
                },
            ],
        };
    }

    get topSafeAreaStyle() {
        return [
            styles.topSafeArea,
            {
                opacity: this.playerDragYPosition.interpolate({
                    extrapolate: 'clamp',
                    inputRange: [0, this.playerMaximumTopOffset * SAFE_AREA_OPACITY_DROP_OFF_PERCENTAGE],
                    outputRange: [VISIBLE, INVISIBLE],
                }),
            },
        ];
    }

    onPlayerVerticalDragStateChange({ nativeEvent }: PanGestureHandlerGestureEvent) {
        const { isFullDetails } = this.state;

        if (nativeEvent.state === PanState.END) {
            const transitionThreshold =
                this.playerMaximumTopOffset * PICTURE_IN_PICTURE_TRANSITION_THRESHOLD_PERCENTAGE;
            const activateFullDetails = isFullDetails && nativeEvent.translationY < transitionThreshold
                || !isFullDetails && Math.abs(nativeEvent.translationY) > transitionThreshold;
            this.setShowFullDetails(activateFullDetails);
        }
    }

    onPlayerSwipeAwayStateChange({ nativeEvent }: PanGestureHandlerGestureEvent) {
        if (nativeEvent.state === PanState.END) {
            this.setState({ isDraggingEnabled: false }, () => {
                const { width } = Dimensions.get('window');
                const swipeAwayDistance = this.pictureInPicturePlayerSize.width * SWIPE_AWAY_THRESHOLD_PERCENTAGE;
                const isSwipeAwaySuccesful = Math.abs(nativeEvent.translationX) > swipeAwayDistance;
                if (isSwipeAwaySuccesful) {
                    Animated.timing(this.touchOnPlayerX, {
                        duration: ANIMATION_LENGTH,
                        toValue: (nativeEvent.translationX > 0 ? 1 : -1) * width * SWIPE_AWAY_SPEED_MULTIPLIER,
                        useNativeDriver: true,
                    }).start();
                } else {
                    Animated.timing(this.touchOnPlayerX, {
                        duration: ANIMATION_LENGTH,
                        toValue: 0,
                        useNativeDriver: true,
                    }).start(() => {
                        this.setState({ isDraggingEnabled: true });
                    });
                }
            });
        }
    }

    setShowFullDetails(activateFullDetails: boolean) {
        this.setState({ isDraggingEnabled: false }, () => {
            const { isFullDetails } = this.state;
            const isFullDetailsYOffset = isFullDetails ? 0 : this.playerMaximumTopOffset;
            Animated.timing(this.touchOnPlayerY, {
                duration: ANIMATION_LENGTH,
                toValue: (activateFullDetails ? 0 : this.playerMaximumTopOffset) - isFullDetailsYOffset,
                useNativeDriver: true,
            }).start(() => {
                this.setState({
                    isDraggingEnabled: true,
                    isFullDetails: activateFullDetails,
                });
            });
        });
    }

    onPlayerLayout({ nativeEvent: { layout } }: LayoutChangeEvent) {
        this.setState({
            playerSize: {
                height: layout.height,
                width: layout.width,
            },
        });
    }
}
rogerkerse commented 4 years ago

Exactly the same code on Android and if the component is transformed, no handlers work, every event is passed through element to underlying list

ezgif-2-57be4b767cd4

tattivitorino commented 4 years ago

Similar issue here.. I have a map underneath my draggable view and after the first drag the view does not respond to the drag anymore and the event is passed to the map! this happens only on Android, IOS works ok!

rogerkerse commented 4 years ago

Is someone actively developing this library?

dhl1402 commented 2 years ago

Any update?

jkcailteux commented 1 year ago

Bump

vshkl commented 1 year ago

Well, I have a similar problem. Maybe, it is not 100% of the same nature, but I ended up in this issue while searching for a solution, so, hopefully it will help someone else, too.

Basically, I have a drop-down menu that slides down from under the navigation bar at the top of the screen. The menu, obviously, has position: "absolute", and involves transform with translateY to show/hide itself. And when expanded, any press event falls through the menu down to the component under it. Note, however, that in my case touch even registers both in menu, and in the component under it.

My "fix" is placing another view that "swallows" any touch even right under the problematic component (doc):

<Animated.View ...>
  <View ...>
    <Menu ... />
  </View>
  <View pointerEvents="none" ... /> // <-- Here
</Animated.View>

I'm using

"react-native-gesture-handler": "^2.9.0",

As in regard to the original issue – probably, a similar approach can help there as well: conditionally adding a similar view under the moving one to confine touch events to it and prevent them from passing through.