react-native-gesture-handler
react-native-gesture-handler copied to clipboard
Android passes clicks through after transform
This is expected behaviour (video how this library works on iOS):
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,
},
});
}
}
Exactly the same code on Android and if the component is transformed, no handlers work, every event is passed through element to underlying list
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!
Is someone actively developing this library?
Any update?
Bump
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.