chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
@@ -0,0 +1,788 @@
// This component is based on RN's DrawerLayoutAndroid API
//
// It perhaps deserves to be put in a separate repo, but since it relies on
// react-native-gesture-handler library which isn't very popular at the moment I
// decided to keep it here for the time being. It will allow us to move faster
// and fix issues that may arise in gesture handler library that could be found
// when using the drawer component
import * as React from 'react';
import { Component } from 'react';
import invariant from 'invariant';
import {
Animated,
StyleSheet,
View,
Keyboard,
StatusBar,
I18nManager,
StatusBarAnimation,
StyleProp,
ViewStyle,
LayoutChangeEvent,
NativeSyntheticEvent,
} from 'react-native';
import {
GestureEvent,
HandlerStateChangeEvent,
UserSelect,
ActiveCursor,
MouseButton,
} from '../handlers/gestureHandlerCommon';
import { PanGestureHandler } from '../handlers/PanGestureHandler';
import type {
PanGestureHandlerEventPayload,
TapGestureHandlerEventPayload,
} from '../handlers/GestureHandlerEventPayload';
import { TapGestureHandler } from '../handlers/TapGestureHandler';
import { State } from '../State';
const DRAG_TOSS = 0.05;
const IDLE: DrawerState = 'Idle';
const DRAGGING: DrawerState = 'Dragging';
const SETTLING: DrawerState = 'Settling';
/**
* @deprecated DrawerLayout is deprecated. Use Reanimated version of DrawerLayout instead.
*/
export type DrawerPosition = 'left' | 'right';
/**
* @deprecated DrawerLayout is deprecated. Use Reanimated version of DrawerLayout instead.
*/
export type DrawerState = 'Idle' | 'Dragging' | 'Settling';
/**
* @deprecated DrawerLayout is deprecated. Use Reanimated version of DrawerLayout instead.
*/
export type DrawerType = 'front' | 'back' | 'slide';
/**
* @deprecated DrawerLayout is deprecated. Use Reanimated version of DrawerLayout instead.
*/
export type DrawerLockMode = 'unlocked' | 'locked-closed' | 'locked-open';
/**
* @deprecated DrawerLayout is deprecated. Use Reanimated version of DrawerLayout instead.
*/
export type DrawerKeyboardDismissMode = 'none' | 'on-drag';
// Animated.AnimatedInterpolation has been converted to a generic type
// in @types/react-native 0.70. This way we can maintain compatibility
// with all versions of @types/react-native`
type AnimatedInterpolation = ReturnType<Animated.Value['interpolate']>;
/**
* @deprecated DrawerLayout is deprecated. Use Reanimated version of DrawerLayout instead.
*/
export interface DrawerLayoutProps {
/**
* This attribute is present in the standard implementation already and is one
* of the required params. Gesture handler version of DrawerLayout make it
* possible for the function passed as `renderNavigationView` to take an
* Animated value as a parameter that indicates the progress of drawer
* opening/closing animation (progress value is 0 when closed and 1 when
* opened). This can be used by the drawer component to animated its children
* while the drawer is opening or closing.
*/
renderNavigationView: (
progressAnimatedValue: Animated.Value
) => React.ReactNode;
drawerPosition?: DrawerPosition;
drawerWidth?: number;
drawerBackgroundColor?: string;
drawerLockMode?: DrawerLockMode;
keyboardDismissMode?: DrawerKeyboardDismissMode;
/**
* Called when the drawer is closed.
*/
onDrawerClose?: () => void;
/**
* Called when the drawer is opened.
*/
onDrawerOpen?: () => void;
/**
* Called when the status of the drawer changes.
*/
onDrawerStateChanged?: (
newState: DrawerState,
drawerWillShow: boolean
) => void;
useNativeAnimations?: boolean;
drawerType?: DrawerType;
/**
* Defines how far from the edge of the content view the gesture should
* activate.
*/
edgeWidth?: number;
minSwipeDistance?: number;
/**
* When set to true Drawer component will use
* {@link https://reactnative.dev/docs/statusbar StatusBar} API to hide the OS
* status bar whenever the drawer is pulled or when its in an "open" state.
*/
hideStatusBar?: boolean;
/**
* @default 'slide'
*
* Can be used when hideStatusBar is set to true and will select the animation
* used for hiding/showing the status bar. See
* {@link https://reactnative.dev/docs/statusbar StatusBar} documentation for
* more details
*/
statusBarAnimation?: StatusBarAnimation;
/**
* @default black
*
* Color of a semi-transparent overlay to be displayed on top of the content
* view when drawer gets open. A solid color should be used as the opacity is
* added by the Drawer itself and the opacity of the overlay is animated (from
* 0% to 70%).
*/
overlayColor?: string;
contentContainerStyle?: StyleProp<ViewStyle>;
drawerContainerStyle?: StyleProp<ViewStyle>;
/**
* Enables two-finger gestures on supported devices, for example iPads with
* trackpads. If not enabled the gesture will require click + drag, with
* `enableTrackpadTwoFingerGesture` swiping with two fingers will also trigger
* the gesture.
*/
enableTrackpadTwoFingerGesture?: boolean;
onDrawerSlide?: (position: number) => void;
onGestureRef?: (ref: PanGestureHandler) => void;
// Implicit `children` prop has been removed in @types/react^18.0.0
children?:
| React.ReactNode
| ((openValue?: AnimatedInterpolation) => React.ReactNode);
/**
* @default 'none'
* Defines which userSelect property should be used.
* Values: 'none'|'text'|'auto'
*/
userSelect?: UserSelect;
/**
* @default 'auto'
* Defines which cursor property should be used when gesture activates.
* Values: see CSS cursor values
*/
activeCursor?: ActiveCursor;
/**
* @default 'MouseButton.LEFT'
* Allows to choose which mouse button should underlying pan handler react to.
*/
mouseButton?: MouseButton;
/**
* @default 'false if MouseButton.RIGHT is specified'
* Allows to enable/disable context menu.
*/
enableContextMenu?: boolean;
}
/**
* @deprecated DrawerLayout is deprecated. Use Reanimated version of DrawerLayout instead.
*/
export type DrawerLayoutState = {
dragX: Animated.Value;
touchX: Animated.Value;
drawerTranslation: Animated.Value;
containerWidth: number;
drawerState: DrawerState;
drawerOpened: boolean;
};
/**
* @deprecated DrawerLayout is deprecated. Use Reanimated version of DrawerLayout instead.
*/
export type DrawerMovementOption = {
velocity?: number;
speed?: number;
};
/**
* @deprecated use Reanimated version of DrawerLayout instead
*/
export default class DrawerLayout extends Component<
DrawerLayoutProps,
DrawerLayoutState
> {
static defaultProps = {
drawerWidth: 200,
drawerPosition: 'left',
useNativeAnimations: true,
drawerType: 'front',
edgeWidth: 20,
minSwipeDistance: 3,
overlayColor: 'rgba(0, 0, 0, 0.7)',
drawerLockMode: 'unlocked',
enableTrackpadTwoFingerGesture: false,
};
constructor(props: DrawerLayoutProps) {
super(props);
const dragX = new Animated.Value(0);
const touchX = new Animated.Value(0);
const drawerTranslation = new Animated.Value(0);
this.state = {
dragX,
touchX,
drawerTranslation,
containerWidth: 0,
drawerState: IDLE,
drawerOpened: false,
};
this.updateAnimatedEvent(props, this.state);
}
shouldComponentUpdate(props: DrawerLayoutProps, state: DrawerLayoutState) {
if (
this.props.drawerPosition !== props.drawerPosition ||
this.props.drawerWidth !== props.drawerWidth ||
this.props.drawerType !== props.drawerType ||
this.state.containerWidth !== state.containerWidth
) {
this.updateAnimatedEvent(props, state);
}
return true;
}
private openValue?: AnimatedInterpolation;
private onGestureEvent?: (
event: GestureEvent<PanGestureHandlerEventPayload>
) => void;
private accessibilityIsModalView =
React.createRef<React.ComponentRef<typeof View>>();
private pointerEventsView =
React.createRef<React.ComponentRef<typeof View>>();
private panGestureHandler = React.createRef<PanGestureHandler | null>();
private drawerShown = false;
static positions = {
Left: 'left',
Right: 'right',
};
private updateAnimatedEvent = (
props: DrawerLayoutProps,
state: DrawerLayoutState
) => {
// Event definition is based on
const { drawerPosition, drawerWidth, drawerType } = props;
const {
dragX: dragXValue,
touchX: touchXValue,
drawerTranslation,
containerWidth,
} = state;
let dragX = dragXValue;
let touchX = touchXValue;
if (drawerPosition !== 'left') {
// Most of the code is written in a way to handle left-side drawer. In
// order to handle right-side drawer the only thing we need to do is to
// reverse events coming from gesture handler in a way they emulate
// left-side drawer gestures. E.g. dragX is simply -dragX, and touchX is
// calulcated by subtracing real touchX from the width of the container
// (such that when touch happens at the right edge the value is simply 0)
dragX = Animated.multiply(
new Animated.Value(-1),
dragXValue
) as Animated.Value; // TODO(TS): (for all "as" in this file) make sure we can map this
touchX = Animated.add(
new Animated.Value(containerWidth),
Animated.multiply(new Animated.Value(-1), touchXValue)
) as Animated.Value; // TODO(TS): make sure we can map this;
touchXValue.setValue(containerWidth);
} else {
touchXValue.setValue(0);
}
// While closing the drawer when user starts gesture outside of its area (in greyed
// out part of the window), we want the drawer to follow only once finger reaches the
// edge of the drawer.
// E.g. on the diagram below drawer is illustrate by X signs and the greyed out area by
// dots. The touch gesture starts at '*' and moves left, touch path is indicated by
// an arrow pointing left
// 1) +---------------+ 2) +---------------+ 3) +---------------+ 4) +---------------+
// |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
// |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
// |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
// |XXXXXXXX|......| |XXXXXXXX|.<-*..| |XXXXXXXX|<--*..| |XXXXX|<-----*..|
// |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
// |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
// |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
// +---------------+ +---------------+ +---------------+ +---------------+
//
// For the above to work properly we define animated value that will keep
// start position of the gesture. Then we use that value to calculate how
// much we need to subtract from the dragX. If the gesture started on the
// greyed out area we take the distance from the edge of the drawer to the
// start position. Otherwise we don't subtract at all and the drawer be
// pulled back as soon as you start the pan.
//
// This is used only when drawerType is "front"
//
let translationX = dragX;
if (drawerType === 'front') {
const startPositionX = Animated.add(
touchX,
Animated.multiply(new Animated.Value(-1), dragX)
);
const dragOffsetFromOnStartPosition = startPositionX.interpolate({
inputRange: [drawerWidth! - 1, drawerWidth!, drawerWidth! + 1],
outputRange: [0, 0, 1],
});
translationX = Animated.add(
dragX,
dragOffsetFromOnStartPosition
) as Animated.Value; // TODO: as above
}
this.openValue = Animated.add(translationX, drawerTranslation).interpolate({
inputRange: [0, drawerWidth!],
outputRange: [0, 1],
extrapolate: 'clamp',
});
const gestureOptions: {
useNativeDriver: boolean;
// TODO: make sure it is correct
listener?: (
ev: NativeSyntheticEvent<PanGestureHandlerEventPayload>
) => void;
} = {
useNativeDriver: props.useNativeAnimations!,
};
if (this.props.onDrawerSlide) {
gestureOptions.listener = (ev) => {
const translationX = Math.floor(Math.abs(ev.nativeEvent.translationX));
const position = translationX / this.state.containerWidth;
this.props.onDrawerSlide?.(position);
};
}
this.onGestureEvent = Animated.event(
[{ nativeEvent: { translationX: dragXValue, x: touchXValue } }],
gestureOptions
);
};
private handleContainerLayout = ({ nativeEvent }: LayoutChangeEvent) => {
this.setState({ containerWidth: nativeEvent.layout.width });
};
private emitStateChanged = (
newState: DrawerState,
drawerWillShow: boolean
) => {
this.props.onDrawerStateChanged?.(newState, drawerWillShow);
};
private openingHandlerStateChange = ({
nativeEvent,
}: HandlerStateChangeEvent<PanGestureHandlerEventPayload>) => {
if (nativeEvent.oldState === State.ACTIVE) {
this.handleRelease({ nativeEvent });
} else if (nativeEvent.state === State.ACTIVE) {
this.emitStateChanged(DRAGGING, false);
this.setState({ drawerState: DRAGGING });
if (this.props.keyboardDismissMode === 'on-drag') {
Keyboard.dismiss();
}
if (this.props.hideStatusBar) {
StatusBar.setHidden(true, this.props.statusBarAnimation || 'slide');
}
}
};
private onTapHandlerStateChange = ({
nativeEvent,
}: HandlerStateChangeEvent<TapGestureHandlerEventPayload>) => {
if (
this.drawerShown &&
nativeEvent.oldState === State.ACTIVE &&
this.props.drawerLockMode !== 'locked-open'
) {
this.closeDrawer();
}
};
private handleRelease = ({
nativeEvent,
}: HandlerStateChangeEvent<PanGestureHandlerEventPayload>) => {
const { drawerWidth, drawerPosition, drawerType } = this.props;
const { containerWidth } = this.state;
let { translationX: dragX, velocityX, x: touchX } = nativeEvent;
if (drawerPosition !== 'left') {
// See description in _updateAnimatedEvent about why events are flipped
// for right-side drawer
dragX = -dragX;
touchX = containerWidth - touchX;
velocityX = -velocityX;
}
const gestureStartX = touchX - dragX;
let dragOffsetBasedOnStart = 0;
if (drawerType === 'front') {
dragOffsetBasedOnStart =
gestureStartX > drawerWidth! ? gestureStartX - drawerWidth! : 0;
}
const startOffsetX =
dragX + dragOffsetBasedOnStart + (this.drawerShown ? drawerWidth! : 0);
const projOffsetX = startOffsetX + DRAG_TOSS * velocityX;
const shouldOpen = projOffsetX > drawerWidth! / 2;
if (shouldOpen) {
this.animateDrawer(startOffsetX, drawerWidth!, velocityX);
} else {
this.animateDrawer(startOffsetX, 0, velocityX);
}
};
private updateShowing = (showing: boolean) => {
this.drawerShown = showing;
this.accessibilityIsModalView.current?.setNativeProps({
accessibilityViewIsModal: showing,
});
this.pointerEventsView.current?.setNativeProps({
pointerEvents: showing ? 'auto' : 'none',
});
const { drawerPosition, minSwipeDistance, edgeWidth } = this.props;
const fromLeft = drawerPosition === 'left';
// gestureOrientation is 1 if the expected gesture is from left to right and
// -1 otherwise e.g. when drawer is on the left and is closed we expect left
// to right gesture, thus orientation will be 1.
const gestureOrientation =
(fromLeft ? 1 : -1) * (this.drawerShown ? -1 : 1);
// When drawer is closed we want the hitSlop to be horizontally shorter than
// the container size by the value of SLOP. This will make it only activate
// when gesture happens not further than SLOP away from the edge
const hitSlop = fromLeft
? { left: 0, width: showing ? undefined : edgeWidth }
: { right: 0, width: showing ? undefined : edgeWidth };
// @ts-ignore internal API, maybe could be fixed in handler types
this.panGestureHandler.current?.setNativeProps({
hitSlop,
activeOffsetX: gestureOrientation * minSwipeDistance!,
});
};
private animateDrawer = (
fromValue: number | null | undefined,
toValue: number,
velocity: number,
speed?: number
) => {
this.state.dragX.setValue(0);
this.state.touchX.setValue(
this.props.drawerPosition === 'left' ? 0 : this.state.containerWidth
);
if (fromValue != null) {
let nextFramePosition = fromValue;
if (this.props.useNativeAnimations) {
// When using native driver, we predict the next position of the
// animation because it takes one frame of a roundtrip to pass RELEASE
// event from native driver to JS before we can start animating. Without
// it, it is more noticable that the frame is dropped.
if (fromValue < toValue && velocity > 0) {
nextFramePosition = Math.min(fromValue + velocity / 60.0, toValue);
} else if (fromValue > toValue && velocity < 0) {
nextFramePosition = Math.max(fromValue + velocity / 60.0, toValue);
}
}
this.state.drawerTranslation.setValue(nextFramePosition);
}
const willShow = toValue !== 0;
this.updateShowing(willShow);
this.emitStateChanged(SETTLING, willShow);
this.setState({ drawerState: SETTLING });
if (this.props.hideStatusBar) {
StatusBar.setHidden(willShow, this.props.statusBarAnimation || 'slide');
}
Animated.spring(this.state.drawerTranslation, {
velocity,
bounciness: 0,
toValue,
useNativeDriver: this.props.useNativeAnimations!,
speed: speed ?? undefined,
}).start(({ finished }) => {
if (finished) {
this.emitStateChanged(IDLE, willShow);
this.setState({ drawerOpened: willShow });
if (this.state.drawerState !== DRAGGING) {
// It's possilbe that user started drag while the drawer
// was settling, don't override state in this case
this.setState({ drawerState: IDLE });
}
if (willShow) {
this.props.onDrawerOpen?.();
} else {
this.props.onDrawerClose?.();
}
}
});
};
// eslint-disable-next-line @eslint-react/no-unused-class-component-members
openDrawer = (options: DrawerMovementOption = {}) => {
this.animateDrawer(
// TODO: decide if it should be null or undefined is the proper value
undefined,
this.props.drawerWidth!,
options.velocity ? options.velocity : 0,
options.speed
);
// We need to force the update, otherwise the overlay is not rerendered and
// it would not be clickable
this.forceUpdate();
};
closeDrawer = (options: DrawerMovementOption = {}) => {
// TODO: decide if it should be null or undefined is the proper value
this.animateDrawer(
undefined,
0,
options.velocity ? options.velocity : 0,
options.speed
);
// We need to force the update, otherwise the overlay is not rerendered and
// it would be still clickable
this.forceUpdate();
};
private renderOverlay = () => {
/* Overlay styles */
invariant(this.openValue, 'should be set');
let overlayOpacity;
if (this.state.drawerState !== IDLE) {
overlayOpacity = this.openValue;
} else {
overlayOpacity = this.state.drawerOpened ? 1 : 0;
}
const dynamicOverlayStyles = {
opacity: overlayOpacity,
backgroundColor: this.props.overlayColor,
};
return (
<TapGestureHandler onHandlerStateChange={this.onTapHandlerStateChange}>
<Animated.View
pointerEvents={this.drawerShown ? 'auto' : 'none'}
ref={this.pointerEventsView}
style={[styles.overlay, dynamicOverlayStyles]}
/>
</TapGestureHandler>
);
};
private renderDrawer = () => {
const {
drawerBackgroundColor,
drawerWidth,
drawerPosition,
drawerType,
drawerContainerStyle,
contentContainerStyle,
} = this.props;
const fromLeft = drawerPosition === 'left';
const drawerSlide = drawerType !== 'back';
const containerSlide = drawerType !== 'front';
// We rely on row and row-reverse flex directions to position the drawer
// properly. Apparently for RTL these are flipped which requires us to use
// the opposite setting for the drawer to appear from left or right
// according to the drawerPosition prop
const reverseContentDirection = I18nManager.isRTL ? fromLeft : !fromLeft;
const dynamicDrawerStyles = {
backgroundColor: drawerBackgroundColor,
width: drawerWidth,
};
const openValue = this.openValue;
invariant(openValue, 'should be set');
let containerStyles;
if (containerSlide) {
const containerTranslateX = openValue.interpolate({
inputRange: [0, 1],
outputRange: fromLeft ? [0, drawerWidth!] : [0, -drawerWidth!],
extrapolate: 'clamp',
});
containerStyles = {
transform: [{ translateX: containerTranslateX }],
};
}
let drawerTranslateX: number | AnimatedInterpolation = 0;
if (drawerSlide) {
const closedDrawerOffset = fromLeft ? -drawerWidth! : drawerWidth!;
if (this.state.drawerState !== IDLE) {
drawerTranslateX = openValue.interpolate({
inputRange: [0, 1],
outputRange: [closedDrawerOffset, 0],
extrapolate: 'clamp',
});
} else {
drawerTranslateX = this.state.drawerOpened ? 0 : closedDrawerOffset;
}
}
const drawerStyles: {
transform: { translateX: number | AnimatedInterpolation }[];
flexDirection: 'row-reverse' | 'row';
} = {
transform: [{ translateX: drawerTranslateX }],
flexDirection: reverseContentDirection ? 'row-reverse' : 'row',
};
return (
<Animated.View style={styles.main} onLayout={this.handleContainerLayout}>
<Animated.View
style={[
drawerType === 'front'
? styles.containerOnBack
: styles.containerInFront,
containerStyles,
contentContainerStyle,
]}
importantForAccessibility={
this.drawerShown ? 'no-hide-descendants' : 'yes'
}>
{typeof this.props.children === 'function'
? this.props.children(this.openValue)
: this.props.children}
{this.renderOverlay()}
</Animated.View>
<Animated.View
pointerEvents="box-none"
ref={this.accessibilityIsModalView}
accessibilityViewIsModal={this.drawerShown}
style={[styles.drawerContainer, drawerStyles, drawerContainerStyle]}>
<View style={dynamicDrawerStyles}>
{this.props.renderNavigationView(this.openValue as Animated.Value)}
</View>
</Animated.View>
</Animated.View>
);
};
private setPanGestureRef = (ref: PanGestureHandler) => {
// TODO(TS): make sure it is OK taken from
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31065#issuecomment-596081842
(
this.panGestureHandler as React.MutableRefObject<PanGestureHandler>
).current = ref;
this.props.onGestureRef?.(ref);
};
render() {
const { drawerPosition, drawerLockMode, edgeWidth, minSwipeDistance } =
this.props;
const fromLeft = drawerPosition === 'left';
// gestureOrientation is 1 if the expected gesture is from left to right and
// -1 otherwise e.g. when drawer is on the left and is closed we expect left
// to right gesture, thus orientation will be 1.
const gestureOrientation =
(fromLeft ? 1 : -1) * (this.drawerShown ? -1 : 1);
// When drawer is closed we want the hitSlop to be horizontally shorter than
// the container size by the value of SLOP. This will make it only activate
// when gesture happens not further than SLOP away from the edge
const hitSlop = fromLeft
? { left: 0, width: this.drawerShown ? undefined : edgeWidth }
: { right: 0, width: this.drawerShown ? undefined : edgeWidth };
return (
<PanGestureHandler
// @ts-ignore could be fixed in handler types
userSelect={this.props.userSelect}
activeCursor={this.props.activeCursor}
mouseButton={this.props.mouseButton}
enableContextMenu={this.props.enableContextMenu}
ref={this.setPanGestureRef}
hitSlop={hitSlop}
activeOffsetX={gestureOrientation * minSwipeDistance!}
failOffsetY={[-15, 15]}
onGestureEvent={this.onGestureEvent}
onHandlerStateChange={this.openingHandlerStateChange}
enableTrackpadTwoFingerGesture={
this.props.enableTrackpadTwoFingerGesture
}
enabled={
drawerLockMode !== 'locked-closed' && drawerLockMode !== 'locked-open'
}>
{this.renderDrawer()}
</PanGestureHandler>
);
}
}
const styles = StyleSheet.create({
drawerContainer: {
...StyleSheet.absoluteFillObject,
zIndex: 1001,
flexDirection: 'row',
},
containerInFront: {
...StyleSheet.absoluteFillObject,
zIndex: 1002,
},
containerOnBack: {
...StyleSheet.absoluteFillObject,
},
main: {
flex: 1,
zIndex: 0,
overflow: 'hidden',
},
overlay: {
...StyleSheet.absoluteFillObject,
zIndex: 1000,
},
});
@@ -0,0 +1,270 @@
import * as React from 'react';
import { Animated, Platform, processColor, StyleSheet } from 'react-native';
import createNativeWrapper from '../handlers/createNativeWrapper';
import GestureHandlerButton from './GestureHandlerButton';
import { State } from '../State';
import {
GestureEvent,
HandlerStateChangeEvent,
} from '../handlers/gestureHandlerCommon';
import type { NativeViewGestureHandlerPayload } from '../handlers/GestureHandlerEventPayload';
import type {
BaseButtonWithRefProps,
BaseButtonProps,
RectButtonWithRefProps,
RectButtonProps,
BorderlessButtonWithRefProps,
BorderlessButtonProps,
} from './GestureButtonsProps';
import { isFabric } from '../utils';
export const RawButton = createNativeWrapper(GestureHandlerButton, {
shouldCancelWhenOutside: false,
shouldActivateOnStart: false,
});
let IS_FABRIC: null | boolean = null;
class InnerBaseButton extends React.Component<BaseButtonWithRefProps> {
static defaultProps = {
delayLongPress: 600,
};
private lastActive: boolean;
private longPressTimeout: ReturnType<typeof setTimeout> | undefined;
private longPressDetected: boolean;
constructor(props: BaseButtonWithRefProps) {
super(props);
this.lastActive = false;
this.longPressDetected = false;
}
private handleEvent = ({
nativeEvent,
}: HandlerStateChangeEvent<NativeViewGestureHandlerPayload>) => {
const { state, oldState, pointerInside } = nativeEvent;
const active = pointerInside && state === State.ACTIVE;
if (active !== this.lastActive && this.props.onActiveStateChange) {
this.props.onActiveStateChange(active);
}
if (
!this.longPressDetected &&
oldState === State.ACTIVE &&
state !== State.CANCELLED &&
this.lastActive &&
this.props.onPress
) {
this.props.onPress(pointerInside);
}
if (
!this.lastActive &&
// NativeViewGestureHandler sends different events based on platform
state === (Platform.OS !== 'android' ? State.ACTIVE : State.BEGAN) &&
pointerInside
) {
this.longPressDetected = false;
if (this.props.onLongPress) {
this.longPressTimeout = setTimeout(
this.onLongPress,
this.props.delayLongPress
);
}
} else if (
// Cancel longpress timeout if it's set and the finger moved out of the view
state === State.ACTIVE &&
!pointerInside &&
this.longPressTimeout !== undefined
) {
clearTimeout(this.longPressTimeout);
this.longPressTimeout = undefined;
} else if (
// Cancel longpress timeout if it's set and the gesture has finished
this.longPressTimeout !== undefined &&
(state === State.END ||
state === State.CANCELLED ||
state === State.FAILED)
) {
clearTimeout(this.longPressTimeout);
this.longPressTimeout = undefined;
}
this.lastActive = active;
};
private onLongPress = () => {
this.longPressDetected = true;
this.props.onLongPress?.();
};
// Normally, the parent would execute it's handler first, then forward the
// event to listeners. However, here our handler is virtually only forwarding
// events to listeners, so we reverse the order to keep the proper order of
// the callbacks (from "raw" ones to "processed").
private onHandlerStateChange = (
e: HandlerStateChangeEvent<NativeViewGestureHandlerPayload>
) => {
this.props.onHandlerStateChange?.(e);
this.handleEvent(e);
};
private onGestureEvent = (
e: GestureEvent<NativeViewGestureHandlerPayload>
) => {
this.props.onGestureEvent?.(e);
this.handleEvent(
e as HandlerStateChangeEvent<NativeViewGestureHandlerPayload>
); // TODO: maybe it is not correct
};
render() {
const { rippleColor: unprocessedRippleColor, style, ...rest } = this.props;
if (IS_FABRIC === null) {
IS_FABRIC = isFabric();
}
const rippleColor = IS_FABRIC
? unprocessedRippleColor
: processColor(unprocessedRippleColor ?? undefined);
return (
<RawButton
ref={this.props.innerRef}
rippleColor={rippleColor}
style={[style, Platform.OS === 'ios' && { cursor: undefined }]}
{...rest}
onGestureEvent={this.onGestureEvent}
onHandlerStateChange={this.onHandlerStateChange}
/>
);
}
}
const AnimatedInnerBaseButton =
Animated.createAnimatedComponent<typeof InnerBaseButton>(InnerBaseButton);
export const BaseButton = React.forwardRef<
React.ComponentType,
Omit<BaseButtonProps, 'innerRef'>
>((props, ref) => <InnerBaseButton innerRef={ref} {...props} />);
const AnimatedBaseButton = React.forwardRef<
React.ComponentType,
Animated.AnimatedProps<BaseButtonWithRefProps>
>((props, ref) => <AnimatedInnerBaseButton innerRef={ref} {...props} />);
const btnStyles = StyleSheet.create({
underlay: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
top: 0,
},
});
class InnerRectButton extends React.Component<RectButtonWithRefProps> {
static defaultProps = {
activeOpacity: 0.105,
underlayColor: 'black',
};
private opacity: Animated.Value;
constructor(props: RectButtonWithRefProps) {
super(props);
this.opacity = new Animated.Value(0);
}
private onActiveStateChange = (active: boolean) => {
if (Platform.OS !== 'android') {
this.opacity.setValue(active ? this.props.activeOpacity! : 0);
}
this.props.onActiveStateChange?.(active);
};
render() {
const { children, style, ...rest } = this.props;
const resolvedStyle = StyleSheet.flatten(style) ?? {};
return (
<BaseButton
{...rest}
ref={this.props.innerRef}
style={resolvedStyle}
onActiveStateChange={this.onActiveStateChange}>
<Animated.View
style={[
btnStyles.underlay,
{
opacity: this.opacity,
backgroundColor: this.props.underlayColor,
borderRadius: resolvedStyle.borderRadius,
borderTopLeftRadius: resolvedStyle.borderTopLeftRadius,
borderTopRightRadius: resolvedStyle.borderTopRightRadius,
borderBottomLeftRadius: resolvedStyle.borderBottomLeftRadius,
borderBottomRightRadius: resolvedStyle.borderBottomRightRadius,
},
]}
/>
{children}
</BaseButton>
);
}
}
export const RectButton = React.forwardRef<
React.ComponentType,
Omit<RectButtonProps, 'innerRef'>
>((props, ref) => <InnerRectButton innerRef={ref} {...props} />);
class InnerBorderlessButton extends React.Component<BorderlessButtonWithRefProps> {
static defaultProps = {
activeOpacity: 0.3,
borderless: true,
};
private opacity: Animated.Value;
constructor(props: BorderlessButtonWithRefProps) {
super(props);
this.opacity = new Animated.Value(1);
}
private onActiveStateChange = (active: boolean) => {
if (Platform.OS !== 'android') {
this.opacity.setValue(active ? this.props.activeOpacity! : 1);
}
this.props.onActiveStateChange?.(active);
};
render() {
const { children, style, innerRef, ...rest } = this.props;
return (
<AnimatedBaseButton
{...rest}
innerRef={innerRef}
onActiveStateChange={this.onActiveStateChange}
style={[style, Platform.OS === 'ios' && { opacity: this.opacity }]}>
{children}
</AnimatedBaseButton>
);
}
}
export const BorderlessButton = React.forwardRef<
React.ComponentType,
Omit<BorderlessButtonProps, 'innerRef'>
>((props, ref) => <InnerBorderlessButton innerRef={ref} {...props} />);
export { default as PureNativeButton } from './GestureHandlerButton';
@@ -0,0 +1,156 @@
import * as React from 'react';
import {
AccessibilityProps,
ColorValue,
LayoutChangeEvent,
StyleProp,
ViewStyle,
} from 'react-native';
import type { NativeViewGestureHandlerProps } from '../handlers/NativeViewGestureHandler';
export interface RawButtonProps
extends NativeViewGestureHandlerProps,
AccessibilityProps {
/**
* Defines if more than one button could be pressed simultaneously. By default
* set true.
*/
exclusive?: boolean;
// TODO: we should transform props in `createNativeWrapper`
/**
* Android only.
*
* Defines color of native ripple animation used since API level 21.
*/
rippleColor?: number | ColorValue | null;
/**
* Android only.
*
* Defines radius of native ripple animation used since API level 21.
*/
rippleRadius?: number | null;
/**
* Android only.
*
* Set this to true if you want the ripple animation to render outside the view bounds.
*/
borderless?: boolean;
/**
* Android only.
*
* Defines whether the ripple animation should be drawn on the foreground of the view.
*/
foreground?: boolean;
/**
* Android only.
*
* Set this to true if you don't want the system to play sound when the button is pressed.
*/
touchSoundDisabled?: boolean;
/**
* Style object, use it to set additional styles.
*/
style?: StyleProp<ViewStyle>;
/**
* Invoked on mount and layout changes.
*/
onLayout?: (event: LayoutChangeEvent) => void;
/**
* Used for testing-library compatibility, not passed to the native component.
* @deprecated test-only props are deprecated and will be removed in the future.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
testOnly_onPress?: Function | null;
/**
* Used for testing-library compatibility, not passed to the native component.
* @deprecated test-only props are deprecated and will be removed in the future.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
testOnly_onPressIn?: Function | null;
/**
* Used for testing-library compatibility, not passed to the native component.
* @deprecated test-only props are deprecated and will be removed in the future.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
testOnly_onPressOut?: Function | null;
/**
* Used for testing-library compatibility, not passed to the native component.
* @deprecated test-only props are deprecated and will be removed in the future.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
testOnly_onLongPress?: Function | null;
}
interface ButtonWithRefProps {
innerRef?: React.ForwardedRef<React.ComponentType<any>>;
}
export interface BaseButtonProps extends RawButtonProps {
/**
* Called when the button gets pressed (analogous to `onPress` in
* `TouchableHighlight` from RN core).
*/
onPress?: (pointerInside: boolean) => void;
/**
* Called when the button gets pressed and is held for `delayLongPress`
* milliseconds.
*/
onLongPress?: () => void;
/**
* Called when button changes from inactive to active and vice versa. It
* passes active state as a boolean variable as a first parameter for that
* method.
*/
onActiveStateChange?: (active: boolean) => void;
style?: StyleProp<ViewStyle>;
testID?: string;
/**
* Delay, in milliseconds, after which the `onLongPress` callback gets called.
* Defaults to 600.
*/
delayLongPress?: number;
}
export interface BaseButtonWithRefProps
extends BaseButtonProps,
ButtonWithRefProps {}
export interface RectButtonProps extends BaseButtonProps {
/**
* Background color that will be dimmed when button is in active state.
*/
underlayColor?: string;
/**
* iOS only.
*
* Opacity applied to the underlay when button is in active state.
*/
activeOpacity?: number;
}
export interface RectButtonWithRefProps
extends RectButtonProps,
ButtonWithRefProps {}
export interface BorderlessButtonProps extends BaseButtonProps {
/**
* iOS only.
*
* Opacity applied to the button when it is in an active state.
*/
activeOpacity?: number;
}
export interface BorderlessButtonWithRefProps
extends BorderlessButtonProps,
ButtonWithRefProps {}
@@ -0,0 +1,148 @@
import * as React from 'react';
import {
PropsWithChildren,
ForwardedRef,
RefAttributes,
ReactElement,
} from 'react';
import {
ScrollView as RNScrollView,
ScrollViewProps as RNScrollViewProps,
Switch as RNSwitch,
SwitchProps as RNSwitchProps,
TextInput as RNTextInput,
TextInputProps as RNTextInputProps,
DrawerLayoutAndroid as RNDrawerLayoutAndroid,
DrawerLayoutAndroidProps as RNDrawerLayoutAndroidProps,
FlatList as RNFlatList,
FlatListProps as RNFlatListProps,
RefreshControl as RNRefreshControl,
} from 'react-native';
import createNativeWrapper from '../handlers/createNativeWrapper';
import {
NativeViewGestureHandlerProps,
nativeViewProps,
} from '../handlers/NativeViewGestureHandler';
import { toArray } from '../utils';
export const RefreshControl = createNativeWrapper(RNRefreshControl, {
disallowInterruption: true,
shouldCancelWhenOutside: false,
});
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type RefreshControl = typeof RefreshControl & RNRefreshControl;
const GHScrollView = createNativeWrapper<PropsWithChildren<RNScrollViewProps>>(
RNScrollView,
{
disallowInterruption: true,
shouldCancelWhenOutside: false,
}
);
export const ScrollView = React.forwardRef<
RNScrollView,
RNScrollViewProps & NativeViewGestureHandlerProps
>((props, ref) => {
const refreshControlGestureRef = React.useRef<RefreshControl>(null);
const { refreshControl, waitFor, ...rest } = props;
return (
<GHScrollView
{...rest}
// @ts-ignore `ref` exists on `GHScrollView`
ref={ref}
waitFor={[...toArray(waitFor ?? []), refreshControlGestureRef]}
// @ts-ignore we don't pass `refreshing` prop as we only want to override the ref
refreshControl={
refreshControl
? React.cloneElement(refreshControl, {
// @ts-ignore for reasons unknown to me, `ref` doesn't exist on the type inferred by TS
ref: refreshControlGestureRef,
})
: undefined
}
/>
);
});
// Backward type compatibility with https://github.com/software-mansion/react-native-gesture-handler/blob/db78d3ca7d48e8ba57482d3fe9b0a15aa79d9932/react-native-gesture-handler.d.ts#L440-L457
// include methods of wrapped components by creating an intersection type with the RN component instead of duplicating them.
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type ScrollView = typeof GHScrollView & RNScrollView;
export const Switch = createNativeWrapper<RNSwitchProps>(RNSwitch, {
shouldCancelWhenOutside: false,
shouldActivateOnStart: true,
disallowInterruption: true,
});
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type Switch = typeof Switch & RNSwitch;
export const TextInput = createNativeWrapper<RNTextInputProps>(RNTextInput);
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type TextInput = typeof TextInput & RNTextInput;
export const DrawerLayoutAndroid = createNativeWrapper<
PropsWithChildren<RNDrawerLayoutAndroidProps>
>(RNDrawerLayoutAndroid, { disallowInterruption: true });
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type DrawerLayoutAndroid = typeof DrawerLayoutAndroid &
RNDrawerLayoutAndroid;
export const FlatList = React.forwardRef((props, ref) => {
const refreshControlGestureRef = React.useRef<RefreshControl>(null);
const { waitFor, refreshControl, ...rest } = props;
const flatListProps = {};
const scrollViewProps = {};
for (const [propName, value] of Object.entries(rest)) {
// https://github.com/microsoft/TypeScript/issues/26255
if ((nativeViewProps as readonly string[]).includes(propName)) {
// @ts-ignore - this function cannot have generic type so we have to ignore this error
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
scrollViewProps[propName] = value;
} else {
// @ts-ignore - this function cannot have generic type so we have to ignore this error
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
flatListProps[propName] = value;
}
}
return (
// @ts-ignore - this function cannot have generic type so we have to ignore this error
<RNFlatList
ref={ref}
{...flatListProps}
renderScrollComponent={(scrollProps) => (
<ScrollView
{...{
...scrollProps,
...scrollViewProps,
waitFor: [...toArray(waitFor ?? []), refreshControlGestureRef],
}}
/>
)}
// @ts-ignore we don't pass `refreshing` prop as we only want to override the ref
refreshControl={
refreshControl
? React.cloneElement(refreshControl, {
// @ts-ignore for reasons unknown to me, `ref` doesn't exist on the type inferred by TS
ref: refreshControlGestureRef,
})
: undefined
}
/>
);
}) as <ItemT = any>(
props: PropsWithChildren<
Omit<RNFlatListProps<ItemT>, 'renderScrollComponent'> &
RefAttributes<FlatList<ItemT>> &
NativeViewGestureHandlerProps
>,
ref?: ForwardedRef<FlatList<ItemT>>
) => ReactElement | null;
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type FlatList<ItemT = any> = typeof FlatList & RNFlatList<ItemT>;
@@ -0,0 +1,41 @@
import * as React from 'react';
import {
FlatList as RNFlatList,
Switch as RNSwitch,
TextInput as RNTextInput,
ScrollView as RNScrollView,
FlatListProps,
View,
} from 'react-native';
import createNativeWrapper from '../handlers/createNativeWrapper';
export const ScrollView = createNativeWrapper(RNScrollView, {
disallowInterruption: false,
});
export const Switch = createNativeWrapper(RNSwitch, {
shouldCancelWhenOutside: false,
shouldActivateOnStart: true,
disallowInterruption: true,
});
export const TextInput = createNativeWrapper(RNTextInput);
export const DrawerLayoutAndroid = () => {
console.warn('DrawerLayoutAndroid is not supported on web!');
return <View />;
};
// RefreshControl is implemented as a functional component, rendering a View
// NativeViewGestureHandler needs to set a ref on its child, which cannot be done
// on functional components
export const RefreshControl = createNativeWrapper(View);
export const FlatList = React.forwardRef(
<ItemT,>(props: FlatListProps<ItemT>, ref: any) => (
<RNFlatList
ref={ref}
{...props}
renderScrollComponent={(scrollProps) => <ScrollView {...scrollProps} />}
/>
)
);
@@ -0,0 +1,5 @@
import { HostComponent } from 'react-native';
import type { RawButtonProps } from './GestureButtonsProps';
import RNGestureHandlerButtonNativeComponent from '../specs/RNGestureHandlerButtonNativeComponent';
export default RNGestureHandlerButtonNativeComponent as HostComponent<RawButtonProps>;
@@ -0,0 +1,6 @@
import * as React from 'react';
import { View } from 'react-native';
export default React.forwardRef<React.ComponentRef<typeof View>>(
(props, ref) => <View ref={ref} accessibilityRole="button" {...props} />
);
@@ -0,0 +1,32 @@
import * as React from 'react';
import { PropsWithChildren } from 'react';
import { ViewProps, StyleSheet } from 'react-native';
import { maybeInitializeFabric } from '../init';
import GestureHandlerRootViewContext from '../GestureHandlerRootViewContext';
import GestureHandlerRootViewNativeComponent from '../specs/RNGestureHandlerRootViewNativeComponent';
export interface GestureHandlerRootViewProps
extends PropsWithChildren<ViewProps> {}
export default function GestureHandlerRootView({
style,
...rest
}: GestureHandlerRootViewProps) {
// Try initialize fabric on the first render, at this point we can
// reliably check if fabric is enabled (the function contains a flag
// to make sure it's called only once)
maybeInitializeFabric();
return (
<GestureHandlerRootViewContext.Provider value>
<GestureHandlerRootViewNativeComponent
style={style ?? styles.container}
{...rest}
/>
</GestureHandlerRootViewContext.Provider>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
});
@@ -0,0 +1,28 @@
import * as React from 'react';
import { PropsWithChildren } from 'react';
import { View, ViewProps, StyleSheet } from 'react-native';
import { maybeInitializeFabric } from '../init';
import GestureHandlerRootViewContext from '../GestureHandlerRootViewContext';
export interface GestureHandlerRootViewProps
extends PropsWithChildren<ViewProps> {}
export default function GestureHandlerRootView({
style,
...rest
}: GestureHandlerRootViewProps) {
// Try initialize fabric on the first render, at this point we can
// reliably check if fabric is enabled (the function contains a flag
// to make sure it's called only once)
maybeInitializeFabric();
return (
<GestureHandlerRootViewContext.Provider value>
<View style={style ?? styles.container} {...rest} />
</GestureHandlerRootViewContext.Provider>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
});
@@ -0,0 +1,22 @@
import * as React from 'react';
import { PropsWithChildren } from 'react';
import { View, ViewProps, StyleSheet } from 'react-native';
import GestureHandlerRootViewContext from '../GestureHandlerRootViewContext';
export interface GestureHandlerRootViewProps
extends PropsWithChildren<ViewProps> {}
export default function GestureHandlerRootView({
style,
...rest
}: GestureHandlerRootViewProps) {
return (
<GestureHandlerRootViewContext.Provider value>
<View style={style ?? styles.container} {...rest} />
</GestureHandlerRootViewContext.Provider>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
});
@@ -0,0 +1,393 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { GestureObjects as Gesture } from '../../handlers/gestures/gestureObjects';
import { GestureDetector } from '../../handlers/gestures/GestureDetector';
import {
PressableEvent,
PressableProps,
PressableDimensions,
} from './PressableProps';
import {
Insets,
LayoutChangeEvent,
Platform,
StyleProp,
ViewStyle,
processColor,
} from 'react-native';
import NativeButton from '../GestureHandlerButton';
import {
gestureToPressableEvent,
addInsets,
numberAsInset,
gestureTouchToPressableEvent,
isTouchWithinInset,
} from './utils';
import { PressabilityDebugView } from '../../handlers/PressabilityDebugView';
import { INT32_MAX, isFabric, isTestEnv } from '../../utils';
import {
applyRelationProp,
RelationPropName,
RelationPropType,
} from '../utils';
import { getStatesConfig, StateMachineEvent } from './stateDefinitions';
import { PressableStateMachine } from './StateMachine';
const DEFAULT_LONG_PRESS_DURATION = 500;
const IS_TEST_ENV = isTestEnv();
let IS_FABRIC: null | boolean = null;
const Pressable = (props: PressableProps) => {
const {
testOnly_pressed,
hitSlop,
pressRetentionOffset,
delayHoverIn,
delayHoverOut,
delayLongPress,
unstable_pressDelay,
onHoverIn,
onHoverOut,
onPress,
onPressIn,
onPressOut,
onLongPress,
onLayout,
style,
children,
android_disableSound,
android_ripple,
disabled,
accessible,
simultaneousWithExternalGesture,
requireExternalGestureToFail,
blocksExternalGesture,
...remainingProps
} = props;
const relationProps = {
simultaneousWithExternalGesture,
requireExternalGestureToFail,
blocksExternalGesture,
};
const [pressedState, setPressedState] = useState(testOnly_pressed ?? false);
const longPressTimeoutRef = useRef<number | null>(null);
const pressDelayTimeoutRef = useRef<number | null>(null);
const isOnPressAllowed = useRef<boolean>(true);
const isCurrentlyPressed = useRef<boolean>(false);
const dimensions = useRef<PressableDimensions>({ width: 0, height: 0 });
const normalizedHitSlop: Insets = useMemo(
() =>
typeof hitSlop === 'number' ? numberAsInset(hitSlop) : (hitSlop ?? {}),
[hitSlop]
);
const normalizedPressRetentionOffset: Insets = useMemo(
() =>
typeof pressRetentionOffset === 'number'
? numberAsInset(pressRetentionOffset)
: (pressRetentionOffset ?? {}),
[pressRetentionOffset]
);
const appliedHitSlop = addInsets(
normalizedHitSlop,
normalizedPressRetentionOffset
);
const cancelLongPress = useCallback(() => {
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
longPressTimeoutRef.current = null;
isOnPressAllowed.current = true;
}
}, []);
const cancelDelayedPress = useCallback(() => {
if (pressDelayTimeoutRef.current) {
clearTimeout(pressDelayTimeoutRef.current);
pressDelayTimeoutRef.current = null;
}
}, []);
const startLongPress = useCallback(
(event: PressableEvent) => {
if (onLongPress) {
cancelLongPress();
longPressTimeoutRef.current = setTimeout(() => {
isOnPressAllowed.current = false;
onLongPress(event);
}, delayLongPress ?? DEFAULT_LONG_PRESS_DURATION);
}
},
[onLongPress, cancelLongPress, delayLongPress]
);
const innerHandlePressIn = useCallback(
(event: PressableEvent) => {
onPressIn?.(event);
startLongPress(event);
setPressedState(true);
if (pressDelayTimeoutRef.current) {
clearTimeout(pressDelayTimeoutRef.current);
pressDelayTimeoutRef.current = null;
}
},
[onPressIn, startLongPress]
);
const handleFinalize = useCallback(() => {
isCurrentlyPressed.current = false;
cancelLongPress();
cancelDelayedPress();
setPressedState(false);
}, [cancelDelayedPress, cancelLongPress]);
const handlePressIn = useCallback(
(event: PressableEvent) => {
if (
!isTouchWithinInset(
dimensions.current,
normalizedHitSlop,
event.nativeEvent.changedTouches.at(-1)
)
) {
// Ignoring pressIn within pressRetentionOffset
return;
}
isCurrentlyPressed.current = true;
if (unstable_pressDelay) {
pressDelayTimeoutRef.current = setTimeout(() => {
innerHandlePressIn(event);
}, unstable_pressDelay);
} else {
innerHandlePressIn(event);
}
},
[innerHandlePressIn, normalizedHitSlop, unstable_pressDelay]
);
const handlePressOut = useCallback(
(event: PressableEvent, success: boolean = true) => {
if (!isCurrentlyPressed.current) {
// Some prop configurations may lead to handlePressOut being called mutliple times.
return;
}
isCurrentlyPressed.current = false;
if (pressDelayTimeoutRef.current) {
innerHandlePressIn(event);
}
onPressOut?.(event);
if (isOnPressAllowed.current && success) {
onPress?.(event);
}
handleFinalize();
},
[handleFinalize, innerHandlePressIn, onPress, onPressOut]
);
const stateMachine = useMemo(() => new PressableStateMachine(), []);
useEffect(() => {
const configuration = getStatesConfig(handlePressIn, handlePressOut);
stateMachine.setStates(configuration);
}, [handlePressIn, handlePressOut, stateMachine]);
const hoverInTimeout = useRef<number | null>(null);
const hoverOutTimeout = useRef<number | null>(null);
const hoverGesture = useMemo(
() =>
Gesture.Hover()
.manualActivation(true) // Prevents Hover blocking Gesture.Native() on web
.cancelsTouchesInView(false)
.onBegin((event) => {
if (hoverOutTimeout.current) {
clearTimeout(hoverOutTimeout.current);
}
if (delayHoverIn) {
hoverInTimeout.current = setTimeout(
() => onHoverIn?.(gestureToPressableEvent(event)),
delayHoverIn
);
return;
}
onHoverIn?.(gestureToPressableEvent(event));
})
.onFinalize((event) => {
if (hoverInTimeout.current) {
clearTimeout(hoverInTimeout.current);
}
if (delayHoverOut) {
hoverOutTimeout.current = setTimeout(
() => onHoverOut?.(gestureToPressableEvent(event)),
delayHoverOut
);
return;
}
onHoverOut?.(gestureToPressableEvent(event));
}),
[delayHoverIn, delayHoverOut, onHoverIn, onHoverOut]
);
const pressAndTouchGesture = useMemo(
() =>
Gesture.LongPress()
.minDuration(INT32_MAX) // Stops long press from blocking Gesture.Native()
.maxDistance(INT32_MAX) // Stops long press from cancelling on touch move
.cancelsTouchesInView(false)
.onTouchesDown((event) => {
const pressableEvent = gestureTouchToPressableEvent(event);
stateMachine.handleEvent(
StateMachineEvent.LONG_PRESS_TOUCHES_DOWN,
pressableEvent
);
})
.onTouchesUp(() => {
if (Platform.OS === 'android') {
// Prevents potential soft-locks
stateMachine.reset();
handleFinalize();
}
})
.onTouchesCancelled((event) => {
const pressableEvent = gestureTouchToPressableEvent(event);
stateMachine.reset();
handlePressOut(pressableEvent, false);
})
.onFinalize(() => {
if (Platform.OS === 'web') {
stateMachine.handleEvent(StateMachineEvent.FINALIZE);
handleFinalize();
}
}),
[stateMachine, handleFinalize, handlePressOut]
);
// RNButton is placed inside ButtonGesture to enable Android's ripple and to capture non-propagating events
const buttonGesture = useMemo(
() =>
Gesture.Native()
.onTouchesCancelled((event) => {
if (Platform.OS !== 'macos' && Platform.OS !== 'web') {
// On MacOS cancel occurs in middle of gesture
// On Web cancel occurs on mouse move, which is unwanted
const pressableEvent = gestureTouchToPressableEvent(event);
stateMachine.reset();
handlePressOut(pressableEvent, false);
}
})
.onBegin(() => {
stateMachine.handleEvent(StateMachineEvent.NATIVE_BEGIN);
})
.onStart(() => {
if (Platform.OS !== 'android') {
// Gesture.Native().onStart() is broken with Android + hitSlop
stateMachine.handleEvent(StateMachineEvent.NATIVE_START);
}
})
.onFinalize(() => {
if (Platform.OS !== 'web') {
// On Web we use LongPress().onFinalize() instead of Native().onFinalize(),
// as Native cancels on mouse move, and LongPress does not.
stateMachine.handleEvent(StateMachineEvent.FINALIZE);
handleFinalize();
}
}),
[stateMachine, handlePressOut, handleFinalize]
);
const isPressableEnabled = disabled !== true;
const gestures = [buttonGesture, pressAndTouchGesture, hoverGesture];
for (const gesture of gestures) {
gesture.enabled(isPressableEnabled);
gesture.runOnJS(true);
gesture.hitSlop(appliedHitSlop);
gesture.shouldCancelWhenOutside(Platform.OS !== 'web');
Object.entries(relationProps).forEach(([relationName, relation]) => {
applyRelationProp(
gesture,
relationName as RelationPropName,
relation as RelationPropType
);
});
}
const gesture = Gesture.Simultaneous(...gestures);
// `cursor: 'pointer'` on `RNButton` crashes iOS
const pointerStyle: StyleProp<ViewStyle> =
Platform.OS === 'web' ? { cursor: 'pointer' } : {};
const styleProp =
typeof style === 'function' ? style({ pressed: pressedState }) : style;
const childrenProp =
typeof children === 'function'
? children({ pressed: pressedState })
: children;
const rippleColor = useMemo(() => {
if (IS_FABRIC === null) {
IS_FABRIC = isFabric();
}
const defaultRippleColor = android_ripple ? undefined : 'transparent';
const unprocessedRippleColor = android_ripple?.color ?? defaultRippleColor;
return IS_FABRIC
? unprocessedRippleColor
: processColor(unprocessedRippleColor);
}, [android_ripple]);
const setDimensions = useCallback(
(event: LayoutChangeEvent) => {
onLayout?.(event);
dimensions.current = event.nativeEvent.layout;
},
[onLayout]
);
return (
<GestureDetector gesture={gesture}>
<NativeButton
{...remainingProps}
onLayout={setDimensions}
accessible={accessible !== false}
hitSlop={appliedHitSlop}
enabled={isPressableEnabled}
touchSoundDisabled={android_disableSound ?? undefined}
rippleColor={rippleColor}
rippleRadius={android_ripple?.radius ?? undefined}
style={[pointerStyle, styleProp]}
testOnly_onPress={IS_TEST_ENV ? onPress : undefined}
testOnly_onPressIn={IS_TEST_ENV ? onPressIn : undefined}
testOnly_onPressOut={IS_TEST_ENV ? onPressOut : undefined}
testOnly_onLongPress={IS_TEST_ENV ? onLongPress : undefined}>
{childrenProp}
{__DEV__ ? (
<PressabilityDebugView color="red" hitSlop={normalizedHitSlop} />
) : null}
</NativeButton>
</GestureDetector>
);
};
export default Pressable;
@@ -0,0 +1,174 @@
import {
AccessibilityProps,
ViewProps,
Insets,
StyleProp,
ViewStyle,
PressableStateCallbackType as RNPressableStateCallbackType,
PressableAndroidRippleConfig as RNPressableAndroidRippleConfig,
View,
} from 'react-native';
import { RelationPropType } from '../utils';
export type PressableDimensions = { width: number; height: number };
export type PressableStateCallbackType = RNPressableStateCallbackType;
export type PressableAndroidRippleConfig = RNPressableAndroidRippleConfig;
export type InnerPressableEvent = {
changedTouches: InnerPressableEvent[];
identifier: number;
locationX: number;
locationY: number;
pageX: number;
pageY: number;
target: number;
timestamp: number;
touches: InnerPressableEvent[];
force?: number;
};
export type PressableEvent = { nativeEvent: InnerPressableEvent };
export interface PressableProps
extends AccessibilityProps,
Omit<ViewProps, 'children' | 'style' | 'hitSlop'> {
/**
* Called when the hover is activated to provide visual feedback.
*/
onHoverIn?: null | ((event: PressableEvent) => void);
/**
* Called when the hover is deactivated to undo visual feedback.
*/
onHoverOut?: null | ((event: PressableEvent) => void);
/**
* Called when a single tap gesture is detected.
*/
onPress?: null | ((event: PressableEvent) => void);
/**
* Called when a touch is engaged before `onPress`.
*/
onPressIn?: null | ((event: PressableEvent) => void);
/**
* Called when a touch is released before `onPress`.
*/
onPressOut?: null | ((event: PressableEvent) => void);
/**
* Called when a long-tap gesture is detected.
*/
onLongPress?: null | ((event: PressableEvent) => void);
/**
* A reference to the pressable element.
*/
ref?: React.Ref<View>;
/**
* Either children or a render prop that receives a boolean reflecting whether
* the component is currently pressed.
*/
children?:
| React.ReactNode
| ((state: PressableStateCallbackType) => React.ReactNode);
/**
* Whether a press gesture can be interrupted by a parent gesture such as a
* scroll event. Defaults to true.
*/
cancelable?: null | boolean;
/**
* Duration to wait after hover in before calling `onHoverIn`.
* @platform web macos
*
* NOTE: not present in RN docs
*/
delayHoverIn?: number | null;
/**
* Duration to wait after hover out before calling `onHoverOut`.
* @platform web macos
*
* NOTE: not present in RN docs
*/
delayHoverOut?: number | null;
/**
* Duration (in milliseconds) from `onPressIn` before `onLongPress` is called.
*/
delayLongPress?: null | number;
/**
* Whether the press behavior is disabled.
*/
disabled?: null | boolean;
/**
* Additional distance outside of this view in which a press is detected.
*/
hitSlop?: null | Insets | number;
/**
* Additional distance outside of this view in which a touch is considered a
* press before `onPressOut` is triggered.
*/
pressRetentionOffset?: null | Insets | number;
/**
* If true, doesn't play system sound on touch.
* @platform android
*/
android_disableSound?: null | boolean;
/**
* Enables the Android ripple effect and configures its color.
* @platform android
*/
android_ripple?: null | PressableAndroidRippleConfig;
/**
* Used only for documentation or testing (e.g. snapshot testing).
*/
testOnly_pressed?: null | boolean;
/**
* Either view styles or a function that receives a boolean reflecting whether
* the component is currently pressed and returns view styles.
*/
style?:
| StyleProp<ViewStyle>
| ((state: PressableStateCallbackType) => StyleProp<ViewStyle>);
/**
* Duration (in milliseconds) to wait after press down before calling onPressIn.
*/
unstable_pressDelay?: number;
/**
* A gesture object or an array of gesture objects containing the configuration and callbacks to be
* used with the Pressable's gesture handlers.
*/
simultaneousWithExternalGesture?: RelationPropType;
/**
* A gesture object or an array of gesture objects containing the configuration and callbacks to be
* used with the Pressable's gesture handlers.
*/
requireExternalGestureToFail?: RelationPropType;
/**
* A gesture object or an array of gesture objects containing the configuration and callbacks to be
* used with the Pressable's gesture handlers.
*/
blocksExternalGesture?: RelationPropType;
/**
* @deprecated This property is no longer used, and will be removed in the future.
*/
dimensionsAfterResize?: PressableDimensions;
}
@@ -0,0 +1,57 @@
import { PressableEvent } from './PressableProps';
export interface StateDefinition {
eventName: string;
callback?: (event: PressableEvent) => void;
}
class PressableStateMachine {
private states: StateDefinition[] | null;
private currentStepIndex: number;
private eventPayload: PressableEvent | null;
constructor() {
this.states = null;
this.currentStepIndex = 0;
this.eventPayload = null;
}
public setStates(states: StateDefinition[]) {
this.states = states;
}
public reset() {
this.currentStepIndex = 0;
this.eventPayload = null;
}
public handleEvent(eventName: string, eventPayload?: PressableEvent) {
if (!this.states) {
return;
}
const step = this.states[this.currentStepIndex];
this.eventPayload = eventPayload || this.eventPayload;
if (step.eventName !== eventName) {
if (this.currentStepIndex > 0) {
// retry with position at index 0
this.reset();
this.handleEvent(eventName, eventPayload);
}
return;
}
if (this.eventPayload && step.callback) {
step.callback(this.eventPayload);
}
this.currentStepIndex++;
if (this.currentStepIndex === this.states.length) {
this.reset();
}
}
}
export { PressableStateMachine };
@@ -0,0 +1,5 @@
export type {
PressableProps,
PressableStateCallbackType,
} from './PressableProps';
export { default } from './Pressable';
@@ -0,0 +1,125 @@
import { Platform } from 'react-native';
import { PressableEvent } from './PressableProps';
import { StateDefinition } from './StateMachine';
export enum StateMachineEvent {
NATIVE_BEGIN = 'nativeBegin',
NATIVE_START = 'nativeStart',
FINALIZE = 'finalize',
LONG_PRESS_TOUCHES_DOWN = 'longPressTouchesDown',
}
function getAndroidStatesConfig(
handlePressIn: (event: PressableEvent) => void,
handlePressOut: (event: PressableEvent) => void
) {
return [
{
eventName: StateMachineEvent.NATIVE_BEGIN,
},
{
eventName: StateMachineEvent.LONG_PRESS_TOUCHES_DOWN,
callback: handlePressIn,
},
{
eventName: StateMachineEvent.FINALIZE,
callback: handlePressOut,
},
];
}
function getIosStatesConfig(
handlePressIn: (event: PressableEvent) => void,
handlePressOut: (event: PressableEvent) => void
) {
return [
{
eventName: StateMachineEvent.LONG_PRESS_TOUCHES_DOWN,
},
{
eventName: StateMachineEvent.NATIVE_START,
callback: handlePressIn,
},
{
eventName: StateMachineEvent.FINALIZE,
callback: handlePressOut,
},
];
}
function getWebStatesConfig(
handlePressIn: (event: PressableEvent) => void,
handlePressOut: (event: PressableEvent) => void
) {
return [
{
eventName: StateMachineEvent.NATIVE_BEGIN,
},
{
eventName: StateMachineEvent.NATIVE_START,
},
{
eventName: StateMachineEvent.LONG_PRESS_TOUCHES_DOWN,
callback: handlePressIn,
},
{
eventName: StateMachineEvent.FINALIZE,
callback: handlePressOut,
},
];
}
function getMacosStatesConfig(
handlePressIn: (event: PressableEvent) => void,
handlePressOut: (event: PressableEvent) => void
) {
return [
{
eventName: StateMachineEvent.LONG_PRESS_TOUCHES_DOWN,
},
{
eventName: StateMachineEvent.NATIVE_BEGIN,
callback: handlePressIn,
},
{
eventName: StateMachineEvent.NATIVE_START,
},
{
eventName: StateMachineEvent.FINALIZE,
callback: handlePressOut,
},
];
}
function getUniversalStatesConfig(
handlePressIn: (event: PressableEvent) => void,
handlePressOut: (event: PressableEvent) => void
) {
return [
{
eventName: StateMachineEvent.FINALIZE,
callback: (event: PressableEvent) => {
handlePressIn(event);
handlePressOut(event);
},
},
];
}
export function getStatesConfig(
handlePressIn: (event: PressableEvent) => void,
handlePressOut: (event: PressableEvent) => void
): StateDefinition[] {
if (Platform.OS === 'android') {
return getAndroidStatesConfig(handlePressIn, handlePressOut);
} else if (Platform.OS === 'ios') {
return getIosStatesConfig(handlePressIn, handlePressOut);
} else if (Platform.OS === 'web') {
return getWebStatesConfig(handlePressIn, handlePressOut);
} else if (Platform.OS === 'macos') {
return getMacosStatesConfig(handlePressIn, handlePressOut);
} else {
// Unknown platform - using minimal universal setup.
return getUniversalStatesConfig(handlePressIn, handlePressOut);
}
}
@@ -0,0 +1,140 @@
import { Insets } from 'react-native';
import {
HoverGestureHandlerEventPayload,
LongPressGestureHandlerEventPayload,
} from '../../handlers/GestureHandlerEventPayload';
import {
TouchData,
GestureStateChangeEvent,
GestureTouchEvent,
} from '../../handlers/gestureHandlerCommon';
import {
PressableDimensions,
InnerPressableEvent,
PressableEvent,
} from './PressableProps';
const numberAsInset = (value: number): Insets => ({
left: value,
right: value,
top: value,
bottom: value,
});
const addInsets = (a: Insets, b: Insets): Insets => ({
left: (a.left ?? 0) + (b.left ?? 0),
right: (a.right ?? 0) + (b.right ?? 0),
top: (a.top ?? 0) + (b.top ?? 0),
bottom: (a.bottom ?? 0) + (b.bottom ?? 0),
});
const touchDataToPressEvent = (
data: TouchData,
timestamp: number,
targetId: number
): InnerPressableEvent => ({
identifier: data.id,
locationX: data.x,
locationY: data.y,
pageX: data.absoluteX,
pageY: data.absoluteY,
target: targetId,
timestamp: timestamp,
touches: [], // Always empty - legacy compatibility
changedTouches: [], // Always empty - legacy compatibility
});
const gestureToPressEvent = (
event: GestureStateChangeEvent<
HoverGestureHandlerEventPayload | LongPressGestureHandlerEventPayload
>,
timestamp: number,
targetId: number
): InnerPressableEvent => ({
identifier: event.handlerTag,
locationX: event.x,
locationY: event.y,
pageX: event.absoluteX,
pageY: event.absoluteY,
target: targetId,
timestamp: timestamp,
touches: [], // Always empty - legacy compatibility
changedTouches: [], // Always empty - legacy compatibility
});
const isTouchWithinInset = (
dimensions: PressableDimensions,
inset: Insets,
touch?: InnerPressableEvent
) =>
(touch?.locationX ?? 0) < (inset.right ?? 0) + dimensions.width &&
(touch?.locationY ?? 0) < (inset.bottom ?? 0) + dimensions.height &&
(touch?.locationX ?? 0) > -(inset.left ?? 0) &&
(touch?.locationY ?? 0) > -(inset.top ?? 0);
const gestureToPressableEvent = (
event: GestureStateChangeEvent<
HoverGestureHandlerEventPayload | LongPressGestureHandlerEventPayload
>
): PressableEvent => {
const timestamp = Date.now();
// As far as I can see, there isn't a conventional way of getting targetId with the data we get
const targetId = 0;
const pressEvent = gestureToPressEvent(event, timestamp, targetId);
return {
nativeEvent: {
touches: [pressEvent],
changedTouches: [pressEvent],
identifier: pressEvent.identifier,
locationX: event.x,
locationY: event.y,
pageX: event.absoluteX,
pageY: event.absoluteY,
target: targetId,
timestamp: timestamp,
force: undefined,
},
};
};
const gestureTouchToPressableEvent = (
event: GestureTouchEvent
): PressableEvent => {
const timestamp = Date.now();
// As far as I can see, there isn't a conventional way of getting targetId with the data we get
const targetId = 0;
const touchesList = event.allTouches.map((touch: TouchData) =>
touchDataToPressEvent(touch, timestamp, targetId)
);
const changedTouchesList = event.changedTouches.map((touch: TouchData) =>
touchDataToPressEvent(touch, timestamp, targetId)
);
return {
nativeEvent: {
touches: touchesList,
changedTouches: changedTouchesList,
identifier: event.handlerTag,
locationX: event.allTouches.at(0)?.x ?? -1,
locationY: event.allTouches.at(0)?.y ?? -1,
pageX: event.allTouches.at(0)?.absoluteX ?? -1,
pageY: event.allTouches.at(0)?.absoluteY ?? -1,
target: targetId,
timestamp: timestamp,
force: undefined,
},
};
};
export {
numberAsInset,
addInsets,
isTouchWithinInset,
gestureToPressableEvent,
gestureTouchToPressableEvent,
};
@@ -0,0 +1,754 @@
// This component is based on RN's DrawerLayoutAndroid API
// It's cross-compatible with all platforms despite
// `DrawerLayoutAndroid` only being available on android
import React, {
ReactNode,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react';
import {
StyleSheet,
Keyboard,
StatusBar,
I18nManager,
StatusBarAnimation,
StyleProp,
ViewStyle,
LayoutChangeEvent,
Platform,
} from 'react-native';
import Animated, {
Extrapolation,
SharedValue,
interpolate,
runOnJS,
useAnimatedProps,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import { GestureObjects as Gesture } from '../handlers/gestures/gestureObjects';
import { GestureDetector } from '../handlers/gestures/GestureDetector';
import {
UserSelect,
ActiveCursor,
MouseButton,
HitSlop,
GestureStateChangeEvent,
} from '../handlers/gestureHandlerCommon';
import { PanGestureHandlerEventPayload } from '../handlers/GestureHandlerEventPayload';
const DRAG_TOSS = 0.05;
export enum DrawerPosition {
LEFT,
RIGHT,
}
export enum DrawerState {
IDLE,
DRAGGING,
SETTLING,
}
export enum DrawerType {
FRONT,
BACK,
SLIDE,
}
export enum DrawerLockMode {
UNLOCKED,
LOCKED_CLOSED,
LOCKED_OPEN,
}
export enum DrawerKeyboardDismissMode {
NONE,
ON_DRAG,
}
export interface DrawerLayoutProps {
/**
* This attribute is present in the native android implementation already and is one
* of the required params. The gesture handler version of DrawerLayout makes it
* possible for the function passed as `renderNavigationView` to take an
* Animated value as a parameter that indicates the progress of drawer
* opening/closing animation (progress value is 0 when closed and 1 when
* opened). This can be used by the drawer component to animated its children
* while the drawer is opening or closing.
*/
renderNavigationView: (
progressAnimatedValue: SharedValue<number>
) => ReactNode;
/**
* Determines the side from which the drawer will open.
*/
drawerPosition?: DrawerPosition;
/**
* Width of the drawer.
*/
drawerWidth?: number;
/**
* Background color of the drawer.
*/
drawerBackgroundColor?: string;
/**
* Specifies the lock mode of the drawer.
* Programatic opening/closing isn't affected by the lock mode. Defaults to `UNLOCKED`.
* - `UNLOCKED` - the drawer will respond to gestures.
* - `LOCKED_CLOSED` - the drawer will move freely until it settles in a closed position, then the gestures will be disabled.
* - `LOCKED_OPEN` - the drawer will move freely until it settles in an opened position, then the gestures will be disabled.
*/
drawerLockMode?: DrawerLockMode;
/**
* Determines if system keyboard should be closed upon dragging the drawer.
*/
keyboardDismissMode?: DrawerKeyboardDismissMode;
/**
* Called when the drawer is closed.
*/
onDrawerClose?: () => void;
/**
* Called when the drawer is opened.
*/
onDrawerOpen?: () => void;
/**
* Called when the status of the drawer changes.
*/
onDrawerStateChanged?: (
newState: DrawerState,
drawerWillShow: boolean
) => void;
/**
* Type of animation that will play when opening the drawer.
*/
drawerType?: DrawerType;
/**
* Speed of animation that will play when letting go, or dismissing the drawer.
* This will also be the default animation speed for programatic controlls.
*/
animationSpeed?: number;
/**
* Defines how far from the edge of the content view the gesture should
* activate.
*/
edgeWidth?: number;
/**
* Minimal distance to swipe before the drawer starts moving.
*/
minSwipeDistance?: number;
/**
* When set to true Drawer component will use
* {@link https://reactnative.dev/docs/statusbar StatusBar} API to hide the OS
* status bar whenever the drawer is pulled or when its in an "open" state.
*/
hideStatusBar?: boolean;
/**
* @default 'slide'
*
* Can be used when hideStatusBar is set to true and will select the animation
* used for hiding/showing the status bar. See
* {@link https://reactnative.dev/docs/statusbar StatusBar} documentation for
* more details
*/
statusBarAnimation?: StatusBarAnimation;
/**
* @default 'rgba(0, 0, 0, 0.7)'
*
* Color of the background overlay.
* Animated from `0%` to `100%` as the drawer opens.
*/
overlayColor?: string;
/**
* Style wrapping the content.
*/
contentContainerStyle?: StyleProp<ViewStyle>;
/**
* Style wrapping the drawer.
*/
drawerContainerStyle?: StyleProp<ViewStyle>;
/**
* Enables two-finger gestures on supported devices, for example iPads with
* trackpads. If not enabled the gesture will require click + drag, with
* `enableTrackpadTwoFingerGesture` swiping with two fingers will also trigger
* the gesture.
*/
enableTrackpadTwoFingerGesture?: boolean;
onDrawerSlide?: (position: number) => void;
// Implicit `children` prop has been removed in @types/react^18.0.
/**
* Elements that will be rendered inside the content view.
*/
children?: ReactNode | ((openValue?: SharedValue<number>) => ReactNode);
/**
* @default 'none'
* Sets whether the text inside both the drawer and the context window can be selected.
* Values: 'none' | 'text' | 'auto'
*/
userSelect?: UserSelect;
/**
* @default 'auto'
* Sets the displayed cursor pictogram when the drawer is being dragged.
* Values: see CSS cursor values
*/
activeCursor?: ActiveCursor;
/**
* @default 'MouseButton.LEFT'
* Allows to choose which mouse button should underlying pan handler react to.
*/
mouseButton?: MouseButton;
/**
* @default 'false if MouseButton.RIGHT is specified'
* Allows to enable/disable context menu.
*/
enableContextMenu?: boolean;
}
export type DrawerMovementOption = {
initialVelocity?: number;
animationSpeed?: number;
};
export interface DrawerLayoutMethods {
openDrawer: (options?: DrawerMovementOption) => void;
closeDrawer: (options?: DrawerMovementOption) => void;
}
const defaultProps = {
drawerWidth: 200,
drawerPosition: DrawerPosition.LEFT,
drawerType: DrawerType.FRONT,
edgeWidth: 20,
minSwipeDistance: 3,
overlayColor: 'rgba(0, 0, 0, 0.7)',
drawerLockMode: DrawerLockMode.UNLOCKED,
enableTrackpadTwoFingerGesture: false,
activeCursor: 'auto' as ActiveCursor,
mouseButton: MouseButton.LEFT,
statusBarAnimation: 'slide' as StatusBarAnimation,
};
// StatusBar.setHidden and Keyboard.dismiss cannot be directly referenced in worklets.
const setStatusBarHidden = StatusBar.setHidden;
const dismissKeyboard = Keyboard.dismiss;
const DrawerLayout = forwardRef<DrawerLayoutMethods, DrawerLayoutProps>(
function DrawerLayout(props: DrawerLayoutProps, ref) {
const [containerWidth, setContainerWidth] = useState(0);
const [drawerState, setDrawerState] = useState<DrawerState>(
DrawerState.IDLE
);
const [drawerOpened, setDrawerOpened] = useState(false);
const {
drawerPosition = defaultProps.drawerPosition,
drawerWidth = defaultProps.drawerWidth,
drawerType = defaultProps.drawerType,
drawerBackgroundColor,
drawerContainerStyle,
contentContainerStyle,
minSwipeDistance = defaultProps.minSwipeDistance,
edgeWidth = defaultProps.edgeWidth,
drawerLockMode = defaultProps.drawerLockMode,
overlayColor = defaultProps.overlayColor,
enableTrackpadTwoFingerGesture = defaultProps.enableTrackpadTwoFingerGesture,
activeCursor = defaultProps.activeCursor,
mouseButton = defaultProps.mouseButton,
statusBarAnimation = defaultProps.statusBarAnimation,
hideStatusBar,
keyboardDismissMode,
userSelect,
enableContextMenu,
renderNavigationView,
onDrawerSlide,
onDrawerClose,
onDrawerOpen,
onDrawerStateChanged,
animationSpeed: animationSpeedProp,
} = props;
const isFromLeft = drawerPosition === DrawerPosition.LEFT;
const sideCorrection = isFromLeft ? 1 : -1;
// While closing the drawer when user starts gesture in the greyed out part of the window,
// we want the drawer to follow only once the finger reaches the edge of the drawer.
// See the diagram for reference. * = starting finger position, < = current finger position
// 1) +---------------+ 2) +---------------+ 3) +---------------+ 4) +---------------+
// |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
// |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
// |XXXXXXXX|..<*..| |XXXXXXXX|.<-*..| |XXXXXXXX|<--*..| |XXXXX|<-----*..|
// |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
// |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........|
// +---------------+ +---------------+ +---------------+ +---------------+
const openValue = useSharedValue<number>(0);
useDerivedValue(() => {
onDrawerSlide && runOnJS(onDrawerSlide)(openValue.value);
}, []);
const isDrawerOpen = useSharedValue(false);
const handleContainerLayout = ({ nativeEvent }: LayoutChangeEvent) => {
setContainerWidth(nativeEvent.layout.width);
};
const emitStateChanged = useCallback(
(newState: DrawerState, drawerWillShow: boolean) => {
'worklet';
onDrawerStateChanged &&
runOnJS(onDrawerStateChanged)?.(newState, drawerWillShow);
},
[onDrawerStateChanged]
);
const drawerAnimatedProps = useAnimatedProps(() => ({
accessibilityViewIsModal: isDrawerOpen.value,
}));
const overlayAnimatedProps = useAnimatedProps(() => ({
pointerEvents: isDrawerOpen.value ? ('auto' as const) : ('none' as const),
}));
// While the drawer is hidden, it's hitSlop overflows onto the main view by edgeWidth
// This way it can be swiped open even when it's hidden
const [edgeHitSlop, setEdgeHitSlop] = useState<HitSlop>(
isFromLeft
? { left: 0, width: edgeWidth }
: { right: 0, width: edgeWidth }
);
// gestureOrientation is 1 if the gesture is expected to move from left to right and -1 otherwise
const gestureOrientation = useMemo(
() => sideCorrection * (drawerOpened ? -1 : 1),
[sideCorrection, drawerOpened]
);
useEffect(() => {
setEdgeHitSlop(
isFromLeft
? { left: 0, width: edgeWidth }
: { right: 0, width: edgeWidth }
);
}, [isFromLeft, edgeWidth]);
const animateDrawer = useCallback(
(toValue: number, initialVelocity: number, animationSpeed?: number) => {
'worklet';
const willShow = toValue !== 0;
isDrawerOpen.value = willShow;
emitStateChanged(DrawerState.SETTLING, willShow);
runOnJS(setDrawerState)(DrawerState.SETTLING);
if (hideStatusBar) {
runOnJS(setStatusBarHidden)(willShow, statusBarAnimation);
}
const normalizedToValue = interpolate(
toValue,
[0, drawerWidth],
[0, 1],
Extrapolation.CLAMP
);
const normalizedInitialVelocity = interpolate(
initialVelocity,
[0, drawerWidth],
[0, 1],
Extrapolation.CLAMP
);
openValue.value = withSpring(
normalizedToValue,
{
overshootClamping: true,
velocity: normalizedInitialVelocity,
mass: animationSpeed
? 1 / animationSpeed
: 1 / (animationSpeedProp ?? 1),
damping: 40,
stiffness: 500,
},
(finished) => {
if (finished) {
emitStateChanged(DrawerState.IDLE, willShow);
runOnJS(setDrawerOpened)(willShow);
runOnJS(setDrawerState)(DrawerState.IDLE);
if (willShow) {
onDrawerOpen && runOnJS(onDrawerOpen)?.();
} else {
onDrawerClose && runOnJS(onDrawerClose)?.();
}
}
}
);
},
[
openValue,
emitStateChanged,
isDrawerOpen,
hideStatusBar,
onDrawerClose,
onDrawerOpen,
drawerWidth,
statusBarAnimation,
]
);
const handleRelease = useCallback(
(event: GestureStateChangeEvent<PanGestureHandlerEventPayload>) => {
'worklet';
let { translationX: dragX, velocityX, x: touchX } = event;
if (drawerPosition !== DrawerPosition.LEFT) {
// See description in _updateAnimatedEvent about why events are flipped
// for right-side drawer
dragX = -dragX;
touchX = containerWidth - touchX;
velocityX = -velocityX;
}
const gestureStartX = touchX - dragX;
let dragOffsetBasedOnStart = 0;
if (drawerType === DrawerType.FRONT) {
dragOffsetBasedOnStart =
gestureStartX > drawerWidth ? gestureStartX - drawerWidth : 0;
}
const startOffsetX =
dragX +
dragOffsetBasedOnStart +
(isDrawerOpen.value ? drawerWidth : 0);
const projOffsetX = startOffsetX + DRAG_TOSS * velocityX;
const shouldOpen = projOffsetX > drawerWidth / 2;
if (shouldOpen) {
animateDrawer(drawerWidth, velocityX);
} else {
animateDrawer(0, velocityX);
}
},
[
animateDrawer,
containerWidth,
drawerPosition,
drawerType,
drawerWidth,
isDrawerOpen,
]
);
const openDrawer = useCallback(
(options: DrawerMovementOption = {}) => {
'worklet';
animateDrawer(
drawerWidth,
options.initialVelocity ?? 0,
options.animationSpeed
);
},
[animateDrawer, drawerWidth]
);
const closeDrawer = useCallback(
(options: DrawerMovementOption = {}) => {
'worklet';
animateDrawer(0, options.initialVelocity ?? 0, options.animationSpeed);
},
[animateDrawer]
);
const overlayDismissGesture = useMemo(
() =>
Gesture.Tap()
.maxDistance(25)
.onEnd(() => {
if (
isDrawerOpen.value &&
drawerLockMode !== DrawerLockMode.LOCKED_OPEN
) {
closeDrawer();
}
}),
[closeDrawer, isDrawerOpen, drawerLockMode]
);
const overlayAnimatedStyle = useAnimatedStyle(() => ({
opacity: openValue.value,
backgroundColor: overlayColor,
}));
const fillHitSlop = useMemo(
() => (isFromLeft ? { left: drawerWidth } : { right: drawerWidth }),
[drawerWidth, isFromLeft]
);
const panGesture = useMemo(() => {
return Gesture.Pan()
.activeCursor(activeCursor)
.mouseButton(mouseButton)
.hitSlop(drawerOpened ? fillHitSlop : edgeHitSlop)
.minDistance(drawerOpened ? 100 : 0)
.activeOffsetX(gestureOrientation * minSwipeDistance)
.failOffsetY([-15, 15])
.simultaneousWithExternalGesture(overlayDismissGesture)
.enableTrackpadTwoFingerGesture(enableTrackpadTwoFingerGesture)
.enabled(
drawerState !== DrawerState.SETTLING &&
(drawerOpened
? drawerLockMode !== DrawerLockMode.LOCKED_OPEN
: drawerLockMode !== DrawerLockMode.LOCKED_CLOSED)
)
.onStart(() => {
emitStateChanged(DrawerState.DRAGGING, false);
runOnJS(setDrawerState)(DrawerState.DRAGGING);
if (keyboardDismissMode === DrawerKeyboardDismissMode.ON_DRAG) {
runOnJS(dismissKeyboard)();
}
if (hideStatusBar) {
runOnJS(setStatusBarHidden)(true, statusBarAnimation);
}
})
.onUpdate((event) => {
const startedOutsideTranslation = isFromLeft
? interpolate(
event.x,
[0, drawerWidth, drawerWidth + 1],
[0, drawerWidth, drawerWidth]
)
: interpolate(
event.x - containerWidth,
[-drawerWidth - 1, -drawerWidth, 0],
[drawerWidth, drawerWidth, 0]
);
const startedInsideTranslation =
sideCorrection *
(event.translationX +
(drawerOpened ? drawerWidth * -gestureOrientation : 0));
const adjustedTranslation = Math.max(
drawerOpened ? startedOutsideTranslation : 0,
startedInsideTranslation
);
openValue.value = interpolate(
adjustedTranslation,
[-drawerWidth, 0, drawerWidth],
[1, 0, 1],
Extrapolation.CLAMP
);
})
.onEnd(handleRelease);
}, [
drawerLockMode,
openValue,
drawerWidth,
emitStateChanged,
gestureOrientation,
handleRelease,
edgeHitSlop,
fillHitSlop,
minSwipeDistance,
hideStatusBar,
keyboardDismissMode,
overlayDismissGesture,
drawerOpened,
isFromLeft,
containerWidth,
sideCorrection,
drawerState,
activeCursor,
enableTrackpadTwoFingerGesture,
mouseButton,
statusBarAnimation,
]);
// When using RTL, row and row-reverse flex directions are flipped.
const reverseContentDirection = I18nManager.isRTL
? isFromLeft
: !isFromLeft;
const dynamicDrawerStyles = {
backgroundColor: drawerBackgroundColor,
width: drawerWidth,
};
const containerStyles = useAnimatedStyle(() => {
if (drawerType === DrawerType.FRONT) {
return {};
}
return {
transform: [
{
translateX: interpolate(
openValue.value,
[0, 1],
[0, drawerWidth * sideCorrection],
Extrapolation.CLAMP
),
},
],
};
});
const drawerAnimatedStyle = useAnimatedStyle(() => {
const closedDrawerOffset = drawerWidth * -sideCorrection;
const isBack = drawerType === DrawerType.BACK;
const isIdle = drawerState === DrawerState.IDLE;
if (isBack) {
return {
transform: [{ translateX: 0 }],
flexDirection: reverseContentDirection ? 'row-reverse' : 'row',
};
}
let translateX = 0;
if (isIdle) {
translateX = drawerOpened ? 0 : closedDrawerOffset;
} else {
translateX = interpolate(
openValue.value,
[0, 1],
[closedDrawerOffset, 0],
Extrapolation.CLAMP
);
}
return {
transform: [{ translateX }],
flexDirection: reverseContentDirection ? 'row-reverse' : 'row',
};
});
const containerAnimatedProps = useAnimatedProps(() => ({
importantForAccessibility:
Platform.OS === 'android'
? isDrawerOpen.value
? ('no-hide-descendants' as const)
: ('yes' as const)
: undefined,
}));
const children =
typeof props.children === 'function'
? props.children(openValue) // renderer function
: props.children;
useImperativeHandle(
ref,
() => ({
openDrawer,
closeDrawer,
}),
[openDrawer, closeDrawer]
);
return (
<GestureDetector
gesture={panGesture}
userSelect={userSelect}
enableContextMenu={enableContextMenu}>
<Animated.View style={styles.main} onLayout={handleContainerLayout}>
<GestureDetector gesture={overlayDismissGesture}>
<Animated.View
style={[
drawerType === DrawerType.FRONT
? styles.containerOnBack
: styles.containerInFront,
containerStyles,
contentContainerStyle,
]}
animatedProps={containerAnimatedProps}>
{children}
<Animated.View
animatedProps={overlayAnimatedProps}
style={[styles.overlay, overlayAnimatedStyle]}
/>
</Animated.View>
</GestureDetector>
<Animated.View
pointerEvents="box-none"
animatedProps={drawerAnimatedProps}
style={[
styles.drawerContainer,
drawerAnimatedStyle,
drawerContainerStyle,
]}>
<Animated.View style={dynamicDrawerStyles}>
{renderNavigationView(openValue)}
</Animated.View>
</Animated.View>
</Animated.View>
</GestureDetector>
);
}
);
export default DrawerLayout;
const styles = StyleSheet.create({
drawerContainer: {
...StyleSheet.absoluteFillObject,
zIndex: 1001,
flexDirection: 'row',
},
containerInFront: {
...StyleSheet.absoluteFillObject,
zIndex: 1002,
},
containerOnBack: {
...StyleSheet.absoluteFillObject,
},
main: {
flex: 1,
zIndex: 0,
overflow: 'hidden',
},
overlay: {
...StyleSheet.absoluteFillObject,
zIndex: 1000,
},
});
@@ -0,0 +1,603 @@
import { useMemo, useCallback, useImperativeHandle, ForwardedRef } from 'react';
import { LayoutChangeEvent, View, I18nManager, StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
interpolate,
runOnJS,
ReduceMotion,
withSpring,
useAnimatedRef,
measure,
runOnUI,
useAnimatedStyle,
} from 'react-native-reanimated';
import { SwipeableProps, SwipeableMethods, SwipeDirection } from '.';
import { Gesture } from '../..';
import {
GestureStateChangeEvent,
GestureUpdateEvent,
} from '../../handlers/gestureHandlerCommon';
import { PanGestureHandlerEventPayload } from '../../handlers/GestureHandlerEventPayload';
import { GestureDetector } from '../../handlers/gestures/GestureDetector';
import {
applyRelationProp,
RelationPropName,
RelationPropType,
} from '../utils';
const DRAG_TOSS = 0.05;
const DEFAULT_FRICTION = 1;
const DEFAULT_OVERSHOOT_FRICTION = 1;
const DEFAULT_DRAG_OFFSET = 10;
const DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE = false;
const Swipeable = (props: SwipeableProps) => {
const {
ref,
leftThreshold,
rightThreshold,
enabled,
containerStyle,
childrenContainerStyle,
animationOptions,
overshootLeft,
overshootRight,
testID,
children,
enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,
dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,
dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,
friction = DEFAULT_FRICTION,
overshootFriction = DEFAULT_OVERSHOOT_FRICTION,
onSwipeableOpenStartDrag,
onSwipeableCloseStartDrag,
onSwipeableWillOpen,
onSwipeableWillClose,
onSwipeableOpen,
onSwipeableClose,
renderLeftActions,
renderRightActions,
simultaneousWithExternalGesture,
requireExternalGestureToFail,
blocksExternalGesture,
hitSlop,
...remainingProps
} = props;
const relationProps = useMemo(
() => ({
simultaneousWithExternalGesture,
requireExternalGestureToFail,
blocksExternalGesture,
}),
[
blocksExternalGesture,
requireExternalGestureToFail,
simultaneousWithExternalGesture,
]
);
const rowState = useSharedValue<number>(0);
const userDrag = useSharedValue<number>(0);
const appliedTranslation = useSharedValue<number>(0);
const rowWidth = useSharedValue<number>(0);
const leftWidth = useSharedValue<number>(0);
const rightWidth = useSharedValue<number>(0);
const showLeftProgress = useSharedValue<number>(0);
const showRightProgress = useSharedValue<number>(0);
const updateAnimatedEvent = useCallback(() => {
'worklet';
const shouldOvershootLeft = overshootLeft ?? leftWidth.value > 0;
const shouldOvershootRight = overshootRight ?? rightWidth.value > 0;
const startOffset =
rowState.value === 1
? leftWidth.value
: rowState.value === -1
? -rightWidth.value
: 0;
const offsetDrag = userDrag.value / friction + startOffset;
appliedTranslation.value = interpolate(
offsetDrag,
[
-rightWidth.value - 1,
-rightWidth.value,
leftWidth.value,
leftWidth.value + 1,
],
[
-rightWidth.value - (shouldOvershootRight ? 1 / overshootFriction : 0),
-rightWidth.value,
leftWidth.value,
leftWidth.value + (shouldOvershootLeft ? 1 / overshootFriction : 0),
]
);
showLeftProgress.value =
leftWidth.value > 0
? interpolate(
appliedTranslation.value,
[-1, 0, leftWidth.value],
[0, 0, 1]
)
: 0;
showRightProgress.value =
rightWidth.value > 0
? interpolate(
appliedTranslation.value,
[-rightWidth.value, 0, 1],
[1, 0, 0]
)
: 0;
}, [
appliedTranslation,
friction,
leftWidth,
overshootFriction,
rightWidth,
rowState,
showLeftProgress,
showRightProgress,
userDrag,
overshootLeft,
overshootRight,
]);
const dispatchImmediateEvents = useCallback(
(fromValue: number, toValue: number) => {
'worklet';
if (onSwipeableWillOpen && toValue !== 0) {
runOnJS(onSwipeableWillOpen)(
toValue > 0 ? SwipeDirection.RIGHT : SwipeDirection.LEFT
);
}
if (onSwipeableWillClose && toValue === 0) {
runOnJS(onSwipeableWillClose)(
fromValue > 0 ? SwipeDirection.LEFT : SwipeDirection.RIGHT
);
}
},
[onSwipeableWillClose, onSwipeableWillOpen]
);
const dispatchEndEvents = useCallback(
(fromValue: number, toValue: number) => {
'worklet';
if (onSwipeableOpen && toValue !== 0) {
runOnJS(onSwipeableOpen)(
toValue > 0 ? SwipeDirection.RIGHT : SwipeDirection.LEFT
);
}
if (onSwipeableClose && toValue === 0) {
runOnJS(onSwipeableClose)(
fromValue > 0 ? SwipeDirection.LEFT : SwipeDirection.RIGHT
);
}
},
[onSwipeableClose, onSwipeableOpen]
);
const animateRow: (toValue: number, velocityX?: number) => void = useCallback(
(toValue: number, velocityX?: number) => {
'worklet';
const translationSpringConfig = {
mass: 2,
damping: 1000,
stiffness: 700,
velocity: velocityX,
overshootClamping: true,
reduceMotion: ReduceMotion.System,
...animationOptions,
};
const isClosing = toValue === 0;
const moveToRight = isClosing ? rowState.value < 0 : toValue > 0;
const usedWidth = isClosing
? moveToRight
? rightWidth.value
: leftWidth.value
: moveToRight
? leftWidth.value
: rightWidth.value;
const progressSpringConfig = {
...translationSpringConfig,
restDisplacementThreshold: 0.01,
restSpeedThreshold: 0.01,
velocity:
velocityX && interpolate(velocityX, [-usedWidth, usedWidth], [-1, 1]),
};
const frozenRowState = rowState.value;
appliedTranslation.value = withSpring(
toValue,
translationSpringConfig,
(isFinished) => {
if (isFinished) {
dispatchEndEvents(frozenRowState, toValue);
}
}
);
const progressTarget = toValue === 0 ? 0 : 1 * Math.sign(toValue);
showLeftProgress.value = withSpring(
Math.max(progressTarget, 0),
progressSpringConfig
);
showRightProgress.value = withSpring(
Math.max(-progressTarget, 0),
progressSpringConfig
);
dispatchImmediateEvents(frozenRowState, toValue);
rowState.value = Math.sign(toValue);
},
[
rowState,
animationOptions,
appliedTranslation,
showLeftProgress,
leftWidth,
showRightProgress,
rightWidth,
dispatchImmediateEvents,
dispatchEndEvents,
]
);
const leftLayoutRef = useAnimatedRef();
const leftWrapperLayoutRef = useAnimatedRef();
const rightLayoutRef = useAnimatedRef();
const updateElementWidths = useCallback(() => {
'worklet';
const leftLayout = measure(leftLayoutRef);
const leftWrapperLayout = measure(leftWrapperLayoutRef);
const rightLayout = measure(rightLayoutRef);
leftWidth.value =
(leftLayout?.pageX ?? 0) - (leftWrapperLayout?.pageX ?? 0);
rightWidth.value =
rowWidth.value -
(rightLayout?.pageX ?? rowWidth.value) +
(leftWrapperLayout?.pageX ?? 0);
}, [
leftLayoutRef,
leftWrapperLayoutRef,
rightLayoutRef,
leftWidth,
rightWidth,
rowWidth,
]);
const swipeableMethods = useMemo<SwipeableMethods>(
() => ({
close() {
'worklet';
if (_WORKLET) {
animateRow(0);
return;
}
runOnUI(() => {
animateRow(0);
})();
},
openLeft() {
'worklet';
if (_WORKLET) {
updateElementWidths();
animateRow(leftWidth.value);
return;
}
runOnUI(() => {
updateElementWidths();
animateRow(leftWidth.value);
})();
},
openRight() {
'worklet';
if (_WORKLET) {
updateElementWidths();
animateRow(-rightWidth.value);
return;
}
runOnUI(() => {
updateElementWidths();
animateRow(-rightWidth.value);
})();
},
reset() {
'worklet';
userDrag.value = 0;
showLeftProgress.value = 0;
appliedTranslation.value = 0;
rowState.value = 0;
},
}),
[
animateRow,
updateElementWidths,
leftWidth,
rightWidth,
userDrag,
showLeftProgress,
appliedTranslation,
rowState,
]
);
const onRowLayout = useCallback(
({ nativeEvent }: LayoutChangeEvent) => {
rowWidth.value = nativeEvent.layout.width;
},
[rowWidth]
);
// As stated in `Dimensions.get` docstring, this function should be called on every render
// since dimensions may change (e.g. orientation change)
const leftActionAnimation = useAnimatedStyle(() => {
return {
opacity: showLeftProgress.value === 0 ? 0 : 1,
};
});
const leftElement = useCallback(
() => (
<Animated.View
ref={leftWrapperLayoutRef}
style={[styles.leftActions, leftActionAnimation]}>
{renderLeftActions?.(
showLeftProgress,
appliedTranslation,
swipeableMethods
)}
<Animated.View ref={leftLayoutRef} />
</Animated.View>
),
[
appliedTranslation,
leftActionAnimation,
leftLayoutRef,
leftWrapperLayoutRef,
renderLeftActions,
showLeftProgress,
swipeableMethods,
]
);
const rightActionAnimation = useAnimatedStyle(() => {
return {
opacity: showRightProgress.value === 0 ? 0 : 1,
};
});
const rightElement = useCallback(
() => (
<Animated.View style={[styles.rightActions, rightActionAnimation]}>
{renderRightActions?.(
showRightProgress,
appliedTranslation,
swipeableMethods
)}
<Animated.View ref={rightLayoutRef} />
</Animated.View>
),
[
appliedTranslation,
renderRightActions,
rightActionAnimation,
rightLayoutRef,
showRightProgress,
swipeableMethods,
]
);
const handleRelease = useCallback(
(event: GestureStateChangeEvent<PanGestureHandlerEventPayload>) => {
'worklet';
const { velocityX } = event;
userDrag.value = event.translationX;
const leftThresholdProp = leftThreshold ?? leftWidth.value / 2;
const rightThresholdProp = rightThreshold ?? rightWidth.value / 2;
const translationX = (userDrag.value + DRAG_TOSS * velocityX) / friction;
let toValue = 0;
if (rowState.value === 0) {
if (translationX > leftThresholdProp) {
toValue = leftWidth.value;
} else if (translationX < -rightThresholdProp) {
toValue = -rightWidth.value;
}
} else if (rowState.value === 1) {
// Swiped to left
if (translationX > -leftThresholdProp) {
toValue = leftWidth.value;
}
} else {
// Swiped to right
if (translationX < rightThresholdProp) {
toValue = -rightWidth.value;
}
}
animateRow(toValue, velocityX / friction);
},
[
animateRow,
friction,
leftThreshold,
leftWidth,
rightThreshold,
rightWidth,
rowState,
userDrag,
]
);
const close = useCallback(() => {
'worklet';
animateRow(0);
}, [animateRow]);
const dragStarted = useSharedValue<boolean>(false);
const tapGesture = useMemo(() => {
const tap = Gesture.Tap()
.shouldCancelWhenOutside(true)
.onStart(() => {
if (rowState.value !== 0) {
close();
}
});
Object.entries(relationProps).forEach(([relationName, relation]) => {
applyRelationProp(
tap,
relationName as RelationPropName,
relation as RelationPropType
);
});
return tap;
}, [close, relationProps, rowState]);
const panGesture = useMemo(() => {
const pan = Gesture.Pan()
.enabled(enabled !== false)
.enableTrackpadTwoFingerGesture(enableTrackpadTwoFingerGesture)
.activeOffsetX([-dragOffsetFromRightEdge, dragOffsetFromLeftEdge])
.onStart(updateElementWidths)
.onUpdate((event: GestureUpdateEvent<PanGestureHandlerEventPayload>) => {
userDrag.value = event.translationX;
const direction =
rowState.value === -1
? SwipeDirection.RIGHT
: rowState.value === 1
? SwipeDirection.LEFT
: event.translationX > 0
? SwipeDirection.RIGHT
: SwipeDirection.LEFT;
if (!dragStarted.value) {
dragStarted.value = true;
if (rowState.value === 0 && onSwipeableOpenStartDrag) {
runOnJS(onSwipeableOpenStartDrag)(direction);
} else if (onSwipeableCloseStartDrag) {
runOnJS(onSwipeableCloseStartDrag)(direction);
}
}
updateAnimatedEvent();
})
.onEnd(
(event: GestureStateChangeEvent<PanGestureHandlerEventPayload>) => {
handleRelease(event);
}
)
.onFinalize(() => {
dragStarted.value = false;
});
Object.entries(relationProps).forEach(([relationName, relation]) => {
applyRelationProp(
pan,
relationName as RelationPropName,
relation as RelationPropType
);
});
return pan;
}, [
enabled,
enableTrackpadTwoFingerGesture,
dragOffsetFromRightEdge,
dragOffsetFromLeftEdge,
updateElementWidths,
relationProps,
userDrag,
rowState,
dragStarted,
updateAnimatedEvent,
onSwipeableOpenStartDrag,
onSwipeableCloseStartDrag,
handleRelease,
]);
useImperativeHandle(ref, () => swipeableMethods, [swipeableMethods]);
const animatedStyle = useAnimatedStyle(
() => ({
transform: [{ translateX: appliedTranslation.value }],
pointerEvents: rowState.value === 0 ? 'auto' : 'box-only',
}),
[appliedTranslation, rowState]
);
const swipeableComponent = (
<GestureDetector gesture={panGesture} touchAction="pan-y">
<Animated.View
{...remainingProps}
onLayout={onRowLayout}
hitSlop={hitSlop ?? undefined}
style={[styles.container, containerStyle]}>
{leftElement()}
{rightElement()}
<GestureDetector gesture={tapGesture} touchAction="pan-y">
<Animated.View style={[animatedStyle, childrenContainerStyle]}>
{children}
</Animated.View>
</GestureDetector>
</Animated.View>
</GestureDetector>
);
return testID ? (
<View testID={testID}>{swipeableComponent}</View>
) : (
swipeableComponent
);
};
export default Swipeable;
export type SwipeableRef = ForwardedRef<SwipeableMethods>;
const styles = StyleSheet.create({
container: {
overflow: 'hidden',
},
leftActions: {
...StyleSheet.absoluteFillObject,
flexDirection: I18nManager.isRTL ? 'row-reverse' : 'row',
overflow: 'hidden',
},
rightActions: {
...StyleSheet.absoluteFillObject,
flexDirection: I18nManager.isRTL ? 'row' : 'row-reverse',
overflow: 'hidden',
},
});
@@ -0,0 +1,199 @@
import React from 'react';
import type { PanGestureHandlerProps } from '../../handlers/PanGestureHandler';
import { SharedValue } from 'react-native-reanimated';
import { StyleProp, ViewStyle } from 'react-native';
import { RelationPropType } from '../utils';
type SwipeableExcludes = Exclude<
keyof PanGestureHandlerProps,
'onGestureEvent' | 'onHandlerStateChange'
>;
export enum SwipeDirection {
LEFT = 'left',
RIGHT = 'right',
}
export interface SwipeableProps
extends Pick<PanGestureHandlerProps, SwipeableExcludes> {
/**
*
*/
ref?: React.RefObject<SwipeableMethods | null>;
/**
* Enables two-finger gestures on supported devices, for example iPads with
* trackpads. If not enabled the gesture will require click + drag, with
* `enableTrackpadTwoFingerGesture` swiping with two fingers will also trigger
* the gesture.
*/
enableTrackpadTwoFingerGesture?: boolean;
/**
* Specifies how much the visual interaction will be delayed compared to the
* gesture distance. e.g. value of 1 will indicate that the swipeable panel
* should exactly follow the gesture, 2 means it is going to be two times
* "slower".
*/
friction?: number;
/**
* Distance from the left edge at which released panel will animate to the
* open state (or the open panel will animate into the closed state). By
* default it's a half of the panel's width.
*/
leftThreshold?: number;
/**
* Distance from the right edge at which released panel will animate to the
* open state (or the open panel will animate into the closed state). By
* default it's a half of the panel's width.
*/
rightThreshold?: number;
/**
* Distance that the panel must be dragged from the left edge to be considered
* a swipe. The default value is 10.
*/
dragOffsetFromLeftEdge?: number;
/**
* Distance that the panel must be dragged from the right edge to be considered
* a swipe. The default value is 10.
*/
dragOffsetFromRightEdge?: number;
/**
* Value indicating if the swipeable panel can be pulled further than the left
* actions panel's width. It is set to true by default as long as the left
* panel render method is present.
*/
overshootLeft?: boolean;
/**
* Value indicating if the swipeable panel can be pulled further than the
* right actions panel's width. It is set to true by default as long as the
* right panel render method is present.
*/
overshootRight?: boolean;
/**
* Specifies how much the visual interaction will be delayed compared to the
* gesture distance at overshoot. Default value is 1, it mean no friction, for
* a native feel, try 8 or above.
*/
overshootFriction?: number;
/**
* Called when action panel gets open (either right or left).
*/
onSwipeableOpen?: (
direction: SwipeDirection.LEFT | SwipeDirection.RIGHT
) => void;
/**
* Called when action panel is closed.
*/
onSwipeableClose?: (
direction: SwipeDirection.LEFT | SwipeDirection.RIGHT
) => void;
/**
* Called when action panel starts animating on open (either right or left).
*/
onSwipeableWillOpen?: (
direction: SwipeDirection.LEFT | SwipeDirection.RIGHT
) => void;
/**
* Called when action panel starts animating on close.
*/
onSwipeableWillClose?: (
direction: SwipeDirection.LEFT | SwipeDirection.RIGHT
) => void;
/**
* Called when action panel starts being shown on dragging to open.
*/
onSwipeableOpenStartDrag?: (
direction: SwipeDirection.LEFT | SwipeDirection.RIGHT
) => void;
/**
* Called when action panel starts being shown on dragging to close.
*/
onSwipeableCloseStartDrag?: (
direction: SwipeDirection.LEFT | SwipeDirection.RIGHT
) => void;
/**
* `progress`: Equals `0` when `swipeable` is closed, `1` when `swipeable` is opened.
* - When the element overshoots it's opened position the value tends towards `Infinity`.
* - Goes back to `1` when `swipeable` is released.
*
* `translation`: a horizontal offset of the `swipeable` relative to its closed position.\
* `swipeableMethods`: provides an object exposing methods for controlling the `swipeable`.
*
* To support `rtl` flexbox layouts use `flexDirection` styling.
* */
renderLeftActions?: (
progress: SharedValue<number>,
translation: SharedValue<number>,
swipeableMethods: SwipeableMethods
) => React.ReactNode;
/**
* `progress`: Equals `0` when `swipeable` is closed, `1` when `swipeable` is opened.
* - When the element overshoots it's opened position the value tends towards `Infinity`.
* - Goes back to `1` when `swipeable` is released.
*
* `translation`: a horizontal offset of the `swipeable` relative to its closed position.\
* `swipeableMethods`: provides an object exposing methods for controlling the `swipeable`.
*
* To support `rtl` flexbox layouts use `flexDirection` styling.
* */
renderRightActions?: (
progress: SharedValue<number>,
translation: SharedValue<number>,
swipeableMethods: SwipeableMethods
) => React.ReactNode;
animationOptions?: Record<string, unknown>;
/**
* Style object for the container (`Animated.View`), for example to override
* `overflow: 'hidden'`.
*/
containerStyle?: StyleProp<ViewStyle>;
/**
* Style object for the children container (`Animated.View`), for example to
* apply `flex: 1`
*/
childrenContainerStyle?: StyleProp<ViewStyle>;
/**
* A gesture object or an array of gesture objects containing the configuration and callbacks to be
* used with the swipeable's gesture handler.
*/
simultaneousWithExternalGesture?: RelationPropType;
/**
* A gesture object or an array of gesture objects containing the configuration and callbacks to be
* used with the swipeable's gesture handler.
*/
requireExternalGestureToFail?: RelationPropType;
/**
* A gesture object or an array of gesture objects containing the configuration and callbacks to be
* used with the swipeable's gesture handler.
*/
blocksExternalGesture?: RelationPropType;
}
export interface SwipeableMethods {
close: () => void;
openLeft: () => void;
openRight: () => void;
reset: () => void;
}
@@ -0,0 +1,6 @@
export {
type SwipeableProps,
type SwipeableMethods,
SwipeDirection,
} from './ReanimatedSwipeableProps';
export { default } from './ReanimatedSwipeable';
@@ -0,0 +1,595 @@
// Similarily to the DrawerLayout component this deserves to be put in a
// separate repo. Although, keeping it here for the time being will allow us to
// move faster and fix possible issues quicker
import * as React from 'react';
import { Component } from 'react';
import {
Animated,
StyleSheet,
View,
I18nManager,
LayoutChangeEvent,
StyleProp,
ViewStyle,
} from 'react-native';
import {
GestureEvent,
HandlerStateChangeEvent,
} from '../handlers/gestureHandlerCommon';
import {
PanGestureHandler,
PanGestureHandlerProps,
} from '../handlers/PanGestureHandler';
import {
PanGestureHandlerEventPayload,
TapGestureHandlerEventPayload,
} from '../handlers/GestureHandlerEventPayload';
import { TapGestureHandler } from '../handlers/TapGestureHandler';
import { State } from '../State';
const DRAG_TOSS = 0.05;
type SwipeableExcludes = Exclude<
keyof PanGestureHandlerProps,
'onGestureEvent' | 'onHandlerStateChange'
>;
// Animated.AnimatedInterpolation has been converted to a generic type
// in @types/react-native 0.70. This way we can maintain compatibility
// with all versions of @types/react-native
type AnimatedInterpolation = ReturnType<Animated.Value['interpolate']>;
export interface SwipeableProps
extends Pick<PanGestureHandlerProps, SwipeableExcludes> {
/**
* Enables two-finger gestures on supported devices, for example iPads with
* trackpads. If not enabled the gesture will require click + drag, with
* `enableTrackpadTwoFingerGesture` swiping with two fingers will also trigger
* the gesture.
*/
enableTrackpadTwoFingerGesture?: boolean;
/**
* Specifies how much the visual interaction will be delayed compared to the
* gesture distance. e.g. value of 1 will indicate that the swipeable panel
* should exactly follow the gesture, 2 means it is going to be two times
* "slower".
*/
friction?: number;
/**
* Distance from the left edge at which released panel will animate to the
* open state (or the open panel will animate into the closed state). By
* default it's a half of the panel's width.
*/
leftThreshold?: number;
/**
* Distance from the right edge at which released panel will animate to the
* open state (or the open panel will animate into the closed state). By
* default it's a half of the panel's width.
*/
rightThreshold?: number;
/**
* Distance that the panel must be dragged from the left edge to be considered
* a swipe. The default value is 10.
*/
dragOffsetFromLeftEdge?: number;
/**
* Distance that the panel must be dragged from the right edge to be considered
* a swipe. The default value is 10.
*/
dragOffsetFromRightEdge?: number;
/**
* Value indicating if the swipeable panel can be pulled further than the left
* actions panel's width. It is set to true by default as long as the left
* panel render method is present.
*/
overshootLeft?: boolean;
/**
* Value indicating if the swipeable panel can be pulled further than the
* right actions panel's width. It is set to true by default as long as the
* right panel render method is present.
*/
overshootRight?: boolean;
/**
* Specifies how much the visual interaction will be delayed compared to the
* gesture distance at overshoot. Default value is 1, it mean no friction, for
* a native feel, try 8 or above.
*/
overshootFriction?: number;
/**
* @deprecated Use `direction` argument of onSwipeableOpen()
*
* Called when left action panel gets open.
*/
onSwipeableLeftOpen?: () => void;
/**
* @deprecated Use `direction` argument of onSwipeableOpen()
*
* Called when right action panel gets open.
*/
onSwipeableRightOpen?: () => void;
/**
* Called when action panel gets open (either right or left).
*/
onSwipeableOpen?: (direction: 'left' | 'right', swipeable: Swipeable) => void;
/**
* Called when action panel is closed.
*/
onSwipeableClose?: (
direction: 'left' | 'right',
swipeable: Swipeable
) => void;
/**
* @deprecated Use `direction` argument of onSwipeableWillOpen()
*
* Called when left action panel starts animating on open.
*/
onSwipeableLeftWillOpen?: () => void;
/**
* @deprecated Use `direction` argument of onSwipeableWillOpen()
*
* Called when right action panel starts animating on open.
*/
onSwipeableRightWillOpen?: () => void;
/**
* Called when action panel starts animating on open (either right or left).
*/
onSwipeableWillOpen?: (direction: 'left' | 'right') => void;
/**
* Called when action panel starts animating on close.
*/
onSwipeableWillClose?: (direction: 'left' | 'right') => void;
/**
* Called when action panel starts being shown on dragging to open.
*/
onSwipeableOpenStartDrag?: (direction: 'left' | 'right') => void;
/**
* Called when action panel starts being shown on dragging to close.
*/
onSwipeableCloseStartDrag?: (direction: 'left' | 'right') => void;
/**
*
* This map describes the values to use as inputRange for extra interpolation:
* AnimatedValue: [startValue, endValue]
*
* progressAnimatedValue: [0, 1] dragAnimatedValue: [0, +]
*
* To support `rtl` flexbox layouts use `flexDirection` styling.
* */
renderLeftActions?: (
progressAnimatedValue: AnimatedInterpolation,
dragAnimatedValue: AnimatedInterpolation,
swipeable: Swipeable
) => React.ReactNode;
/**
*
* This map describes the values to use as inputRange for extra interpolation:
* AnimatedValue: [startValue, endValue]
*
* progressAnimatedValue: [0, 1] dragAnimatedValue: [0, -]
*
* To support `rtl` flexbox layouts use `flexDirection` styling.
* */
renderRightActions?: (
progressAnimatedValue: AnimatedInterpolation,
dragAnimatedValue: AnimatedInterpolation,
swipeable: Swipeable
) => React.ReactNode;
useNativeAnimations?: boolean;
animationOptions?: Record<string, unknown>;
/**
* Style object for the container (`Animated.View`), for example to override
* `overflow: 'hidden'`.
*/
containerStyle?: StyleProp<ViewStyle>;
/**
* Style object for the children container (`Animated.View`), for example to
* apply `flex: 1`
*/
childrenContainerStyle?: StyleProp<ViewStyle>;
}
type SwipeableState = {
dragX: Animated.Value;
rowTranslation: Animated.Value;
rowState: number;
leftWidth?: number;
rightOffset?: number;
rowWidth?: number;
};
/**
* @deprecated use Reanimated version of Swipeable instead
*
* This component allows for implementing swipeable rows or similar interaction.
*/
export default class Swipeable extends Component<
SwipeableProps,
SwipeableState
> {
static defaultProps = {
friction: 1,
overshootFriction: 1,
useNativeAnimations: true,
};
constructor(props: SwipeableProps) {
super(props);
const dragX = new Animated.Value(0);
this.state = {
dragX,
rowTranslation: new Animated.Value(0),
rowState: 0,
leftWidth: undefined,
rightOffset: undefined,
rowWidth: undefined,
};
this.updateAnimatedEvent(props, this.state);
this.onGestureEvent = Animated.event(
[{ nativeEvent: { translationX: dragX } }],
{ useNativeDriver: props.useNativeAnimations! }
);
}
shouldComponentUpdate(props: SwipeableProps, state: SwipeableState) {
if (
this.props.friction !== props.friction ||
this.props.overshootLeft !== props.overshootLeft ||
this.props.overshootRight !== props.overshootRight ||
this.props.overshootFriction !== props.overshootFriction ||
this.state.leftWidth !== state.leftWidth ||
this.state.rightOffset !== state.rightOffset ||
this.state.rowWidth !== state.rowWidth
) {
this.updateAnimatedEvent(props, state);
}
return true;
}
private onGestureEvent?: (
event: GestureEvent<PanGestureHandlerEventPayload>
) => void;
private transX?: AnimatedInterpolation;
private showLeftAction?: AnimatedInterpolation | Animated.Value;
private leftActionTranslate?: AnimatedInterpolation;
private showRightAction?: AnimatedInterpolation | Animated.Value;
private rightActionTranslate?: AnimatedInterpolation;
private updateAnimatedEvent = (
props: SwipeableProps,
state: SwipeableState
) => {
const { friction, overshootFriction } = props;
const { dragX, rowTranslation, leftWidth = 0, rowWidth = 0 } = state;
const { rightOffset = rowWidth } = state;
const rightWidth = Math.max(0, rowWidth - rightOffset);
const { overshootLeft = leftWidth > 0, overshootRight = rightWidth > 0 } =
props;
const transX = Animated.add(
rowTranslation,
dragX.interpolate({
inputRange: [0, friction!],
outputRange: [0, 1],
})
).interpolate({
inputRange: [-rightWidth - 1, -rightWidth, leftWidth, leftWidth + 1],
outputRange: [
-rightWidth - (overshootRight ? 1 / overshootFriction! : 0),
-rightWidth,
leftWidth,
leftWidth + (overshootLeft ? 1 / overshootFriction! : 0),
],
});
this.transX = transX;
this.showLeftAction =
leftWidth > 0
? transX.interpolate({
inputRange: [-1, 0, leftWidth],
outputRange: [0, 0, 1],
})
: new Animated.Value(0);
this.leftActionTranslate = this.showLeftAction.interpolate({
inputRange: [0, Number.MIN_VALUE],
outputRange: [-10000, 0],
extrapolate: 'clamp',
});
this.showRightAction =
rightWidth > 0
? transX.interpolate({
inputRange: [-rightWidth, 0, 1],
outputRange: [1, 0, 0],
})
: new Animated.Value(0);
this.rightActionTranslate = this.showRightAction.interpolate({
inputRange: [0, Number.MIN_VALUE],
outputRange: [-10000, 0],
extrapolate: 'clamp',
});
};
private onTapHandlerStateChange = ({
nativeEvent,
}: HandlerStateChangeEvent<TapGestureHandlerEventPayload>) => {
if (nativeEvent.oldState === State.ACTIVE) {
this.close();
}
};
private onHandlerStateChange = (
ev: HandlerStateChangeEvent<PanGestureHandlerEventPayload>
) => {
if (ev.nativeEvent.oldState === State.ACTIVE) {
this.handleRelease(ev);
}
if (ev.nativeEvent.state === State.ACTIVE) {
const { velocityX, translationX: dragX } = ev.nativeEvent;
const { rowState } = this.state;
const { friction } = this.props;
const translationX = (dragX + DRAG_TOSS * velocityX) / friction!;
const direction =
rowState === -1
? 'right'
: rowState === 1
? 'left'
: translationX > 0
? 'left'
: 'right';
if (rowState === 0) {
this.props.onSwipeableOpenStartDrag?.(direction);
} else {
this.props.onSwipeableCloseStartDrag?.(direction);
}
}
};
private handleRelease = (
ev: HandlerStateChangeEvent<PanGestureHandlerEventPayload>
) => {
const { velocityX, translationX: dragX } = ev.nativeEvent;
const { leftWidth = 0, rowWidth = 0, rowState } = this.state;
const { rightOffset = rowWidth } = this.state;
const rightWidth = rowWidth - rightOffset;
const {
friction,
leftThreshold = leftWidth / 2,
rightThreshold = rightWidth / 2,
} = this.props;
const startOffsetX = this.currentOffset() + dragX / friction!;
const translationX = (dragX + DRAG_TOSS * velocityX) / friction!;
let toValue = 0;
if (rowState === 0) {
if (translationX > leftThreshold) {
toValue = leftWidth;
} else if (translationX < -rightThreshold) {
toValue = -rightWidth;
}
} else if (rowState === 1) {
// Swiped to left
if (translationX > -leftThreshold) {
toValue = leftWidth;
}
} else {
// Swiped to right
if (translationX < rightThreshold) {
toValue = -rightWidth;
}
}
this.animateRow(startOffsetX, toValue, velocityX / friction!);
};
private animateRow = (
fromValue: number,
toValue: number,
velocityX?:
| number
| {
x: number;
y: number;
}
) => {
const { dragX, rowTranslation } = this.state;
dragX.setValue(0);
rowTranslation.setValue(fromValue);
this.setState({ rowState: Math.sign(toValue) });
Animated.spring(rowTranslation, {
restSpeedThreshold: 1.7,
restDisplacementThreshold: 0.4,
velocity: velocityX,
bounciness: 0,
toValue,
useNativeDriver: this.props.useNativeAnimations!,
...this.props.animationOptions,
}).start(({ finished }) => {
if (finished) {
if (toValue > 0) {
this.props.onSwipeableLeftOpen?.();
this.props.onSwipeableOpen?.('left', this);
} else if (toValue < 0) {
this.props.onSwipeableRightOpen?.();
this.props.onSwipeableOpen?.('right', this);
} else {
const closingDirection = fromValue > 0 ? 'left' : 'right';
this.props.onSwipeableClose?.(closingDirection, this);
}
}
});
if (toValue > 0) {
this.props.onSwipeableLeftWillOpen?.();
this.props.onSwipeableWillOpen?.('left');
} else if (toValue < 0) {
this.props.onSwipeableRightWillOpen?.();
this.props.onSwipeableWillOpen?.('right');
} else {
const closingDirection = fromValue > 0 ? 'left' : 'right';
this.props.onSwipeableWillClose?.(closingDirection);
}
};
private onRowLayout = ({ nativeEvent }: LayoutChangeEvent) => {
this.setState({ rowWidth: nativeEvent.layout.width });
};
private currentOffset = () => {
const { leftWidth = 0, rowWidth = 0, rowState } = this.state;
const { rightOffset = rowWidth } = this.state;
const rightWidth = rowWidth - rightOffset;
if (rowState === 1) {
return leftWidth;
} else if (rowState === -1) {
return -rightWidth;
}
return 0;
};
close = () => {
this.animateRow(this.currentOffset(), 0);
};
// eslint-disable-next-line @eslint-react/no-unused-class-component-members
openLeft = () => {
const { leftWidth = 0 } = this.state;
this.animateRow(this.currentOffset(), leftWidth);
};
// eslint-disable-next-line @eslint-react/no-unused-class-component-members
openRight = () => {
const { rowWidth = 0 } = this.state;
const { rightOffset = rowWidth } = this.state;
const rightWidth = rowWidth - rightOffset;
this.animateRow(this.currentOffset(), -rightWidth);
};
// eslint-disable-next-line @eslint-react/no-unused-class-component-members
reset = () => {
const { dragX, rowTranslation } = this.state;
dragX.setValue(0);
rowTranslation.setValue(0);
this.setState({ rowState: 0 });
};
render() {
const { rowState } = this.state;
const {
children,
renderLeftActions,
renderRightActions,
dragOffsetFromLeftEdge = 10,
dragOffsetFromRightEdge = 10,
} = this.props;
const left = renderLeftActions && (
<Animated.View
style={[
styles.leftActions,
// All those and below parameters can have ! since they are all
// asigned in constructor in `updateAnimatedEvent` but TS cannot spot
// it for some reason
{ transform: [{ translateX: this.leftActionTranslate! }] },
]}>
{renderLeftActions(this.showLeftAction!, this.transX!, this)}
<View
onLayout={({ nativeEvent }) =>
this.setState({ leftWidth: nativeEvent.layout.x })
}
/>
</Animated.View>
);
const right = renderRightActions && (
<Animated.View
style={[
styles.rightActions,
{ transform: [{ translateX: this.rightActionTranslate! }] },
]}>
{renderRightActions(this.showRightAction!, this.transX!, this)}
<View
onLayout={({ nativeEvent }) =>
this.setState({ rightOffset: nativeEvent.layout.x })
}
/>
</Animated.View>
);
return (
<PanGestureHandler
activeOffsetX={[-dragOffsetFromRightEdge, dragOffsetFromLeftEdge]}
touchAction="pan-y"
{...this.props}
onGestureEvent={this.onGestureEvent}
onHandlerStateChange={this.onHandlerStateChange}>
<Animated.View
onLayout={this.onRowLayout}
style={[styles.container, this.props.containerStyle]}>
{left}
{right}
<TapGestureHandler
enabled={rowState !== 0}
touchAction="pan-y"
onHandlerStateChange={this.onTapHandlerStateChange}>
<Animated.View
pointerEvents={rowState === 0 ? 'auto' : 'box-only'}
style={[
{
transform: [{ translateX: this.transX! }],
},
this.props.childrenContainerStyle,
]}>
{children}
</Animated.View>
</TapGestureHandler>
</Animated.View>
</PanGestureHandler>
);
}
}
const styles = StyleSheet.create({
container: {
overflow: 'hidden',
},
leftActions: {
...StyleSheet.absoluteFillObject,
flexDirection: I18nManager.isRTL ? 'row-reverse' : 'row',
},
rightActions: {
...StyleSheet.absoluteFillObject,
flexDirection: I18nManager.isRTL ? 'row' : 'row-reverse',
},
});
@@ -0,0 +1,77 @@
import React, {
ForwardedRef,
forwardRef,
RefObject,
useEffect,
useRef,
} from 'react';
import {
Platform,
Text as RNText,
TextProps as RNTextProps,
} from 'react-native';
import { GestureObjects as Gesture } from '../handlers/gestures/gestureObjects';
import { GestureDetector } from '../handlers/gestures/GestureDetector';
export const Text = forwardRef(
(
props: RNTextProps,
ref: ForwardedRef<React.ComponentRef<typeof RNText>>
) => {
const { onPress, onLongPress, ...rest } = props;
const textRef = useRef<RNText | null>(null);
const native = Gesture.Native().runOnJS(true);
const refHandler = (node: any) => {
textRef.current = node;
if (ref === null) {
return;
}
if (typeof ref === 'function') {
ref(node);
} else {
ref.current = node;
}
};
// This is a special case for `Text` component. After https://github.com/software-mansion/react-native-gesture-handler/pull/3379 we check for
// `displayName` field. However, `Text` from RN has this field set to `Text`, but is also present in `RNSVGElements` set.
// We don't want to treat our `Text` as the one from `SVG`, therefore we add special field to ref.
refHandler.rngh = true;
useEffect(() => {
if (Platform.OS !== 'web') {
return;
}
const textElement = ref
? (ref as RefObject<React.ComponentRef<typeof RNText>>).current
: textRef.current;
// At this point we are sure that textElement is div in HTML tree
(textElement as unknown as HTMLDivElement)?.setAttribute(
'rnghtext',
'true'
);
}, []);
return onPress || onLongPress ? (
<GestureDetector gesture={native}>
<RNText
onPress={onPress}
onLongPress={onLongPress}
ref={refHandler}
{...rest}
/>
</GestureDetector>
) : (
<RNText ref={ref} {...rest} />
);
}
);
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type Text = typeof Text & RNText;
@@ -0,0 +1,34 @@
import * as React from 'react';
import { StyleSheet, StyleProp, ViewStyle } from 'react-native';
import hoistNonReactStatics from 'hoist-non-react-statics';
import GestureHandlerRootView from './GestureHandlerRootView';
/**
* @deprecated `gestureHandlerRootHOC` is deprecated and will be removed in the future version of Gesture Handler.
* Use `GestureHandlerRootView` directly instead.
*/
export default function gestureHandlerRootHOC<P extends object>(
Component: React.ComponentType<P>,
containerStyles?: StyleProp<ViewStyle>
): React.ComponentType<P> {
function Wrapper(props: P) {
return (
<GestureHandlerRootView style={[styles.container, containerStyles]}>
<Component {...props} />
</GestureHandlerRootView>
);
}
Wrapper.displayName = `gestureHandlerRootHOC(${
Component.displayName || Component.name
})`;
// @ts-ignore - hoistNonReactStatics uses old version of @types/react
hoistNonReactStatics(Wrapper, Component);
return Wrapper;
}
const styles = StyleSheet.create({
container: { flex: 1 },
});
@@ -0,0 +1,7 @@
export type ExtraButtonProps = {
borderless?: boolean;
rippleColor?: number | string | null;
rippleRadius?: number | null;
foreground?: boolean;
exclusive?: boolean;
};
@@ -0,0 +1,275 @@
import * as React from 'react';
import { Component } from 'react';
import { Animated, Platform } from 'react-native';
import { State } from '../../State';
import { BaseButton } from '../GestureButtons';
import {
GestureEvent,
HandlerStateChangeEvent,
} from '../../handlers/gestureHandlerCommon';
import type { NativeViewGestureHandlerPayload } from '../../handlers/GestureHandlerEventPayload';
import type { GenericTouchableProps } from './GenericTouchableProps';
/**
* Each touchable is a states' machine which preforms transitions.
* On very beginning (and on the very end or recognition) touchable is
* UNDETERMINED. Then it moves to BEGAN. If touchable recognizes that finger
* travel outside it transits to special MOVED_OUTSIDE state. Gesture recognition
* finishes in UNDETERMINED state.
*/
export const TOUCHABLE_STATE = {
UNDETERMINED: 0,
BEGAN: 1,
MOVED_OUTSIDE: 2,
} as const;
type TouchableState = (typeof TOUCHABLE_STATE)[keyof typeof TOUCHABLE_STATE];
interface InternalProps {
onStateChange?: (oldState: TouchableState, newState: TouchableState) => void;
}
// TODO: maybe can be better
// TODO: all clearTimeout have ! added, maybe they shouldn't ?
type Timeout = ReturnType<typeof setTimeout> | null | undefined;
/**
* GenericTouchable is not intented to be used as it is.
* Should be treated as a source for the rest of touchables
*/
export default class GenericTouchable extends Component<
GenericTouchableProps & InternalProps
> {
static defaultProps = {
delayLongPress: 600,
extraButtonProps: {
rippleColor: 'transparent',
exclusive: true,
},
};
// Timeout handlers
pressInTimeout: Timeout;
pressOutTimeout: Timeout;
longPressTimeout: Timeout;
// This flag is required since recognition of longPress implies not-invoking onPress
longPressDetected = false;
pointerInside = true;
// State of touchable
STATE: TouchableState = TOUCHABLE_STATE.UNDETERMINED;
// handlePressIn in called on first touch on traveling inside component.
// Handles state transition with delay.
handlePressIn() {
if (this.props.delayPressIn) {
this.pressInTimeout = setTimeout(() => {
this.moveToState(TOUCHABLE_STATE.BEGAN);
this.pressInTimeout = null;
}, this.props.delayPressIn);
} else {
this.moveToState(TOUCHABLE_STATE.BEGAN);
}
if (this.props.onLongPress) {
const time =
(this.props.delayPressIn || 0) + (this.props.delayLongPress || 0);
this.longPressTimeout = setTimeout(this.onLongPressDetected, time);
}
}
// handleMoveOutside in called on traveling outside component.
// Handles state transition with delay.
handleMoveOutside() {
if (this.props.delayPressOut) {
this.pressOutTimeout =
this.pressOutTimeout ||
setTimeout(() => {
this.moveToState(TOUCHABLE_STATE.MOVED_OUTSIDE);
this.pressOutTimeout = null;
}, this.props.delayPressOut);
} else {
this.moveToState(TOUCHABLE_STATE.MOVED_OUTSIDE);
}
}
// handleGoToUndetermined transits to UNDETERMINED state with proper delay
handleGoToUndetermined() {
clearTimeout(this.pressOutTimeout!); // TODO: maybe it can be undefined
if (this.props.delayPressOut) {
this.pressOutTimeout = setTimeout(() => {
if (this.STATE === TOUCHABLE_STATE.UNDETERMINED) {
this.moveToState(TOUCHABLE_STATE.BEGAN);
}
this.moveToState(TOUCHABLE_STATE.UNDETERMINED);
this.pressOutTimeout = null;
}, this.props.delayPressOut);
} else {
if (this.STATE === TOUCHABLE_STATE.UNDETERMINED) {
this.moveToState(TOUCHABLE_STATE.BEGAN);
}
this.moveToState(TOUCHABLE_STATE.UNDETERMINED);
}
}
componentDidMount() {
this.reset();
}
// Reset timeout to prevent memory leaks.
reset() {
this.longPressDetected = false;
this.pointerInside = true;
clearTimeout(this.pressInTimeout!);
clearTimeout(this.pressOutTimeout!);
clearTimeout(this.longPressTimeout!);
this.pressOutTimeout = null;
this.longPressTimeout = null;
this.pressInTimeout = null;
}
// All states' transitions are defined here.
moveToState(newState: TouchableState) {
if (newState === this.STATE) {
// Ignore dummy transitions
return;
}
if (newState === TOUCHABLE_STATE.BEGAN) {
// First touch and moving inside
this.props.onPressIn?.();
} else if (newState === TOUCHABLE_STATE.MOVED_OUTSIDE) {
// Moving outside
this.props.onPressOut?.();
} else if (newState === TOUCHABLE_STATE.UNDETERMINED) {
// Need to reset each time on transition to UNDETERMINED
this.reset();
if (this.STATE === TOUCHABLE_STATE.BEGAN) {
// ... and if it happens inside button.
this.props.onPressOut?.();
}
}
// Finally call lister (used by subclasses)
this.props.onStateChange?.(this.STATE, newState);
// ... and make transition.
this.STATE = newState;
}
onGestureEvent = ({
nativeEvent: { pointerInside },
}: GestureEvent<NativeViewGestureHandlerPayload>) => {
if (this.pointerInside !== pointerInside) {
if (pointerInside) {
this.onMoveIn();
} else {
this.onMoveOut();
}
}
this.pointerInside = pointerInside;
};
onHandlerStateChange = ({
nativeEvent,
}: HandlerStateChangeEvent<NativeViewGestureHandlerPayload>) => {
const { state } = nativeEvent;
if (state === State.CANCELLED || state === State.FAILED) {
// Need to handle case with external cancellation (e.g. by ScrollView)
this.moveToState(TOUCHABLE_STATE.UNDETERMINED);
} else if (
// This platform check is an implication of slightly different behavior of handlers on different platform.
// And Android "Active" state is achieving on first move of a finger, not on press in.
// On iOS event on "Began" is not delivered.
state === (Platform.OS !== 'android' ? State.ACTIVE : State.BEGAN) &&
this.STATE === TOUCHABLE_STATE.UNDETERMINED
) {
// Moving inside requires
this.handlePressIn();
} else if (state === State.END) {
const shouldCallOnPress =
!this.longPressDetected &&
this.STATE !== TOUCHABLE_STATE.MOVED_OUTSIDE &&
this.pressOutTimeout === null;
this.handleGoToUndetermined();
if (shouldCallOnPress) {
// Calls only inside component whether no long press was called previously
this.props.onPress?.();
}
}
};
onLongPressDetected = () => {
this.longPressDetected = true;
// Checked for in the caller of `onLongPressDetected`, but better to check twice
this.props.onLongPress?.();
};
componentWillUnmount() {
// To prevent memory leaks
this.reset();
}
onMoveIn() {
if (this.STATE === TOUCHABLE_STATE.MOVED_OUTSIDE) {
// This call is not throttled with delays (like in RN's implementation).
this.moveToState(TOUCHABLE_STATE.BEGAN);
}
}
onMoveOut() {
// Long press should no longer be detected
clearTimeout(this.longPressTimeout!);
this.longPressTimeout = null;
if (this.STATE === TOUCHABLE_STATE.BEGAN) {
this.handleMoveOutside();
}
}
render() {
const hitSlop =
(typeof this.props.hitSlop === 'number'
? {
top: this.props.hitSlop,
left: this.props.hitSlop,
bottom: this.props.hitSlop,
right: this.props.hitSlop,
}
: this.props.hitSlop) ?? undefined;
const coreProps = {
accessible: this.props.accessible !== false,
accessibilityLabel: this.props.accessibilityLabel,
accessibilityHint: this.props.accessibilityHint,
accessibilityRole: this.props.accessibilityRole,
// TODO: check if changed to no 's' correctly, also removed 2 props that are no longer available: `accessibilityComponentType` and `accessibilityTraits`,
// would be good to check if it is ok for sure, see: https://github.com/facebook/react-native/issues/24016
accessibilityState: this.props.accessibilityState,
accessibilityActions: this.props.accessibilityActions,
onAccessibilityAction: this.props.onAccessibilityAction,
nativeID: this.props.nativeID,
onLayout: this.props.onLayout,
};
return (
<BaseButton
style={this.props.containerStyle}
onHandlerStateChange={
// TODO: not sure if it can be undefined instead of null
this.props.disabled ? undefined : this.onHandlerStateChange
}
onGestureEvent={this.onGestureEvent}
hitSlop={hitSlop}
userSelect={this.props.userSelect}
shouldActivateOnStart={this.props.shouldActivateOnStart}
disallowInterruption={this.props.disallowInterruption}
testID={this.props.testID}
touchSoundDisabled={this.props.touchSoundDisabled ?? false}
enabled={!this.props.disabled}
{...this.props.extraButtonProps}>
<Animated.View {...coreProps} style={this.props.style}>
{this.props.children}
</Animated.View>
</BaseButton>
);
}
}
@@ -0,0 +1,28 @@
import type {
StyleProp,
ViewStyle,
TouchableWithoutFeedbackProps,
Insets,
} from 'react-native';
import type { UserSelect } from '../../handlers/gestureHandlerCommon';
import { ExtraButtonProps } from './ExtraButtonProps';
export interface GenericTouchableProps
extends Omit<TouchableWithoutFeedbackProps, 'hitSlop'> {
// Decided to drop not used fields from RN's implementation.
// e.g. onBlur and onFocus as well as deprecated props. - TODO: this comment may be unuseful in this moment
// TODO: in RN these events get native event parameter, which prolly could be used in our implementation too
onPress?: () => void;
onPressIn?: () => void;
onPressOut?: () => void;
onLongPress?: () => void;
nativeID?: string;
shouldActivateOnStart?: boolean;
disallowInterruption?: boolean;
containerStyle?: StyleProp<ViewStyle>;
hitSlop?: Insets | number;
userSelect?: UserSelect;
extraButtonProps?: ExtraButtonProps;
}
@@ -0,0 +1,118 @@
import * as React from 'react';
import { Component } from 'react';
import GenericTouchable, { TOUCHABLE_STATE } from './GenericTouchable';
import type { GenericTouchableProps } from './GenericTouchableProps';
import {
StyleSheet,
View,
TouchableHighlightProps as RNTouchableHighlightProps,
ColorValue,
ViewProps,
} from 'react-native';
interface State {
extraChildStyle: null | {
opacity?: number;
};
extraUnderlayStyle: null | {
backgroundColor?: ColorValue;
};
}
/**
* @deprecated TouchableHighlight will be removed in the future version of Gesture Handler. Use Pressable instead.
*/
export type TouchableHighlightProps = RNTouchableHighlightProps &
GenericTouchableProps;
/**
* @deprecated TouchableHighlight will be removed in the future version of Gesture Handler. Use Pressable instead.
*
* TouchableHighlight follows RN's implementation
*/
export default class TouchableHighlight extends Component<
TouchableHighlightProps,
State
> {
static defaultProps = {
...GenericTouchable.defaultProps,
activeOpacity: 0.85,
delayPressOut: 100,
underlayColor: 'black',
};
constructor(props: TouchableHighlightProps) {
super(props);
this.state = {
extraChildStyle: null,
extraUnderlayStyle: null,
};
}
// Copied from RN
showUnderlay = () => {
if (!this.hasPressHandler()) {
return;
}
this.setState({
extraChildStyle: {
opacity: this.props.activeOpacity,
},
extraUnderlayStyle: {
backgroundColor: this.props.underlayColor,
},
});
this.props.onShowUnderlay?.();
};
hasPressHandler = () =>
this.props.onPress ||
this.props.onPressIn ||
this.props.onPressOut ||
this.props.onLongPress;
hideUnderlay = () => {
this.setState({
extraChildStyle: null,
extraUnderlayStyle: null,
});
this.props.onHideUnderlay?.();
};
renderChildren() {
if (!this.props.children) {
return <View />;
}
const child = React.Children.only(
this.props.children
) as React.ReactElement<ViewProps>; // TODO: not sure if OK but fixes error
return React.cloneElement(child, {
style: StyleSheet.compose(child.props.style, this.state.extraChildStyle),
});
}
onStateChange = (_from: number, to: number) => {
if (to === TOUCHABLE_STATE.BEGAN) {
this.showUnderlay();
} else if (
to === TOUCHABLE_STATE.UNDETERMINED ||
to === TOUCHABLE_STATE.MOVED_OUTSIDE
) {
this.hideUnderlay();
}
};
render() {
const { style = {}, ...rest } = this.props;
const { extraUnderlayStyle } = this.state;
return (
<GenericTouchable
{...rest}
style={[style, extraUnderlayStyle]}
onStateChange={this.onStateChange}>
{this.renderChildren()}
</GenericTouchable>
);
}
}
@@ -0,0 +1,83 @@
import { Platform, ColorValue } from 'react-native';
import * as React from 'react';
import { Component } from 'react';
import GenericTouchable from './GenericTouchable';
import {
TouchableNativeFeedbackProps,
TouchableNativeFeedbackExtraProps,
} from './TouchableNativeFeedbackProps';
/**
* @deprecated TouchableNativeFeedback will be removed in the future version of Gesture Handler. Use Pressable instead.
*
* TouchableNativeFeedback behaves slightly different than RN's TouchableNativeFeedback.
* There's small difference with handling long press ripple since RN's implementation calls
* ripple animation via bridge. This solution leaves all animations' handling for native components so
* it follows native behaviours.
*/
export default class TouchableNativeFeedback extends Component<TouchableNativeFeedbackProps> {
static defaultProps = {
...GenericTouchable.defaultProps,
useForeground: true,
extraButtonProps: {
// Disable hiding ripple on Android
rippleColor: null,
},
};
// Could be taken as RNTouchableNativeFeedback.SelectableBackground etc. but the API may change
static SelectableBackground = (rippleRadius?: number) => ({
type: 'ThemeAttrAndroid',
// I added `attribute` prop to clone the implementation of RN and be able to use only 2 types
attribute: 'selectableItemBackground',
rippleRadius,
});
static SelectableBackgroundBorderless = (rippleRadius?: number) => ({
type: 'ThemeAttrAndroid',
attribute: 'selectableItemBackgroundBorderless',
rippleRadius,
});
static Ripple = (
color: ColorValue,
borderless: boolean,
rippleRadius?: number
) => ({
type: 'RippleAndroid',
color,
borderless,
rippleRadius,
});
static canUseNativeForeground = () =>
Platform.OS === 'android' && Platform.Version >= 23;
getExtraButtonProps() {
const extraProps: TouchableNativeFeedbackExtraProps = {};
const { background } = this.props;
if (background) {
// I changed type values to match those used in RN
// TODO(TS): check if it works the same as previous implementation - looks like it works the same as RN component, so it should be ok
if (background.type === 'RippleAndroid') {
extraProps['borderless'] = background.borderless;
extraProps['rippleColor'] = background.color;
} else if (background.type === 'ThemeAttrAndroid') {
extraProps['borderless'] =
background.attribute === 'selectableItemBackgroundBorderless';
}
// I moved it from above since it should be available in all options
extraProps['rippleRadius'] = background.rippleRadius;
}
extraProps['foreground'] = this.props.useForeground;
return extraProps;
}
render() {
const { style = {}, ...rest } = this.props;
return (
<GenericTouchable
{...rest}
style={style}
extraButtonProps={this.getExtraButtonProps()}
/>
);
}
}
@@ -0,0 +1,8 @@
import { TouchableNativeFeedback as RNTouchableNativeFeedback } from 'react-native';
/**
* @deprecated TouchableNativeFeedback will be removed in the future version of Gesture Handler. Use Pressable instead.
*/
const TouchableNativeFeedback = RNTouchableNativeFeedback;
export default TouchableNativeFeedback;
@@ -0,0 +1,10 @@
import type { TouchableNativeFeedbackProps as RNTouchableNativeFeedbackProps } from 'react-native';
import type { GenericTouchableProps } from './GenericTouchableProps';
import { ExtraButtonProps } from './ExtraButtonProps';
export type TouchableNativeFeedbackExtraProps = ExtraButtonProps;
/**
* @deprecated TouchableNativeFeedback will be removed in the future version of Gesture Handler. Use Pressable instead.
*/
export type TouchableNativeFeedbackProps = RNTouchableNativeFeedbackProps &
GenericTouchableProps;
@@ -0,0 +1,78 @@
import {
Animated,
Easing,
StyleSheet,
View,
TouchableOpacityProps as RNTouchableOpacityProps,
} from 'react-native';
import GenericTouchable, { TOUCHABLE_STATE } from './GenericTouchable';
import type { GenericTouchableProps } from './GenericTouchableProps';
import * as React from 'react';
import { Component } from 'react';
/**
* @deprecated TouchableOpacity will be removed in the future version of Gesture Handler. Use Pressable instead.
*/
export type TouchableOpacityProps = RNTouchableOpacityProps &
GenericTouchableProps & {
useNativeAnimations?: boolean;
};
/**
* @deprecated TouchableOpacity will be removed in the future version of Gesture Handler. Use Pressable instead.
*
* TouchableOpacity bases on timing animation which has been used in RN's core
*/
export default class TouchableOpacity extends Component<TouchableOpacityProps> {
static defaultProps = {
...GenericTouchable.defaultProps,
activeOpacity: 0.2,
};
// Opacity is 1 one by default but could be overwritten
getChildStyleOpacityWithDefault = () => {
const childStyle = StyleSheet.flatten(this.props.style) || {};
return childStyle.opacity == null
? 1
: (childStyle.opacity.valueOf() as number);
};
opacity = new Animated.Value(this.getChildStyleOpacityWithDefault());
setOpacityTo = (value: number, duration: number) => {
Animated.timing(this.opacity, {
toValue: value,
duration: duration,
easing: Easing.inOut(Easing.quad),
useNativeDriver: this.props.useNativeAnimations ?? true,
}).start();
};
onStateChange = (_from: number, to: number) => {
if (to === TOUCHABLE_STATE.BEGAN) {
this.setOpacityTo(this.props.activeOpacity!, 0);
} else if (
to === TOUCHABLE_STATE.UNDETERMINED ||
to === TOUCHABLE_STATE.MOVED_OUTSIDE
) {
this.setOpacityTo(this.getChildStyleOpacityWithDefault(), 150);
}
};
render() {
const { style = {}, ...rest } = this.props;
return (
<GenericTouchable
{...rest}
style={[
style,
{
opacity: this.opacity as unknown as number, // TODO: fix this
},
]}
onStateChange={this.onStateChange}>
{this.props.children ? this.props.children : <View />}
</GenericTouchable>
);
}
}
@@ -0,0 +1,39 @@
import * as React from 'react';
import { PropsWithChildren } from 'react';
import GenericTouchable from './GenericTouchable';
import type { GenericTouchableProps } from './GenericTouchableProps';
/**
* @deprecated TouchableWithoutFeedback will be removed in the future version of Gesture Handler. Use Pressable instead.
*/
export type TouchableWithoutFeedbackProps = GenericTouchableProps;
/**
* @deprecated TouchableWithoutFeedback will be removed in the future version of Gesture Handler. Use Pressable instead.
*/
const TouchableWithoutFeedback = React.forwardRef<
GenericTouchable,
PropsWithChildren<TouchableWithoutFeedbackProps>
>(
(
{
delayLongPress = 600,
extraButtonProps = {
rippleColor: 'transparent',
exclusive: true,
},
...rest
},
ref
) => (
<GenericTouchable
ref={ref}
delayLongPress={delayLongPress}
extraButtonProps={extraButtonProps}
{...rest}
/>
)
);
export default TouchableWithoutFeedback;
@@ -0,0 +1,7 @@
export type { TouchableHighlightProps } from './TouchableHighlight';
export type { TouchableOpacityProps } from './TouchableOpacity';
export type { TouchableWithoutFeedbackProps } from './TouchableWithoutFeedback';
export { default as TouchableNativeFeedback } from './TouchableNativeFeedback';
export { default as TouchableWithoutFeedback } from './TouchableWithoutFeedback';
export { default as TouchableOpacity } from './TouchableOpacity';
export { default as TouchableHighlight } from './TouchableHighlight';
@@ -0,0 +1,26 @@
import { BaseGesture, GestureRef } from '../handlers/gestures/gesture';
export type RelationPropName =
| 'simultaneousWithExternalGesture'
| 'requireExternalGestureToFail'
| 'blocksExternalGesture';
export type RelationPropType =
| Exclude<GestureRef, number>
| Exclude<GestureRef, number>[];
export function applyRelationProp(
gesture: BaseGesture<any>,
relationPropName: RelationPropName,
relationProp: RelationPropType
) {
if (!relationProp) {
return;
}
if (Array.isArray(relationProp)) {
gesture[relationPropName](...relationProp);
} else {
gesture[relationPropName](relationProp);
}
}