Skip to content

Android passes clicks through after transform #1000

@rogerkerse

Description

@rogerkerse

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,
            },
        });
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions