react-native-gesture-handler icon indicating copy to clipboard operation
react-native-gesture-handler copied to clipboard

Android passes clicks through after transform

Open rogerkerse opened this issue 4 years ago • 6 comments

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 avatar Mar 09 '20 16:03 rogerkerse

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

rogerkerse avatar Mar 17 '20 10:03 rogerkerse

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!

tattivitorino avatar Mar 30 '20 14:03 tattivitorino

Is someone actively developing this library?

rogerkerse avatar Jun 10 '20 10:06 rogerkerse

Any update?

dhl1402 avatar Oct 02 '21 16:10 dhl1402

Bump

jkcailteux avatar Feb 22 '23 22:02 jkcailteux

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.

vshkl avatar May 03 '23 12:05 vshkl