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,9 @@
export const ActionType = {
REANIMATED_WORKLET: 1,
NATIVE_ANIMATED_EVENT: 2,
JS_FUNCTION_OLD_API: 3,
JS_FUNCTION_NEW_API: 4,
} as const;
// eslint-disable-next-line @typescript-eslint/no-redeclare -- backward compatibility; it can be used as a type and as a value
export type ActionType = (typeof ActionType)[keyof typeof ActionType];
@@ -0,0 +1,26 @@
const RIGHT = 1;
const LEFT = 2;
const UP = 4;
const DOWN = 8;
// Public interface
export const Directions = {
RIGHT: RIGHT,
LEFT: LEFT,
UP: UP,
DOWN: DOWN,
} as const;
// Internal interface
export const DiagonalDirections = {
UP_RIGHT: UP | RIGHT,
DOWN_RIGHT: DOWN | RIGHT,
UP_LEFT: UP | LEFT,
DOWN_LEFT: DOWN | LEFT,
} as const;
// eslint-disable-next-line @typescript-eslint/no-redeclare -- backward compatibility; it can be used as a type and as a value
export type Directions = (typeof Directions)[keyof typeof Directions];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type DiagonalDirections =
(typeof DiagonalDirections)[keyof typeof DiagonalDirections];
@@ -0,0 +1,53 @@
import { Platform } from 'react-native';
import { tagMessage } from './utils';
let useNewWebImplementation = true;
let getWasCalled = false;
/**
* @deprecated new web implementation is enabled by default. This function will be removed in Gesture Handler 3
*/
export function enableExperimentalWebImplementation(
_shouldEnable = true
): void {
// NO-OP since the new implementation is now the default
console.warn(
tagMessage(
'New web implementation is enabled by default. This function will be removed in Gesture Handler 3.'
)
);
}
/**
* @deprecated legacy implementation is no longer supported. This function will be removed in Gesture Handler 3
*/
export function enableLegacyWebImplementation(
shouldUseLegacyImplementation = true
): void {
console.warn(
tagMessage(
'Legacy web implementation is deprecated. This function will be removed in Gesture Handler 3.'
)
);
if (
Platform.OS !== 'web' ||
useNewWebImplementation === !shouldUseLegacyImplementation
) {
return;
}
if (getWasCalled) {
console.error(
'Some parts of this application have already started using the new gesture handler implementation. No changes will be applied. You can try enabling legacy implementation earlier.'
);
return;
}
useNewWebImplementation = !shouldUseLegacyImplementation;
}
export function isNewWebImplementationEnabled(): boolean {
getWasCalled = true;
return useNewWebImplementation;
}
@@ -0,0 +1,3 @@
import React from 'react';
export default React.createContext(false);
@@ -0,0 +1,8 @@
import { NativeModules, Platform } from 'react-native';
type PlatformConstants = {
forceTouchAvailable: boolean;
};
export default (NativeModules?.PlatformConstants ??
Platform.constants) as PlatformConstants;
@@ -0,0 +1,5 @@
export default {
get forceTouchAvailable() {
return false;
},
};
@@ -0,0 +1,7 @@
export enum PointerType {
TOUCH,
STYLUS,
MOUSE,
KEY,
OTHER,
}
@@ -0,0 +1,5 @@
// Reexport the native module spec used by codegen. The relevant files are inluded on Android
// to ensure the compatibility with the old arch, while iOS doesn't require those at all.
import Module from './specs/NativeRNGestureHandlerModule';
export default Module;
@@ -0,0 +1,124 @@
import React from 'react';
import type { ActionType } from './ActionType';
import { isNewWebImplementationEnabled } from './EnableNewWebImplementation';
import { Gestures, HammerGestures } from './web/Gestures';
import type { Config } from './web/interfaces';
import InteractionManager from './web/tools/InteractionManager';
import NodeManager from './web/tools/NodeManager';
import * as HammerNodeManager from './web_hammer/NodeManager';
import { GestureHandlerWebDelegate } from './web/tools/GestureHandlerWebDelegate';
// init method is called inside attachGestureHandler function. However, this function may
// fail when received view is not valid HTML element. On the other hand, dropGestureHandler
// will be called even if attach failed, which will result in crash.
//
// We use this flag to check whether or not dropGestureHandler should be called.
let shouldPreventDrop = false;
export default {
handleSetJSResponder(tag: number, blockNativeResponder: boolean) {
console.warn('handleSetJSResponder: ', tag, blockNativeResponder);
},
handleClearJSResponder() {
console.warn('handleClearJSResponder: ');
},
createGestureHandler<T>(
handlerName: keyof typeof Gestures,
handlerTag: number,
config: T
) {
if (isNewWebImplementationEnabled()) {
if (!(handlerName in Gestures)) {
throw new Error(
`react-native-gesture-handler: ${handlerName} is not supported on web.`
);
}
const GestureClass = Gestures[handlerName];
NodeManager.createGestureHandler(
handlerTag,
new GestureClass(new GestureHandlerWebDelegate())
);
InteractionManager.instance.configureInteractions(
NodeManager.getHandler(handlerTag),
config as unknown as Config
);
} else {
if (!(handlerName in HammerGestures)) {
throw new Error(
`react-native-gesture-handler: ${handlerName} is not supported on web.`
);
}
// @ts-ignore If it doesn't exist, the error is thrown
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const GestureClass = HammerGestures[handlerName];
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
HammerNodeManager.createGestureHandler(handlerTag, new GestureClass());
}
this.updateGestureHandler(handlerTag, config as unknown as Config);
},
attachGestureHandler(
handlerTag: number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
newView: any,
_actionType: ActionType,
propsRef: React.RefObject<unknown>
) {
if (!(newView instanceof Element || newView instanceof React.Component)) {
shouldPreventDrop = true;
const handler = isNewWebImplementationEnabled()
? NodeManager.getHandler(handlerTag)
: HammerNodeManager.getHandler(handlerTag);
const handlerName = handler.constructor.name;
throw new Error(
`${handlerName} with tag ${handlerTag} received child that is not valid HTML element.`
);
}
if (isNewWebImplementationEnabled()) {
// @ts-ignore Types should be HTMLElement or React.Component
NodeManager.getHandler(handlerTag).init(newView, propsRef);
} else {
// @ts-ignore Types should be HTMLElement or React.Component
HammerNodeManager.getHandler(handlerTag).setView(newView, propsRef);
}
},
updateGestureHandler(handlerTag: number, newConfig: Config) {
if (isNewWebImplementationEnabled()) {
NodeManager.getHandler(handlerTag).updateGestureConfig(newConfig);
InteractionManager.instance.configureInteractions(
NodeManager.getHandler(handlerTag),
newConfig
);
} else {
HammerNodeManager.getHandler(handlerTag).updateGestureConfig(newConfig);
}
},
getGestureHandlerNode(handlerTag: number) {
if (isNewWebImplementationEnabled()) {
return NodeManager.getHandler(handlerTag);
} else {
return HammerNodeManager.getHandler(handlerTag);
}
},
dropGestureHandler(handlerTag: number) {
if (shouldPreventDrop) {
return;
}
if (isNewWebImplementationEnabled()) {
NodeManager.dropGestureHandler(handlerTag);
} else {
HammerNodeManager.dropGestureHandler(handlerTag);
}
},
// eslint-disable-next-line @typescript-eslint/no-empty-function
flushOperations() {},
};
@@ -0,0 +1,62 @@
import React from 'react';
import { ActionType } from './ActionType';
// GestureHandlers
import PanGestureHandler from './web/handlers/PanGestureHandler';
import TapGestureHandler from './web/handlers/TapGestureHandler';
import LongPressGestureHandler from './web/handlers/LongPressGestureHandler';
import PinchGestureHandler from './web/handlers/PinchGestureHandler';
import RotationGestureHandler from './web/handlers/RotationGestureHandler';
import FlingGestureHandler from './web/handlers/FlingGestureHandler';
import NativeViewGestureHandler from './web/handlers/NativeViewGestureHandler';
import ManualGestureHandler from './web/handlers/ManualGestureHandler';
import { Config } from './web/interfaces';
export const Gestures = {
NativeViewGestureHandler,
PanGestureHandler,
TapGestureHandler,
LongPressGestureHandler,
PinchGestureHandler,
RotationGestureHandler,
FlingGestureHandler,
ManualGestureHandler,
};
export default {
handleSetJSResponder(_tag: number, _blockNativeResponder: boolean) {
// NO-OP
},
handleClearJSResponder() {
// NO-OP
},
createGestureHandler<T>(
_handlerName: keyof typeof Gestures,
_handlerTag: number,
_config: T
) {
// NO-OP
},
attachGestureHandler(
_handlerTag: number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
_newView: any,
_actionType: ActionType,
_propsRef: React.RefObject<unknown>
) {
// NO-OP
},
updateGestureHandler(_handlerTag: number, _newConfig: Config) {
// NO-OP
},
getGestureHandlerNode(_handlerTag: number) {
// NO-OP
},
dropGestureHandler(_handlerTag: number) {
// NO-OP
},
flushOperations() {
// NO-OP
},
};
@@ -0,0 +1,3 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
export { default as RNRenderer } from 'react-native/Libraries/Renderer/shims/ReactNative';
@@ -0,0 +1,3 @@
export const RNRenderer = {
findHostInstance_DEPRECATED: (_ref: any) => null,
};
+13
View File
@@ -0,0 +1,13 @@
// TODO use State from RNModule
export const State = {
UNDETERMINED: 0,
FAILED: 1,
BEGAN: 2,
CANCELLED: 3,
ACTIVE: 4,
END: 5,
} as const;
// eslint-disable-next-line @typescript-eslint/no-redeclare -- backward compatibility; it can be used as a type and as a value
export type State = (typeof State)[keyof typeof State];
@@ -0,0 +1,11 @@
export const TouchEventType = {
UNDETERMINED: 0,
TOUCHES_DOWN: 1,
TOUCHES_MOVE: 2,
TOUCHES_UP: 3,
TOUCHES_CANCELLED: 4,
} as const;
// eslint-disable-next-line @typescript-eslint/no-redeclare -- backward compatibility; it can be used as a type and as a value
export type TouchEventType =
(typeof TouchEventType)[keyof typeof TouchEventType];
@@ -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);
}
}
@@ -0,0 +1,3 @@
import { findNodeHandle } from 'react-native';
export default findNodeHandle;
@@ -0,0 +1,41 @@
import { FlatList } from 'react-native';
import type { GestureHandlerRef, SVGRef } from './web/interfaces';
import { isRNSVGElement } from './web/utils';
export default function findNodeHandle(
viewRef: GestureHandlerRef | SVGRef | HTMLElement | SVGElement
): HTMLElement | SVGElement | number {
// TODO: Remove this once we remove old API.
if (viewRef instanceof FlatList) {
// @ts-ignore This is the only way to get the scroll ref from FlatList.
return viewRef._listRef._scrollRef.firstChild;
}
// Old API assumes that child handler is HTMLElement.
// However, if we nest handlers, we will get ref to another handler.
// In that case, we want to recursively call findNodeHandle with new handler viewTag (which can also be ref to another handler).
if ((viewRef as GestureHandlerRef)?.viewTag !== undefined) {
return findNodeHandle((viewRef as GestureHandlerRef).viewTag);
}
if (viewRef instanceof Element) {
if (viewRef.style.display === 'contents') {
return findNodeHandle(viewRef.firstChild as HTMLElement);
}
return viewRef;
}
if (isRNSVGElement(viewRef)) {
return (viewRef as SVGRef).elementRef.current;
}
// In new API, we receive ref object which `current` field points to wrapper `div` with `display: contents;`.
// We want to return the first descendant (in DFS order) that doesn't have this property.
let element = (viewRef as GestureHandlerRef)?.current;
while (element && element.style.display === 'contents') {
element = element.firstChild as HTMLElement;
}
return element;
}
@@ -0,0 +1,50 @@
// Used by GestureDetector (unsupported on web at the moment) to check whether the
// attached view may get flattened on Fabric. This implementation causes errors
// on web due to the static resolution of `require` statements by webpack breaking
// the conditional importing. Solved by making .web file.
let findHostInstance_DEPRECATED: (ref: unknown) => void;
let getInternalInstanceHandleFromPublicInstance: (ref: unknown) => {
stateNode: { node: unknown };
};
export function getShadowNodeFromRef(ref: unknown) {
// Load findHostInstance_DEPRECATED lazily because it may not be available before render
if (findHostInstance_DEPRECATED === undefined) {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ReactFabric = require('react-native/Libraries/Renderer/shims/ReactFabric');
// Since RN 0.77 ReactFabric exports findHostInstance_DEPRECATED in default object so we're trying to
// access it first, then fallback on named export
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
findHostInstance_DEPRECATED =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
ReactFabric?.default?.findHostInstance_DEPRECATED ||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
ReactFabric?.findHostInstance_DEPRECATED;
} catch (e) {
findHostInstance_DEPRECATED = (_ref: unknown) => null;
}
}
// Load findHostInstance_DEPRECATED lazily because it may not be available before render
if (getInternalInstanceHandleFromPublicInstance === undefined) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
getInternalInstanceHandleFromPublicInstance =
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance')
.getInternalInstanceHandleFromPublicInstance ??
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return
((ref: any) => ref._internalInstanceHandle);
} catch (e) {
getInternalInstanceHandleFromPublicInstance = (ref: any) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return
ref._internalInstanceHandle;
}
}
// @ts-ignore Fabric
return getInternalInstanceHandleFromPublicInstance(
findHostInstance_DEPRECATED(ref)
).stateNode.node;
}
@@ -0,0 +1,7 @@
// Used by GestureDetector (unsupported on web at the moment) to check whether the
// attached view may get flattened on Fabric. Original implementation causes errors
// on web due to the static resolution of `require` statements by webpack breaking
// the conditional importing.
export function getShadowNodeFromRef(_ref: any) {
return null;
}
@@ -0,0 +1,8 @@
// We check for typeof requestAnimationFrame because of SSR
// Functions are bound to null to avoid issues with scope when using Metro inline requires.
export const ghQueueMicrotask =
typeof setImmediate === 'function'
? setImmediate.bind(null)
: typeof requestAnimationFrame === 'function'
? requestAnimationFrame.bind(null)
: queueMicrotask.bind(null);
@@ -0,0 +1,64 @@
import type { FlingGestureHandlerEventPayload } from './GestureHandlerEventPayload';
import createHandler from './createHandler';
import {
BaseGestureHandlerProps,
baseGestureHandlerProps,
} from './gestureHandlerCommon';
export const flingGestureHandlerProps = [
'numberOfPointers',
'direction',
] as const;
export interface FlingGestureConfig {
/**
* Expressed allowed direction of movement. It's possible to pass one or many
* directions in one parameter:
*
* ```js
* direction={Directions.RIGHT | Directions.LEFT}
* ```
*
* or
*
* ```js
* direction={Directions.DOWN}
* ```
*/
direction?: number;
/**
* Determine exact number of points required to handle the fling gesture.
*/
numberOfPointers?: number;
}
/**
* @deprecated FlingGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Fling()` instead.
*/
export interface FlingGestureHandlerProps
extends BaseGestureHandlerProps<FlingGestureHandlerEventPayload>,
FlingGestureConfig {}
export const flingHandlerName = 'FlingGestureHandler';
/**
* @deprecated FlingGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Fling()` instead.
*/
export type FlingGestureHandler = typeof FlingGestureHandler;
/**
* @deprecated FlingGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Fling()` instead.
*/
// eslint-disable-next-line @typescript-eslint/no-redeclare -- backward compatibility; see description on the top of gestureHandlerCommon.ts file
export const FlingGestureHandler = createHandler<
FlingGestureHandlerProps,
FlingGestureHandlerEventPayload
>({
name: flingHandlerName,
allowedProps: [
...baseGestureHandlerProps,
...flingGestureHandlerProps,
] as const,
config: {},
});
@@ -0,0 +1,88 @@
import React, { PropsWithChildren } from 'react';
import { tagMessage } from '../utils';
import PlatformConstants from '../PlatformConstants';
import createHandler from './createHandler';
import {
BaseGestureHandlerProps,
baseGestureHandlerProps,
} from './gestureHandlerCommon';
import type { ForceTouchGestureHandlerEventPayload } from './GestureHandlerEventPayload';
export const forceTouchGestureHandlerProps = [
'minForce',
'maxForce',
'feedbackOnActivation',
] as const;
// implicit `children` prop has been removed in @types/react^18.0.0
class ForceTouchFallback extends React.Component<PropsWithChildren<unknown>> {
static forceTouchAvailable = false;
componentDidMount() {
console.warn(
tagMessage(
'ForceTouchGestureHandler is not available on this platform. Please use ForceTouchGestureHandler.forceTouchAvailable to conditionally render other components that would provide a fallback behavior specific to your usecase'
)
);
}
render() {
return this.props.children;
}
}
export interface ForceTouchGestureConfig {
/**
*
* A minimal pressure that is required before handler can activate. Should be a
* value from range `[0.0, 1.0]`. Default is `0.2`.
*/
minForce?: number;
/**
* A maximal pressure that could be applied for handler. If the pressure is
* greater, handler fails. Should be a value from range `[0.0, 1.0]`.
*/
maxForce?: number;
/**
* Boolean value defining if haptic feedback has to be performed on
* activation.
*/
feedbackOnActivation?: boolean;
}
/**
* @deprecated ForceTouchGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.ForceTouch()` instead.
*/
export interface ForceTouchGestureHandlerProps
extends BaseGestureHandlerProps<ForceTouchGestureHandlerEventPayload>,
ForceTouchGestureConfig {}
/**
* @deprecated ForceTouchGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.ForceTouch()` instead.
*/
export type ForceTouchGestureHandler = typeof ForceTouchGestureHandler & {
forceTouchAvailable: boolean;
};
export const forceTouchHandlerName = 'ForceTouchGestureHandler';
/**
* @deprecated ForceTouchGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.ForceTouch()` instead.
*/
// eslint-disable-next-line @typescript-eslint/no-redeclare -- backward compatibility; see description on the top of gestureHandlerCommon.ts file
export const ForceTouchGestureHandler = PlatformConstants?.forceTouchAvailable
? createHandler<
ForceTouchGestureHandlerProps,
ForceTouchGestureHandlerEventPayload
>({
name: forceTouchHandlerName,
allowedProps: [
...baseGestureHandlerProps,
...forceTouchGestureHandlerProps,
] as const,
config: {},
})
: ForceTouchFallback;
(ForceTouchGestureHandler as ForceTouchGestureHandler).forceTouchAvailable =
PlatformConstants?.forceTouchAvailable || false;
@@ -0,0 +1,229 @@
import { StylusData } from '../web/interfaces';
export type FlingGestureHandlerEventPayload = {
x: number;
y: number;
absoluteX: number;
absoluteY: number;
};
/**
* @deprecated ForceTouch gesture is deprecated and will be removed in the future.
*/
export type ForceTouchGestureHandlerEventPayload = {
x: number;
y: number;
absoluteX: number;
absoluteY: number;
/**
* The pressure of a touch.
*/
force: number;
};
export type LongPressGestureHandlerEventPayload = {
/**
* X coordinate, expressed in points, of the current position of the pointer
* (finger or a leading pointer when there are multiple fingers placed)
* relative to the view attached to the handler.
*/
x: number;
/**
* Y coordinate, expressed in points, of the current position of the pointer
* (finger or a leading pointer when there are multiple fingers placed)
* relative to the view attached to the handler.
*/
y: number;
/**
* X coordinate, expressed in points, of the current position of the pointer
* (finger or a leading pointer when there are multiple fingers placed)
* relative to the window. It is recommended to use `absoluteX` instead of
* `x` in cases when the view attached to the handler can be transformed as an
* effect of the gesture.
*/
absoluteX: number;
/**
* Y coordinate, expressed in points, of the current position of the pointer
* (finger or a leading pointer when there are multiple fingers placed)
* relative to the window. It is recommended to use `absoluteY` instead of
* `y` in cases when the view attached to the handler can be transformed as an
* effect of the gesture.
*/
absoluteY: number;
/**
* Duration of the long press (time since the start of the event), expressed
* in milliseconds.
*/
duration: number;
};
export type NativeViewGestureHandlerPayload = {
/**
* True if gesture was performed inside of containing view, false otherwise.
*/
pointerInside: boolean;
};
export type PanGestureHandlerEventPayload = {
/**
* X coordinate of the current position of the pointer (finger or a leading
* pointer when there are multiple fingers placed) relative to the view
* attached to the handler. Expressed in point units.
*/
x: number;
/**
* Y coordinate of the current position of the pointer (finger or a leading
* pointer when there are multiple fingers placed) relative to the view
* attached to the handler. Expressed in point units.
*/
y: number;
/**
* X coordinate of the current position of the pointer (finger or a leading
* pointer when there are multiple fingers placed) relative to the window.
* The value is expressed in point units. It is recommended to use it instead
* of `x` in cases when the original view can be transformed as an effect of
* the gesture.
*/
absoluteX: number;
/**
* Y coordinate of the current position of the pointer (finger or a leading
* pointer when there are multiple fingers placed) relative to the window.
* The value is expressed in point units. It is recommended to use it instead
* of `y` in cases when the original view can be transformed as an
* effect of the gesture.
*/
absoluteY: number;
/**
* Translation of the pan gesture along X axis accumulated over the time of
* the gesture. The value is expressed in the point units.
*/
translationX: number;
/**
* Translation of the pan gesture along Y axis accumulated over the time of
* the gesture. The value is expressed in the point units.
*/
translationY: number;
/**
* Velocity of the pan gesture along the X axis in the current moment. The
* value is expressed in point units per second.
*/
velocityX: number;
/**
* Velocity of the pan gesture along the Y axis in the current moment. The
* value is expressed in point units per second.
*/
velocityY: number;
/**
* Object containing additional stylus data.
*/
stylusData?: StylusData;
};
export type PinchGestureHandlerEventPayload = {
/**
* The scale factor relative to the points of the two touches in screen
* coordinates.
*/
scale: number;
/**
* Position expressed in points along X axis of center anchor point of
* gesture.
*/
focalX: number;
/**
* Position expressed in points along Y axis of center anchor point of
* gesture.
*/
focalY: number;
/**
*
* Velocity of the pan gesture the current moment. The value is expressed in
* point units per second.
*/
velocity: number;
};
export type TapGestureHandlerEventPayload = {
x: number;
y: number;
absoluteX: number;
absoluteY: number;
};
export type RotationGestureHandlerEventPayload = {
/**
* Amount rotated, expressed in radians, from the gesture's focal point
* (anchor).
*/
rotation: number;
/**
* X coordinate, expressed in points, of the gesture's central focal point
* (anchor).
*/
anchorX: number;
/**
* Y coordinate, expressed in points, of the gesture's central focal point
* (anchor).
*/
anchorY: number;
/**
*
* Instantaneous velocity, expressed in point units per second, of the
* gesture.
*/
velocity: number;
};
export type HoverGestureHandlerEventPayload = {
/**
* X coordinate of the current position of the pointer relative to the view
* attached to the handler. Expressed in point units.
*/
x: number;
/**
* Y coordinate of the current position of the pointer relative to the view
* attached to the handler. Expressed in point units.
*/
y: number;
/**
* X coordinate of the current position of the pointer relative to the window.
* The value is expressed in point units. It is recommended to use it instead
* of `x` in cases when the original view can be transformed as an
* effect of the gesture.
*/
absoluteX: number;
/**
* Y coordinate of the current position of the pointer relative to the window.
* The value is expressed in point units. It is recommended to use it instead
* of `y` in cases when the original view can be transformed as an
* effect of the gesture.
*/
absoluteY: number;
/**
* Object containing additional stylus data.
*/
stylusData?: StylusData;
};
@@ -0,0 +1,65 @@
import { LongPressGestureHandlerEventPayload } from './GestureHandlerEventPayload';
import createHandler from './createHandler';
import {
BaseGestureHandlerProps,
baseGestureHandlerProps,
} from './gestureHandlerCommon';
export const longPressGestureHandlerProps = [
'minDurationMs',
'maxDist',
'numberOfPointers',
] as const;
export interface LongPressGestureConfig {
/**
* Minimum time, expressed in milliseconds, that a finger must remain pressed on
* the corresponding view. The default value is 500.
*/
minDurationMs?: number;
/**
* Maximum distance, expressed in points, that defines how far the finger is
* allowed to travel during a long press gesture. If the finger travels
* further than the defined distance and the handler hasn't yet activated, it
* will fail to recognize the gesture. The default value is 10.
*/
maxDist?: number;
/**
* Determine exact number of points required to handle the long press gesture.
*/
numberOfPointers?: number;
}
/**
* @deprecated LongPressGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.LongPress()` instead.
*/
export interface LongPressGestureHandlerProps
extends BaseGestureHandlerProps<LongPressGestureHandlerEventPayload>,
LongPressGestureConfig {}
export const longPressHandlerName = 'LongPressGestureHandler';
/**
* @deprecated LongPressGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.LongPress()` instead.
*/
export type LongPressGestureHandler = typeof LongPressGestureHandler;
/**
* @deprecated LongPressGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.LongPress()` instead.
*/
// eslint-disable-next-line @typescript-eslint/no-redeclare -- backward compatibility; see description on the top of gestureHandlerCommon.ts file
export const LongPressGestureHandler = createHandler<
LongPressGestureHandlerProps,
LongPressGestureHandlerEventPayload
>({
name: longPressHandlerName,
allowedProps: [
...baseGestureHandlerProps,
...longPressGestureHandlerProps,
] as const,
config: {
shouldCancelWhenOutside: true,
},
});
@@ -0,0 +1,59 @@
import type { NativeViewGestureHandlerPayload } from './GestureHandlerEventPayload';
import createHandler from './createHandler';
import {
BaseGestureHandlerProps,
baseGestureHandlerProps,
} from './gestureHandlerCommon';
export const nativeViewGestureHandlerProps = [
'shouldActivateOnStart',
'disallowInterruption',
] as const;
export interface NativeViewGestureConfig {
/**
* Android only.
*
* Determines whether the handler should check for an existing touch event on
* instantiation.
*/
shouldActivateOnStart?: boolean;
/**
* When `true`, cancels all other gesture handlers when this
* `NativeViewGestureHandler` receives an `ACTIVE` state event.
*/
disallowInterruption?: boolean;
}
/**
* @deprecated NativeViewGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Native()` instead.
*/
export interface NativeViewGestureHandlerProps
extends BaseGestureHandlerProps<NativeViewGestureHandlerPayload>,
NativeViewGestureConfig {}
export const nativeViewProps = [
...baseGestureHandlerProps,
...nativeViewGestureHandlerProps,
] as const;
export const nativeViewHandlerName = 'NativeViewGestureHandler';
/**
* @deprecated NativeViewGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Native()` instead.
*/
export type NativeViewGestureHandler = typeof NativeViewGestureHandler;
/**
* @deprecated NativeViewGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Native()` instead.
*/
// eslint-disable-next-line @typescript-eslint/no-redeclare -- backward compatibility; see description on the top of gestureHandlerCommon.ts file
export const NativeViewGestureHandler = createHandler<
NativeViewGestureHandlerProps,
NativeViewGestureHandlerPayload
>({
name: nativeViewHandlerName,
allowedProps: nativeViewProps,
config: {},
});
@@ -0,0 +1,282 @@
import type { PanGestureHandlerEventPayload } from './GestureHandlerEventPayload';
import createHandler from './createHandler';
import {
BaseGestureHandlerProps,
baseGestureHandlerProps,
} from './gestureHandlerCommon';
export const panGestureHandlerProps = [
'activeOffsetY',
'activeOffsetX',
'failOffsetY',
'failOffsetX',
'minDist',
'minVelocity',
'minVelocityX',
'minVelocityY',
'minPointers',
'maxPointers',
'avgTouches',
'enableTrackpadTwoFingerGesture',
'activateAfterLongPress',
] as const;
export const panGestureHandlerCustomNativeProps = [
'activeOffsetYStart',
'activeOffsetYEnd',
'activeOffsetXStart',
'activeOffsetXEnd',
'failOffsetYStart',
'failOffsetYEnd',
'failOffsetXStart',
'failOffsetXEnd',
] as const;
interface CommonPanProperties {
/**
* Minimum distance the finger (or multiple finger) need to travel before the
* handler activates. Expressed in points.
*/
minDist?: number;
/**
* Android only.
*/
avgTouches?: boolean;
/**
* 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;
/**
* A number of fingers that is required to be placed before handler can
* activate. Should be a higher or equal to 0 integer.
*/
minPointers?: number;
/**
* When the given number of fingers is placed on the screen and handler hasn't
* yet activated it will fail recognizing the gesture. Should be a higher or
* equal to 0 integer.
*/
maxPointers?: number;
minVelocity?: number;
minVelocityX?: number;
minVelocityY?: number;
activateAfterLongPress?: number;
}
export interface PanGestureConfig extends CommonPanProperties {
activeOffsetYStart?: number;
activeOffsetYEnd?: number;
activeOffsetXStart?: number;
activeOffsetXEnd?: number;
failOffsetYStart?: number;
failOffsetYEnd?: number;
failOffsetXStart?: number;
failOffsetXEnd?: number;
}
/**
* @deprecated PanGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Pan()` instead.
*/
export interface PanGestureHandlerProps
extends BaseGestureHandlerProps<PanGestureHandlerEventPayload>,
CommonPanProperties {
/**
* Range along X axis (in points) where fingers travels without activation of
* handler. Moving outside of this range implies activation of handler. Range
* can be given as an array or a single number. If range is set as an array,
* first value must be lower or equal to 0, a the second one higher or equal
* to 0. If only one number `p` is given a range of `(-inf, p)` will be used
* if `p` is higher or equal to 0 and `(-p, inf)` otherwise.
*/
activeOffsetY?:
| number
| [activeOffsetYStart: number, activeOffsetYEnd: number];
/**
* Range along X axis (in points) where fingers travels without activation of
* handler. Moving outside of this range implies activation of handler. Range
* can be given as an array or a single number. If range is set as an array,
* first value must be lower or equal to 0, a the second one higher or equal
* to 0. If only one number `p` is given a range of `(-inf, p)` will be used
* if `p` is higher or equal to 0 and `(-p, inf)` otherwise.
*/
activeOffsetX?:
| number
| [activeOffsetXStart: number, activeOffsetXEnd: number];
/**
* When the finger moves outside this range (in points) along Y axis and
* handler hasn't yet activated it will fail recognizing the gesture. Range
* can be given as an array or a single number. If range is set as an array,
* first value must be lower or equal to 0, a the second one higher or equal
* to 0. If only one number `p` is given a range of `(-inf, p)` will be used
* if `p` is higher or equal to 0 and `(-p, inf)` otherwise.
*/
failOffsetY?: number | [failOffsetYStart: number, failOffsetYEnd: number];
/**
* When the finger moves outside this range (in points) along X axis and
* handler hasn't yet activated it will fail recognizing the gesture. Range
* can be given as an array or a single number. If range is set as an array,
* first value must be lower or equal to 0, a the second one higher or equal
* to 0. If only one number `p` is given a range of `(-inf, p)` will be used
* if `p` is higher or equal to 0 and `(-p, inf)` otherwise.
*/
failOffsetX?: number | [failOffsetXStart: number, failOffsetXEnd: number];
}
export const panHandlerName = 'PanGestureHandler';
/**
* @deprecated PanGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Pan()` instead.
*/
export type PanGestureHandler = typeof PanGestureHandler;
/**
* @deprecated PanGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Pan()` instead.
*/
// eslint-disable-next-line @typescript-eslint/no-redeclare -- backward compatibility; see description on the top of gestureHandlerCommon.ts file
export const PanGestureHandler = createHandler<
PanGestureHandlerProps,
PanGestureHandlerEventPayload
>({
name: panHandlerName,
allowedProps: [
...baseGestureHandlerProps,
...panGestureHandlerProps,
] as const,
config: {},
transformProps: managePanProps,
customNativeProps: panGestureHandlerCustomNativeProps,
});
function validatePanGestureHandlerProps(props: PanGestureHandlerProps) {
if (
Array.isArray(props.activeOffsetX) &&
(props.activeOffsetX[0] > 0 || props.activeOffsetX[1] < 0)
) {
throw new Error(
`First element of activeOffsetX should be negative, a the second one should be positive`
);
}
if (
Array.isArray(props.activeOffsetY) &&
(props.activeOffsetY[0] > 0 || props.activeOffsetY[1] < 0)
) {
throw new Error(
`First element of activeOffsetY should be negative, a the second one should be positive`
);
}
if (
Array.isArray(props.failOffsetX) &&
(props.failOffsetX[0] > 0 || props.failOffsetX[1] < 0)
) {
throw new Error(
`First element of failOffsetX should be negative, a the second one should be positive`
);
}
if (
Array.isArray(props.failOffsetY) &&
(props.failOffsetY[0] > 0 || props.failOffsetY[1] < 0)
) {
throw new Error(
`First element of failOffsetY should be negative, a the second one should be positive`
);
}
if (props.minDist && (props.failOffsetX || props.failOffsetY)) {
throw new Error(
`It is not supported to use minDist with failOffsetX or failOffsetY, use activeOffsetX and activeOffsetY instead`
);
}
if (props.minDist && (props.activeOffsetX || props.activeOffsetY)) {
throw new Error(
`It is not supported to use minDist with activeOffsetX or activeOffsetY`
);
}
}
function transformPanGestureHandlerProps(props: PanGestureHandlerProps) {
type InternalPanGHKeys =
| 'activeOffsetXStart'
| 'activeOffsetXEnd'
| 'failOffsetXStart'
| 'failOffsetXEnd'
| 'activeOffsetYStart'
| 'activeOffsetYEnd'
| 'failOffsetYStart'
| 'failOffsetYEnd';
type PanGestureHandlerInternalProps = PanGestureHandlerProps &
Partial<Record<InternalPanGHKeys, number>>;
const res: PanGestureHandlerInternalProps = { ...props };
if (props.activeOffsetX !== undefined) {
delete res.activeOffsetX;
if (Array.isArray(props.activeOffsetX)) {
res.activeOffsetXStart = props.activeOffsetX[0];
res.activeOffsetXEnd = props.activeOffsetX[1];
} else if (props.activeOffsetX < 0) {
res.activeOffsetXStart = props.activeOffsetX;
} else {
res.activeOffsetXEnd = props.activeOffsetX;
}
}
if (props.activeOffsetY !== undefined) {
delete res.activeOffsetY;
if (Array.isArray(props.activeOffsetY)) {
res.activeOffsetYStart = props.activeOffsetY[0];
res.activeOffsetYEnd = props.activeOffsetY[1];
} else if (props.activeOffsetY < 0) {
res.activeOffsetYStart = props.activeOffsetY;
} else {
res.activeOffsetYEnd = props.activeOffsetY;
}
}
if (props.failOffsetX !== undefined) {
delete res.failOffsetX;
if (Array.isArray(props.failOffsetX)) {
res.failOffsetXStart = props.failOffsetX[0];
res.failOffsetXEnd = props.failOffsetX[1];
} else if (props.failOffsetX < 0) {
res.failOffsetXStart = props.failOffsetX;
} else {
res.failOffsetXEnd = props.failOffsetX;
}
}
if (props.failOffsetY !== undefined) {
delete res.failOffsetY;
if (Array.isArray(props.failOffsetY)) {
res.failOffsetYStart = props.failOffsetY[0];
res.failOffsetYEnd = props.failOffsetY[1];
} else if (props.failOffsetY < 0) {
res.failOffsetYStart = props.failOffsetY;
} else {
res.failOffsetYEnd = props.failOffsetY;
}
}
return res;
}
export function managePanProps(props: PanGestureHandlerProps) {
if (__DEV__) {
validatePanGestureHandlerProps(props);
}
return transformPanGestureHandlerProps(props);
}
@@ -0,0 +1,32 @@
import { PinchGestureHandlerEventPayload } from './GestureHandlerEventPayload';
import createHandler from './createHandler';
import {
BaseGestureHandlerProps,
baseGestureHandlerProps,
} from './gestureHandlerCommon';
/**
* @deprecated PinchGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Pinch()` instead.
*/
export interface PinchGestureHandlerProps
extends BaseGestureHandlerProps<PinchGestureHandlerEventPayload> {}
export const pinchHandlerName = 'PinchGestureHandler';
/**
* @deprecated PinchGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Pinch()` instead.
*/
export type PinchGestureHandler = typeof PinchGestureHandler;
/**
* @deprecated PinchGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Pinch()` instead.
*/
// eslint-disable-next-line @typescript-eslint/no-redeclare -- backward compatibility; see description on the top of gestureHandlerCommon.ts file
export const PinchGestureHandler = createHandler<
PinchGestureHandlerProps,
PinchGestureHandlerEventPayload
>({
name: pinchHandlerName,
allowedProps: baseGestureHandlerProps,
config: {},
});
@@ -0,0 +1,2 @@
// @ts-ignore it's not exported so we need to import it from path
export { PressabilityDebugView } from 'react-native/Libraries/Pressability/PressabilityDebug';
@@ -0,0 +1,4 @@
// PressabilityDebugView is not implemented in react-native-web
export function PressabilityDebugView() {
return null;
}
@@ -0,0 +1,32 @@
import { RotationGestureHandlerEventPayload } from './GestureHandlerEventPayload';
import createHandler from './createHandler';
import {
BaseGestureHandlerProps,
baseGestureHandlerProps,
} from './gestureHandlerCommon';
/**
* @deprecated RotationGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Rotation()` instead.
*/
export interface RotationGestureHandlerProps
extends BaseGestureHandlerProps<RotationGestureHandlerEventPayload> {}
export const rotationHandlerName = 'RotationGestureHandler';
/**
* @deprecated RotationGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Rotation()` instead.
*/
export type RotationGestureHandler = typeof RotationGestureHandler;
/**
* @deprecated RotationGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Rotation()` instead.
*/
// eslint-disable-next-line @typescript-eslint/no-redeclare -- backward compatibility; see description on the top of gestureHandlerCommon.ts file
export const RotationGestureHandler = createHandler<
RotationGestureHandlerProps,
RotationGestureHandlerEventPayload
>({
name: rotationHandlerName,
allowedProps: baseGestureHandlerProps,
config: {},
});
@@ -0,0 +1,99 @@
import type { TapGestureHandlerEventPayload } from './GestureHandlerEventPayload';
import createHandler from './createHandler';
import {
BaseGestureHandlerProps,
baseGestureHandlerProps,
} from './gestureHandlerCommon';
export const tapGestureHandlerProps = [
'maxDurationMs',
'maxDelayMs',
'numberOfTaps',
'maxDeltaX',
'maxDeltaY',
'maxDist',
'minPointers',
] as const;
export interface TapGestureConfig {
/**
* Minimum number of pointers (fingers) required to be placed before the
* handler activates. Should be a positive integer.
* The default value is 1.
*/
minPointers?: number;
/**
* Maximum time, expressed in milliseconds, that defines how fast a finger
* must be released after a touch. The default value is 500.
*/
maxDurationMs?: number;
/**
* Maximum time, expressed in milliseconds, that can pass before the next tap
* if many taps are required. The default value is 500.
*/
maxDelayMs?: number;
/**
* Number of tap gestures required to activate the handler. The default value
* is 1.
*/
numberOfTaps?: number;
/**
* Maximum distance, expressed in points, that defines how far the finger is
* allowed to travel along the X axis during a tap gesture. If the finger
* travels further than the defined distance along the X axis and the handler
* hasn't yet activated, it will fail to recognize the gesture.
*/
maxDeltaX?: number;
/**
* Maximum distance, expressed in points, that defines how far the finger is
* allowed to travel along the Y axis during a tap gesture. If the finger
* travels further than the defined distance along the Y axis and the handler
* hasn't yet activated, it will fail to recognize the gesture.
*/
maxDeltaY?: number;
/**
* Maximum distance, expressed in points, that defines how far the finger is
* allowed to travel during a tap gesture. If the finger travels further than
* the defined distance and the handler hasn't yet
* activated, it will fail to recognize the gesture.
*/
maxDist?: number;
}
/**
* @deprecated TapGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Tap()` instead.
*/
export interface TapGestureHandlerProps
extends BaseGestureHandlerProps<TapGestureHandlerEventPayload>,
TapGestureConfig {}
export const tapHandlerName = 'TapGestureHandler';
/**
* @deprecated TapGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Tap()` instead.
*/
export type TapGestureHandler = typeof TapGestureHandler;
/**
* @deprecated TapGestureHandler will be removed in the future version of Gesture Handler. Use `Gesture.Tap()` instead.
*/
// eslint-disable-next-line @typescript-eslint/no-redeclare -- backward compatibility; see description on the top of gestureHandlerCommon.ts file
export const TapGestureHandler = createHandler<
TapGestureHandlerProps,
TapGestureHandlerEventPayload
>({
name: tapHandlerName,
allowedProps: [
...baseGestureHandlerProps,
...tapGestureHandlerProps,
] as const,
config: {
shouldCancelWhenOutside: true,
},
});
@@ -0,0 +1,572 @@
import * as React from 'react';
import {
Platform,
UIManager,
DeviceEventEmitter,
EmitterSubscription,
} from 'react-native';
import { customDirectEventTypes } from './customDirectEventTypes';
import RNGestureHandlerModule from '../RNGestureHandlerModule';
import { State } from '../State';
import {
handlerIDToTag,
registerOldGestureHandler,
unregisterOldGestureHandler,
} from './handlersRegistry';
import { getNextHandlerTag } from './getNextHandlerTag';
import {
BaseGestureHandlerProps,
GestureEvent,
HandlerStateChangeEvent,
} from './gestureHandlerCommon';
import { filterConfig, scheduleFlushOperations } from './utils';
import findNodeHandle from '../findNodeHandle';
import { ValueOf } from '../typeUtils';
import {
deepEqual,
isFabric,
isReact19,
isTestEnv,
tagMessage,
} from '../utils';
import { ActionType } from '../ActionType';
import { PressabilityDebugView } from './PressabilityDebugView';
import GestureHandlerRootViewContext from '../GestureHandlerRootViewContext';
import { ghQueueMicrotask } from '../ghQueueMicrotask';
import { MountRegistry } from '../mountRegistry';
import { ReactElement } from 'react';
const UIManagerAny = UIManager as any;
customDirectEventTypes.topGestureHandlerEvent = {
registrationName: 'onGestureHandlerEvent',
};
const customGHEventsConfigFabricAndroid = {
topOnGestureHandlerEvent: { registrationName: 'onGestureHandlerEvent' },
topOnGestureHandlerStateChange: {
registrationName: 'onGestureHandlerStateChange',
},
};
const customGHEventsConfig = {
onGestureHandlerEvent: { registrationName: 'onGestureHandlerEvent' },
onGestureHandlerStateChange: {
registrationName: 'onGestureHandlerStateChange',
},
// When using React Native Gesture Handler for Animated.event with useNativeDriver: true
// on Android with Fabric enabled, the native part still sends the native events to JS
// but prefixed with "top". We cannot simply rename the events above so they are prefixed
// with "top" instead of "on" because in such case Animated.events would not be registered.
// That's why we need to register another pair of event names.
// The incoming events will be queued but never handled.
// Without this piece of code below, you'll get the following JS error:
// Unsupported top level event type "topOnGestureHandlerEvent" dispatched
...(isFabric() &&
Platform.OS === 'android' &&
customGHEventsConfigFabricAndroid),
};
// Add gesture specific events to genericDirectEventTypes object exported from UIManager
// native module.
// Once new event types are registered with react it is possible to dispatch these
// events to all kind of native views.
UIManagerAny.genericDirectEventTypes = {
...UIManagerAny.genericDirectEventTypes,
...customGHEventsConfig,
};
const UIManagerConstants = UIManagerAny.getViewManagerConfig?.('getConstants');
if (UIManagerConstants) {
UIManagerConstants.genericDirectEventTypes = {
...UIManagerConstants.genericDirectEventTypes,
...customGHEventsConfig,
};
}
// Wrap JS responder calls and notify gesture handler manager
const {
setJSResponder: oldSetJSResponder = () => {
// no-op
},
clearJSResponder: oldClearJSResponder = () => {
// no-op
},
} = UIManagerAny;
UIManagerAny.setJSResponder = (tag: number, blockNativeResponder: boolean) => {
RNGestureHandlerModule.handleSetJSResponder(tag, blockNativeResponder);
oldSetJSResponder(tag, blockNativeResponder);
};
UIManagerAny.clearJSResponder = () => {
RNGestureHandlerModule.handleClearJSResponder();
oldClearJSResponder();
};
let allowTouches = true;
const DEV_ON_ANDROID = __DEV__ && Platform.OS === 'android';
// Toggled inspector blocks touch events in order to allow inspecting on Android
// This needs to be a global variable in order to set initial state for `allowTouches` property in Handler component
if (DEV_ON_ANDROID) {
DeviceEventEmitter.addListener('toggleElementInspector', () => {
allowTouches = !allowTouches;
});
}
type HandlerProps<T extends Record<string, unknown>> = Readonly<
React.PropsWithChildren<BaseGestureHandlerProps<T>>
>;
function hasUnresolvedRefs<T extends Record<string, unknown>>(
props: HandlerProps<T>
) {
// TODO(TS) - add type for extract arg
const extract = (refs: any | any[]) => {
if (!Array.isArray(refs)) {
return refs && refs.current === null;
}
return refs.some((r) => r && r.current === null);
};
return extract(props['simultaneousHandlers']) || extract(props['waitFor']);
}
const stateToPropMappings = {
[State.UNDETERMINED]: undefined,
[State.BEGAN]: 'onBegan',
[State.FAILED]: 'onFailed',
[State.CANCELLED]: 'onCancelled',
[State.ACTIVE]: 'onActivated',
[State.END]: 'onEnded',
} as const;
type CreateHandlerArgs<HandlerPropsT extends Record<string, unknown>> =
Readonly<{
name: string;
allowedProps: Readonly<Extract<keyof HandlerPropsT, string>[]>;
config: Readonly<Record<string, unknown>>;
transformProps?: (props: HandlerPropsT) => HandlerPropsT;
customNativeProps?: Readonly<string[]>;
}>;
// TODO(TS) fix event types
type InternalEventHandlers = {
onGestureHandlerEvent?: (event: any) => void;
onGestureHandlerStateChange?: (event: any) => void;
};
type AttachGestureHandlerWeb = (
handlerTag: number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
newView: any,
_actionType: ActionType,
propsRef: React.RefObject<unknown>
) => void;
const UNRESOLVED_REFS_RETRY_LIMIT = 1;
// TODO(TS) - make sure that BaseGestureHandlerProps doesn't need other generic parameter to work with custom properties.
export default function createHandler<
T extends BaseGestureHandlerProps<U>,
U extends Record<string, unknown>,
>({
name,
allowedProps = [],
config = {},
transformProps,
customNativeProps = [],
}: CreateHandlerArgs<T>): React.ComponentType<T & React.RefAttributes<any>> {
interface HandlerState {
allowTouches: boolean;
}
class Handler extends React.Component<
T & InternalEventHandlers,
HandlerState
> {
static displayName = name;
static contextType = GestureHandlerRootViewContext;
private handlerTag = -1;
private config: Record<string, unknown>;
private propsRef: React.MutableRefObject<unknown>;
private isMountedRef: React.MutableRefObject<boolean | null>;
private viewNode: any;
private viewTag?: number;
private inspectorToggleListener?: EmitterSubscription;
constructor(props: T & InternalEventHandlers) {
super(props);
this.config = {};
this.propsRef = React.createRef();
this.isMountedRef = React.createRef();
this.state = { allowTouches };
if (props.id) {
if (handlerIDToTag[props.id] !== undefined) {
throw new Error(`Handler with ID "${props.id}" already registered`);
}
handlerIDToTag[props.id] = this.handlerTag;
}
}
componentDidMount() {
const props: HandlerProps<U> = this.props;
this.isMountedRef.current = true;
if (DEV_ON_ANDROID) {
this.inspectorToggleListener = DeviceEventEmitter.addListener(
'toggleElementInspector',
() => {
this.setState((_) => ({ allowTouches }));
this.update(UNRESOLVED_REFS_RETRY_LIMIT);
}
);
}
if (hasUnresolvedRefs(props)) {
// If there are unresolved refs (e.g. ".current" has not yet been set)
// passed as `simultaneousHandlers` or `waitFor`, we enqueue a call to
// _update method that will try to update native handler props using
// queueMicrotask. This makes it so update() function gets called after all
// react components are mounted and we expect the missing ref object to
// be resolved by then.
ghQueueMicrotask(() => {
this.update(UNRESOLVED_REFS_RETRY_LIMIT);
});
}
this.createGestureHandler(
filterConfig(
transformProps ? transformProps(this.props) : this.props,
[...allowedProps, ...customNativeProps],
config
)
);
if (!this.viewNode) {
throw new Error(
`[Gesture Handler] Failed to obtain view for ${Handler.displayName}. Note that old API doesn't support functional components.`
);
}
this.attachGestureHandler(findNodeHandle(this.viewNode) as number); // TODO(TS) - check if this can be null
}
componentDidUpdate() {
const viewTag = findNodeHandle(this.viewNode);
if (this.viewTag !== viewTag) {
this.attachGestureHandler(viewTag as number); // TODO(TS) - check interaction between _viewTag & findNodeHandle
}
this.update(UNRESOLVED_REFS_RETRY_LIMIT);
}
componentWillUnmount() {
this.inspectorToggleListener?.remove();
this.isMountedRef.current = false;
if (Platform.OS !== 'web') {
unregisterOldGestureHandler(this.handlerTag);
}
RNGestureHandlerModule.dropGestureHandler(this.handlerTag);
scheduleFlushOperations();
// We can't use this.props.id directly due to TS generic type narrowing bug, see https://github.com/microsoft/TypeScript/issues/13995 for more context
const handlerID: string | undefined = this.props.id;
if (handlerID) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete handlerIDToTag[handlerID];
}
MountRegistry.gestureHandlerWillUnmount(this);
}
private onGestureHandlerEvent = (event: GestureEvent<U>) => {
if (event.nativeEvent.handlerTag === this.handlerTag) {
if (typeof this.props.onGestureEvent === 'function') {
this.props.onGestureEvent?.(event);
}
} else {
this.props.onGestureHandlerEvent?.(event);
}
};
// TODO(TS) - make sure this is right type for event
private onGestureHandlerStateChange = (
event: HandlerStateChangeEvent<U>
) => {
if (event.nativeEvent.handlerTag === this.handlerTag) {
if (typeof this.props.onHandlerStateChange === 'function') {
this.props.onHandlerStateChange?.(event);
}
const state: ValueOf<typeof State> = event.nativeEvent.state;
const stateEventName = stateToPropMappings[state];
const eventHandler = stateEventName && this.props[stateEventName];
if (eventHandler && typeof eventHandler === 'function') {
eventHandler(event);
}
} else {
this.props.onGestureHandlerStateChange?.(event);
}
};
private refHandler = (node: any) => {
this.viewNode = node;
const child = React.Children.only(this.props.children);
// @ts-ignore Since React 19 ref is accessible as standard prop
// https://react.dev/blog/2024/04/25/react-19-upgrade-guide#deprecated-element-ref
const ref = isReact19() ? (child as ReactElement).props?.ref : child?.ref;
if (!ref) {
return;
}
if (typeof ref === 'function') {
ref(node);
} else {
ref.current = node;
}
};
private createGestureHandler = (
newConfig: Readonly<Record<string, unknown>>
) => {
this.handlerTag = getNextHandlerTag();
this.config = newConfig;
RNGestureHandlerModule.createGestureHandler(
name,
this.handlerTag,
newConfig
);
};
private attachGestureHandler = (newViewTag: number) => {
this.viewTag = newViewTag;
if (Platform.OS === 'web') {
// Typecast due to dynamic resolution, attachGestureHandler should have web version signature in this branch
(
RNGestureHandlerModule.attachGestureHandler as AttachGestureHandlerWeb
)(
this.handlerTag,
newViewTag,
ActionType.JS_FUNCTION_OLD_API, // ignored on web
this.propsRef
);
} else {
registerOldGestureHandler(this.handlerTag, {
onGestureEvent: this.onGestureHandlerEvent,
onGestureStateChange: this.onGestureHandlerStateChange,
});
const actionType = (() => {
const onGestureEvent = this.props?.onGestureEvent;
const isGestureHandlerWorklet =
onGestureEvent &&
('current' in onGestureEvent ||
'workletEventHandler' in onGestureEvent);
const onHandlerStateChange = this.props?.onHandlerStateChange;
const isStateChangeHandlerWorklet =
onHandlerStateChange &&
('current' in onHandlerStateChange ||
'workletEventHandler' in onHandlerStateChange);
const isReanimatedHandler =
isGestureHandlerWorklet || isStateChangeHandlerWorklet;
if (isReanimatedHandler) {
// Reanimated worklet
return ActionType.REANIMATED_WORKLET;
} else if (onGestureEvent && '__isNative' in onGestureEvent) {
// Animated.event with useNativeDriver: true
return ActionType.NATIVE_ANIMATED_EVENT;
} else {
// JS callback or Animated.event with useNativeDriver: false
return ActionType.JS_FUNCTION_OLD_API;
}
})();
RNGestureHandlerModule.attachGestureHandler(
this.handlerTag,
newViewTag,
actionType
);
}
scheduleFlushOperations();
ghQueueMicrotask(() => {
MountRegistry.gestureHandlerWillMount(this);
});
};
private updateGestureHandler = (
newConfig: Readonly<Record<string, unknown>>
) => {
this.config = newConfig;
RNGestureHandlerModule.updateGestureHandler(this.handlerTag, newConfig);
scheduleFlushOperations();
};
private update(remainingTries: number) {
if (!this.isMountedRef.current) {
return;
}
const props: HandlerProps<U> = this.props;
// When ref is set via a function i.e. `ref={(r) => refObject.current = r}` instead of
// `ref={refObject}` it's possible that it won't be resolved in time. Seems like trying
// again is easy enough fix.
if (hasUnresolvedRefs(props) && remainingTries > 0) {
ghQueueMicrotask(() => {
this.update(remainingTries - 1);
});
} else {
const newConfig = filterConfig(
transformProps ? transformProps(this.props) : this.props,
[...allowedProps, ...customNativeProps],
config
);
if (!deepEqual(this.config, newConfig)) {
this.updateGestureHandler(newConfig);
}
}
}
// eslint-disable-next-line @eslint-react/no-unused-class-component-members
setNativeProps(updates: any) {
const mergedProps = { ...this.props, ...updates };
const newConfig = filterConfig(
transformProps ? transformProps(mergedProps) : mergedProps,
[...allowedProps, ...customNativeProps],
config
);
this.updateGestureHandler(newConfig);
}
render() {
if (__DEV__ && !this.context && !isTestEnv() && Platform.OS !== 'web') {
throw new Error(
name +
' must be used as a descendant of GestureHandlerRootView. Otherwise the gestures will not be recognized. See https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/installation for more details.'
);
}
let gestureEventHandler = this.onGestureHandlerEvent;
// Another instance of https://github.com/microsoft/TypeScript/issues/13995
type OnGestureEventHandlers = {
onGestureEvent?: BaseGestureHandlerProps<U>['onGestureEvent'];
onGestureHandlerEvent?: InternalEventHandlers['onGestureHandlerEvent'];
};
const { onGestureEvent, onGestureHandlerEvent }: OnGestureEventHandlers =
this.props;
if (onGestureEvent && typeof onGestureEvent !== 'function') {
// If it's not a method it should be an native Animated.event
// object. We set it directly as the handler for the view
// In this case nested handlers are not going to be supported
if (onGestureHandlerEvent) {
throw new Error(
'Nesting touch handlers with native animated driver is not supported yet'
);
}
gestureEventHandler = onGestureEvent;
} else {
if (
onGestureHandlerEvent &&
typeof onGestureHandlerEvent !== 'function'
) {
throw new Error(
'Nesting touch handlers with native animated driver is not supported yet'
);
}
}
let gestureStateEventHandler = this.onGestureHandlerStateChange;
// Another instance of https://github.com/microsoft/TypeScript/issues/13995
type OnGestureStateChangeHandlers = {
onHandlerStateChange?: BaseGestureHandlerProps<U>['onHandlerStateChange'];
onGestureHandlerStateChange?: InternalEventHandlers['onGestureHandlerStateChange'];
};
const {
onHandlerStateChange,
onGestureHandlerStateChange,
}: OnGestureStateChangeHandlers = this.props;
if (onHandlerStateChange && typeof onHandlerStateChange !== 'function') {
// If it's not a method it should be an native Animated.event
// object. We set it directly as the handler for the view
// In this case nested handlers are not going to be supported
if (onGestureHandlerStateChange) {
throw new Error(
'Nesting touch handlers with native animated driver is not supported yet'
);
}
gestureStateEventHandler = onHandlerStateChange;
} else {
if (
onGestureHandlerStateChange &&
typeof onGestureHandlerStateChange !== 'function'
) {
throw new Error(
'Nesting touch handlers with native animated driver is not supported yet'
);
}
}
const events = {
onGestureHandlerEvent: this.state.allowTouches
? gestureEventHandler
: undefined,
onGestureHandlerStateChange: this.state.allowTouches
? gestureStateEventHandler
: undefined,
};
this.propsRef.current = events;
let child: any = null;
try {
child = React.Children.only(this.props.children);
} catch (e) {
throw new Error(
tagMessage(
`${name} got more than one view as a child. If you want the gesture to work on multiple views, wrap them with a common parent and attach the gesture to that view.`
)
);
}
let grandChildren = child.props.children;
if (
__DEV__ &&
child.type &&
(child.type === 'RNGestureHandlerButton' ||
child.type.name === 'View' ||
child.type.displayName === 'View')
) {
grandChildren = React.Children.toArray(grandChildren);
grandChildren.push(
<PressabilityDebugView
key="pressabilityDebugView"
color="mediumspringgreen"
hitSlop={child.props.hitSlop}
/>
);
}
return React.cloneElement(
child,
{
ref: this.refHandler,
collapsable: false,
...(isTestEnv()
? {
handlerType: name,
handlerTag: this.handlerTag,
enabled: this.props.enabled,
}
: {}),
testID: this.props.testID ?? child.props.testID,
...events,
},
grandChildren
);
}
}
return Handler;
}
@@ -0,0 +1,90 @@
import * as React from 'react';
import { useImperativeHandle, useRef } from 'react';
import {
NativeViewGestureHandler,
NativeViewGestureHandlerProps,
nativeViewProps,
} from './NativeViewGestureHandler';
/*
* This array should consist of:
* - All keys in propTypes from NativeGestureHandler
* (and all keys in GestureHandlerPropTypes)
* - 'onGestureHandlerEvent'
* - 'onGestureHandlerStateChange'
*/
const NATIVE_WRAPPER_PROPS_FILTER = [
...nativeViewProps,
'onGestureHandlerEvent',
'onGestureHandlerStateChange',
] as const;
export default function createNativeWrapper<P>(
Component: React.ComponentType<P>,
config: Readonly<NativeViewGestureHandlerProps> = {}
) {
const ComponentWrapper = React.forwardRef<
React.ComponentType<any>,
P & NativeViewGestureHandlerProps
>((props, ref) => {
// Filter out props that should be passed to gesture handler wrapper
const { gestureHandlerProps, childProps } = Object.keys(props).reduce(
(res, key) => {
// TS being overly protective with it's types, see https://github.com/microsoft/TypeScript/issues/26255#issuecomment-458013731 for more info
const allowedKeys: readonly string[] = NATIVE_WRAPPER_PROPS_FILTER;
if (allowedKeys.includes(key)) {
// @ts-ignore FIXME(TS)
res.gestureHandlerProps[key] = props[key];
} else {
// @ts-ignore FIXME(TS)
res.childProps[key] = props[key];
}
return res;
},
{
gestureHandlerProps: { ...config }, // Watch out not to modify config
childProps: {
enabled: props.enabled,
hitSlop: props.hitSlop,
testID: props.testID,
} as P,
}
);
const _ref = useRef<React.ComponentType<P>>(null);
const _gestureHandlerRef = useRef<React.ComponentType<P>>(null);
useImperativeHandle(
ref,
// @ts-ignore TODO(TS) decide how nulls work in this context
() => {
const node = _gestureHandlerRef.current;
// Add handlerTag for relations config
if (_ref.current && node) {
// @ts-ignore FIXME(TS) think about createHandler return type
_ref.current.handlerTag = node.handlerTag;
return _ref.current;
}
return null;
},
[_ref, _gestureHandlerRef]
);
return (
<NativeViewGestureHandler
{...gestureHandlerProps}
// @ts-ignore TODO(TS)
ref={_gestureHandlerRef}>
<Component {...childProps} ref={_ref} />
</NativeViewGestureHandler>
);
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
ComponentWrapper.displayName =
Component?.displayName ||
// @ts-ignore if render doesn't exist it will return undefined and go further
Component?.render?.name ||
(typeof Component === 'string' && Component) ||
'ComponentWrapper';
return ComponentWrapper;
}
@@ -0,0 +1,2 @@
// @ts-ignore - its taken straight from RN
export { customDirectEventTypes } from 'react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry';
@@ -0,0 +1,5 @@
// customDirectEventTypes doesn't exist in react-native-web, therefore importing it
// directly in createHandler.tsx would end in crash.
const customDirectEventTypes = {};
export { customDirectEventTypes };
@@ -0,0 +1,211 @@
// Previous types exported gesture handlers as classes which creates an interface and variable, both named the same as class.
// Without those types, we'd introduce breaking change, forcing users to prefix every handler type specification with typeof
// e.g. React.createRef<TapGestureHandler> -> React.createRef<typeof TapGestureHandler>.
// See https://www.typescriptlang.org/docs/handbook/classes.html#constructor-functions for reference.
import * as React from 'react';
import { State } from '../State';
import { TouchEventType } from '../TouchEventType';
import { ValueOf } from '../typeUtils';
import { PointerType } from '../PointerType';
const commonProps = [
'id',
'enabled',
'shouldCancelWhenOutside',
'hitSlop',
'cancelsTouchesInView',
'userSelect',
'activeCursor',
'mouseButton',
'enableContextMenu',
'touchAction',
] as const;
const componentInteractionProps = [
'waitFor',
'simultaneousHandlers',
'blocksHandlers',
] as const;
export const baseGestureHandlerProps = [
...commonProps,
...componentInteractionProps,
'onBegan',
'onFailed',
'onCancelled',
'onActivated',
'onEnded',
'onGestureEvent',
'onHandlerStateChange',
] as const;
export const baseGestureHandlerWithDetectorProps = [
...commonProps,
'needsPointerData',
'manualActivation',
];
export interface GestureEventPayload {
handlerTag: number;
numberOfPointers: number;
state: ValueOf<typeof State>;
pointerType: PointerType;
}
export interface HandlerStateChangeEventPayload extends GestureEventPayload {
oldState: ValueOf<typeof State>;
}
export type HitSlop =
| number
| null
| undefined
| Partial<
Record<
'left' | 'right' | 'top' | 'bottom' | 'vertical' | 'horizontal',
number
>
>
| Record<'width' | 'left', number>
| Record<'width' | 'right', number>
| Record<'height' | 'top', number>
| Record<'height' | 'bottom', number>;
export type UserSelect = 'none' | 'auto' | 'text';
export type ActiveCursor =
| 'auto'
| 'default'
| 'none'
| 'context-menu'
| 'help'
| 'pointer'
| 'progress'
| 'wait'
| 'cell'
| 'crosshair'
| 'text'
| 'vertical-text'
| 'alias'
| 'copy'
| 'move'
| 'no-drop'
| 'not-allowed'
| 'grab'
| 'grabbing'
| 'e-resize'
| 'n-resize'
| 'ne-resize'
| 'nw-resize'
| 's-resize'
| 'se-resize'
| 'sw-resize'
| 'w-resize'
| 'ew-resize'
| 'ns-resize'
| 'nesw-resize'
| 'nwse-resize'
| 'col-resize'
| 'row-resize'
| 'all-scroll'
| 'zoom-in'
| 'zoom-out';
export enum MouseButton {
LEFT = 1,
RIGHT = 2,
MIDDLE = 4,
BUTTON_4 = 8,
BUTTON_5 = 16,
ALL = 31,
}
export type TouchAction =
| 'auto'
| 'none'
| 'pan-x'
| 'pan-left'
| 'pan-right'
| 'pan-y'
| 'pan-up'
| 'pan-down'
| 'pinch-zoom'
| 'manipulation'
| 'inherit'
| 'initial'
| 'revert'
| 'revert-layer'
| 'unset';
// TODO(TS) events in handlers
export interface GestureEvent<ExtraEventPayloadT = Record<string, unknown>> {
nativeEvent: Readonly<GestureEventPayload & ExtraEventPayloadT>;
}
export interface HandlerStateChangeEvent<
ExtraEventPayloadT = Record<string, unknown>,
> {
nativeEvent: Readonly<HandlerStateChangeEventPayload & ExtraEventPayloadT>;
}
export type TouchData = {
id: number;
x: number;
y: number;
absoluteX: number;
absoluteY: number;
};
export type GestureTouchEvent = {
handlerTag: number;
numberOfTouches: number;
state: ValueOf<typeof State>;
eventType: TouchEventType;
allTouches: TouchData[];
changedTouches: TouchData[];
pointerType: PointerType;
};
export type GestureUpdateEvent<GestureEventPayloadT = Record<string, unknown>> =
GestureEventPayload & GestureEventPayloadT;
export type GestureStateChangeEvent<
GestureStateChangeEventPayloadT = Record<string, unknown>,
> = HandlerStateChangeEventPayload & GestureStateChangeEventPayloadT;
export type CommonGestureConfig = {
enabled?: boolean;
shouldCancelWhenOutside?: boolean;
hitSlop?: HitSlop;
userSelect?: UserSelect;
activeCursor?: ActiveCursor;
mouseButton?: MouseButton;
enableContextMenu?: boolean;
touchAction?: TouchAction;
};
// Events payloads are types instead of interfaces due to TS limitation.
// See https://github.com/microsoft/TypeScript/issues/15300 for more info.
export type BaseGestureHandlerProps<
ExtraEventPayloadT extends Record<string, unknown> = Record<string, unknown>,
> = CommonGestureConfig & {
id?: string;
waitFor?: React.Ref<unknown> | React.Ref<unknown>[];
simultaneousHandlers?: React.Ref<unknown> | React.Ref<unknown>[];
blocksHandlers?: React.Ref<unknown> | React.Ref<unknown>[];
testID?: string;
cancelsTouchesInView?: boolean;
// TODO(TS) - fix event types
onBegan?: (event: HandlerStateChangeEvent) => void;
onFailed?: (event: HandlerStateChangeEvent) => void;
onCancelled?: (event: HandlerStateChangeEvent) => void;
onActivated?: (event: HandlerStateChangeEvent) => void;
onEnded?: (event: HandlerStateChangeEvent) => void;
// TODO(TS) consider using NativeSyntheticEvent
onGestureEvent?: (event: GestureEvent<ExtraEventPayloadT>) => void;
onHandlerStateChange?: (
event: HandlerStateChangeEvent<ExtraEventPayloadT>
) => void;
// Implicit `children` prop has been removed in @types/react^18.0.0
children?: React.ReactNode;
};
@@ -0,0 +1,101 @@
import type {
BaseButtonProps,
BorderlessButtonProps,
RawButtonProps,
RectButtonProps,
} from '../components/GestureButtonsProps';
import {
GestureEvent,
GestureEventPayload,
HandlerStateChangeEvent,
HandlerStateChangeEventPayload,
} from './gestureHandlerCommon';
import type { FlingGestureHandlerProps } from './FlingGestureHandler';
import type {
FlingGestureHandlerEventPayload,
ForceTouchGestureHandlerEventPayload,
LongPressGestureHandlerEventPayload,
PanGestureHandlerEventPayload,
PinchGestureHandlerEventPayload,
RotationGestureHandlerEventPayload,
TapGestureHandlerEventPayload,
NativeViewGestureHandlerPayload,
} from './GestureHandlerEventPayload';
import type { ForceTouchGestureHandlerProps } from './ForceTouchGestureHandler';
import type { LongPressGestureHandlerProps } from './LongPressGestureHandler';
import type { PanGestureHandlerProps } from './PanGestureHandler';
import type { PinchGestureHandlerProps } from './PinchGestureHandler';
import type { RotationGestureHandlerProps } from './RotationGestureHandler';
import type { TapGestureHandlerProps } from './TapGestureHandler';
import type { NativeViewGestureHandlerProps } from './NativeViewGestureHandler';
// Events
export type GestureHandlerGestureEventNativeEvent = GestureEventPayload;
export type GestureHandlerStateChangeNativeEvent =
HandlerStateChangeEventPayload;
export type GestureHandlerGestureEvent = GestureEvent;
export type GestureHandlerStateChangeEvent = HandlerStateChangeEvent;
// Gesture handlers events
export type NativeViewGestureHandlerGestureEvent =
GestureEvent<NativeViewGestureHandlerPayload>;
export type NativeViewGestureHandlerStateChangeEvent =
HandlerStateChangeEvent<NativeViewGestureHandlerPayload>;
export type TapGestureHandlerGestureEvent =
GestureEvent<TapGestureHandlerEventPayload>;
export type TapGestureHandlerStateChangeEvent =
HandlerStateChangeEvent<TapGestureHandlerEventPayload>;
/**
* @deprecated ForceTouchGestureHandler is deprecated and will be removed in the future.
*/
export type ForceTouchGestureHandlerGestureEvent =
GestureEvent<ForceTouchGestureHandlerEventPayload>;
/**
* @deprecated ForceTouchGestureHandler is deprecated and will be removed in the future.
*/
export type ForceTouchGestureHandlerStateChangeEvent =
HandlerStateChangeEvent<ForceTouchGestureHandlerEventPayload>;
export type LongPressGestureHandlerGestureEvent =
GestureEvent<LongPressGestureHandlerEventPayload>;
export type LongPressGestureHandlerStateChangeEvent =
HandlerStateChangeEvent<LongPressGestureHandlerEventPayload>;
export type PanGestureHandlerGestureEvent =
GestureEvent<PanGestureHandlerEventPayload>;
export type PanGestureHandlerStateChangeEvent =
HandlerStateChangeEvent<PanGestureHandlerEventPayload>;
export type PinchGestureHandlerGestureEvent =
GestureEvent<PinchGestureHandlerEventPayload>;
export type PinchGestureHandlerStateChangeEvent =
HandlerStateChangeEvent<PinchGestureHandlerEventPayload>;
export type RotationGestureHandlerGestureEvent =
GestureEvent<RotationGestureHandlerEventPayload>;
export type RotationGestureHandlerStateChangeEvent =
HandlerStateChangeEvent<RotationGestureHandlerEventPayload>;
export type FlingGestureHandlerGestureEvent =
GestureEvent<FlingGestureHandlerEventPayload>;
export type FlingGestureHandlerStateChangeEvent =
HandlerStateChangeEvent<FlingGestureHandlerEventPayload>;
// Handlers properties
export type NativeViewGestureHandlerProperties = NativeViewGestureHandlerProps;
export type TapGestureHandlerProperties = TapGestureHandlerProps;
export type LongPressGestureHandlerProperties = LongPressGestureHandlerProps;
export type PanGestureHandlerProperties = PanGestureHandlerProps;
export type PinchGestureHandlerProperties = PinchGestureHandlerProps;
export type RotationGestureHandlerProperties = RotationGestureHandlerProps;
export type FlingGestureHandlerProperties = FlingGestureHandlerProps;
/**
* @deprecated ForceTouch gesture is deprecated and will be removed in the future.
*/
export type ForceTouchGestureHandlerProperties = ForceTouchGestureHandlerProps;
// Button props
export type RawButtonProperties = RawButtonProps;
export type BaseButtonProperties = BaseButtonProps;
export type RectButtonProperties = RectButtonProps;
export type BorderlessButtonProperties = BorderlessButtonProps;
@@ -0,0 +1,35 @@
import React from 'react';
import { Reanimated } from '../reanimatedWrapper';
import { tagMessage } from '../../../utils';
export class Wrap extends React.Component<{
onGestureHandlerEvent?: unknown;
// Implicit `children` prop has been removed in @types/react^18.0.0
children?: React.ReactNode;
}> {
render() {
try {
// I don't think that fighting with types over such a simple function is worth it
// The only thing it does is add 'collapsable: false' to the child component
// to make sure it is in the native view hierarchy so the detector can find
// correct viewTag to attach to.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const child: any = React.Children.only(this.props.children);
return React.cloneElement(
child,
{ collapsable: false },
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
child.props.children
);
} catch (e) {
throw new Error(
tagMessage(
`GestureDetector got more than one view as a child. If you want the gesture to work on multiple views, wrap them with a common parent and attach the gesture to that view.`
)
);
}
}
}
export const AnimatedWrap =
Reanimated?.default?.createAnimatedComponent(Wrap) ?? Wrap;
@@ -0,0 +1,42 @@
import React, { forwardRef } from 'react';
import type { LegacyRef, PropsWithChildren } from 'react';
import { tagMessage } from '../../../utils';
import { isRNSVGNode } from '../../../web/utils';
export const Wrap = forwardRef<HTMLDivElement, PropsWithChildren<{}>>(
({ children }, ref) => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const child: any = React.Children.only(children);
if (isRNSVGNode(child)) {
const clone = React.cloneElement(
child,
{ ref },
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
child.props.children
);
return clone;
}
return (
<div
ref={ref as LegacyRef<HTMLDivElement>}
style={{ display: 'contents' }}>
{child}
</div>
);
} catch (e) {
throw new Error(
tagMessage(
`GestureDetector got more than one view as a child. If you want the gesture to work on multiple views, wrap them with a common parent and attach the gesture to that view.`
)
);
}
}
);
// On web we never take a path with Reanimated,
// therefore we can simply export Wrap
export const AnimatedWrap = Wrap;
@@ -0,0 +1,112 @@
import React from 'react';
import { GestureType, HandlerCallbacks } from '../gesture';
import { registerHandler } from '../../handlersRegistry';
import RNGestureHandlerModule from '../../../RNGestureHandlerModule';
import { filterConfig, scheduleFlushOperations } from '../../utils';
import { ComposedGesture } from '../gestureComposition';
import { ActionType } from '../../../ActionType';
import { Platform } from 'react-native';
import type RNGestureHandlerModuleWeb from '../../../RNGestureHandlerModule.web';
import { ghQueueMicrotask } from '../../../ghQueueMicrotask';
import { AttachedGestureState, WebEventHandler } from './types';
import {
extractGestureRelations,
checkGestureCallbacksForWorklets,
ALLOWED_PROPS,
} from './utils';
import { MountRegistry } from '../../../mountRegistry';
interface AttachHandlersConfig {
preparedGesture: AttachedGestureState;
gestureConfig: ComposedGesture | GestureType;
gesturesToAttach: GestureType[];
viewTag: number;
webEventHandlersRef: React.RefObject<WebEventHandler>;
}
export function attachHandlers({
preparedGesture,
gestureConfig,
gesturesToAttach,
viewTag,
webEventHandlersRef,
}: AttachHandlersConfig) {
gestureConfig.initialize();
// Use queueMicrotask to extract handlerTags, because all refs should be initialized
// when it's ran
ghQueueMicrotask(() => {
if (!preparedGesture.isMounted) {
return;
}
gestureConfig.prepare();
});
for (const handler of gesturesToAttach) {
checkGestureCallbacksForWorklets(handler);
RNGestureHandlerModule.createGestureHandler(
handler.handlerName,
handler.handlerTag,
filterConfig(handler.config, ALLOWED_PROPS)
);
registerHandler(handler.handlerTag, handler, handler.config.testId);
}
// Use queueMicrotask to extract handlerTags, because all refs should be initialized
// when it's ran
ghQueueMicrotask(() => {
if (!preparedGesture.isMounted) {
return;
}
for (const handler of gesturesToAttach) {
RNGestureHandlerModule.updateGestureHandler(
handler.handlerTag,
filterConfig(
handler.config,
ALLOWED_PROPS,
extractGestureRelations(handler)
)
);
}
scheduleFlushOperations();
});
for (const gesture of gesturesToAttach) {
const actionType = gesture.shouldUseReanimated
? ActionType.REANIMATED_WORKLET
: ActionType.JS_FUNCTION_NEW_API;
if (Platform.OS === 'web') {
(
RNGestureHandlerModule.attachGestureHandler as typeof RNGestureHandlerModuleWeb.attachGestureHandler
)(
gesture.handlerTag,
viewTag,
ActionType.JS_FUNCTION_OLD_API, // Ignored on web
webEventHandlersRef
);
} else {
RNGestureHandlerModule.attachGestureHandler(
gesture.handlerTag,
viewTag,
actionType
);
}
MountRegistry.gestureWillMount(gesture);
}
preparedGesture.attachedGestures = gesturesToAttach;
if (preparedGesture.animatedHandlers) {
const isAnimatedGesture = (g: GestureType) => g.shouldUseReanimated;
preparedGesture.animatedHandlers.value = gesturesToAttach
.filter(isAnimatedGesture)
.map((g) => g.handlers) as unknown as HandlerCallbacks<
Record<string, unknown>
>[];
}
}
@@ -0,0 +1,17 @@
import { unregisterHandler } from '../../handlersRegistry';
import RNGestureHandlerModule from '../../../RNGestureHandlerModule';
import { scheduleFlushOperations } from '../../utils';
import { AttachedGestureState } from './types';
import { MountRegistry } from '../../../mountRegistry';
export function dropHandlers(preparedGesture: AttachedGestureState) {
for (const handler of preparedGesture.attachedGestures) {
RNGestureHandlerModule.dropGestureHandler(handler.handlerTag);
unregisterHandler(handler.handlerTag, handler.config.testId);
MountRegistry.gestureWillUnmount(handler);
}
scheduleFlushOperations();
}
@@ -0,0 +1,191 @@
/* eslint-disable react/no-unused-prop-types */
import React, {
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { Platform } from 'react-native';
import findNodeHandle from '../../../findNodeHandle';
import { GestureType } from '../gesture';
import { UserSelect, TouchAction } from '../../gestureHandlerCommon';
import { ComposedGesture } from '../gestureComposition';
import { isTestEnv } from '../../../utils';
import GestureHandlerRootViewContext from '../../../GestureHandlerRootViewContext';
import { AttachedGestureState, GestureDetectorState } from './types';
import { useAnimatedGesture } from './useAnimatedGesture';
import { attachHandlers } from './attachHandlers';
import { needsToReattach } from './needsToReattach';
import { dropHandlers } from './dropHandlers';
import { useWebEventHandlers } from './utils';
import { Wrap, AnimatedWrap } from './Wrap';
import { useDetectorUpdater } from './useDetectorUpdater';
import { useViewRefHandler } from './useViewRefHandler';
import { useMountReactions } from './useMountReactions';
function propagateDetectorConfig(
props: GestureDetectorProps,
gesture: ComposedGesture | GestureType
) {
const keysToPropagate: (keyof GestureDetectorProps)[] = [
'userSelect',
'enableContextMenu',
'touchAction',
];
for (const key of keysToPropagate) {
const value = props[key];
if (value === undefined) {
continue;
}
for (const g of gesture.toGestureArray()) {
const config = g.config as { [key: string]: unknown };
config[key] = value;
}
}
}
interface GestureDetectorProps {
children?: React.ReactNode;
/**
* A gesture object containing the configuration and callbacks.
* Can be any of:
* - base gestures (`Tap`, `Pan`, ...)
* - `ComposedGesture` (`Race`, `Simultaneous`, `Exclusive`)
*/
gesture: ComposedGesture | GestureType;
/**
* #### Web only
* This parameter allows to specify which `userSelect` property should be applied to underlying view.
* Possible values are `"none" | "auto" | "text"`. Default value is set to `"none"`.
*/
userSelect?: UserSelect;
/**
* #### Web only
* Specifies whether context menu should be enabled after clicking on underlying view with right mouse button.
* Default value is set to `false`.
*/
enableContextMenu?: boolean;
/**
* #### Web only
* This parameter allows to specify which `touchAction` property should be applied to underlying view.
* Supports all CSS touch-action values (e.g. `"none"`, `"pan-y"`). Default value is set to `"none"`.
*/
touchAction?: TouchAction;
}
/**
* `GestureDetector` is responsible for creating and updating native gesture handlers based on the config of provided gesture.
*
* ### Props
* - `gesture`
* - `userSelect` (**Web only**)
* - `enableContextMenu` (**Web only**)
* - `touchAction` (**Web only**)
*
* ### Remarks
* - Gesture Detector will use first native view in its subtree to recognize gestures, however if this view is used only to group its children it may get automatically collapsed.
* - Using the same instance of a gesture across multiple Gesture Detectors is not possible.
*
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/gesture-detector
*/
export const GestureDetector = (props: GestureDetectorProps) => {
const rootViewContext = useContext(GestureHandlerRootViewContext);
if (__DEV__ && !rootViewContext && !isTestEnv() && Platform.OS !== 'web') {
throw new Error(
'GestureDetector must be used as a descendant of GestureHandlerRootView. Otherwise the gestures will not be recognized. See https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/installation for more details.'
);
}
// Gesture config should be wrapped with useMemo to prevent unnecessary re-renders
const gestureConfig = props.gesture;
propagateDetectorConfig(props, gestureConfig);
const gesturesToAttach = useMemo(
() => gestureConfig.toGestureArray(),
[gestureConfig]
);
const shouldUseReanimated = gesturesToAttach.some(
(g) => g.shouldUseReanimated
);
const webEventHandlersRef = useWebEventHandlers();
// Store state in ref to prevent unnecessary renders
const state = useRef<GestureDetectorState>({
firstRender: true,
viewRef: null,
previousViewTag: -1,
forceRebuildReanimatedEvent: false,
}).current;
const preparedGesture = React.useRef<AttachedGestureState>({
attachedGestures: [],
animatedEventHandler: null,
animatedHandlers: null,
shouldUseReanimated: shouldUseReanimated,
isMounted: false,
}).current;
const updateAttachedGestures = useDetectorUpdater(
state,
preparedGesture,
gesturesToAttach,
gestureConfig,
webEventHandlersRef
);
const refHandler = useViewRefHandler(state, updateAttachedGestures);
// Reanimated event should be rebuilt only when gestures are reattached, otherwise
// config update will be enough as all necessary items are stored in shared values anyway
const needsToRebuildReanimatedEvent =
state.firstRender ||
state.forceRebuildReanimatedEvent ||
needsToReattach(preparedGesture, gesturesToAttach);
state.forceRebuildReanimatedEvent = false;
useAnimatedGesture(preparedGesture, needsToRebuildReanimatedEvent);
useLayoutEffect(() => {
const viewTag = findNodeHandle(state.viewRef) as number;
preparedGesture.isMounted = true;
attachHandlers({
preparedGesture,
gestureConfig,
gesturesToAttach,
webEventHandlersRef,
viewTag,
});
return () => {
preparedGesture.isMounted = false;
dropHandlers(preparedGesture);
};
}, []);
useEffect(() => {
if (state.firstRender) {
state.firstRender = false;
} else {
updateAttachedGestures();
}
}, [props]);
useMountReactions(updateAttachedGestures, preparedGesture);
if (shouldUseReanimated) {
return (
<AnimatedWrap
ref={refHandler}
onGestureHandlerEvent={preparedGesture.animatedEventHandler}>
{props.children}
</AnimatedWrap>
);
} else {
return <Wrap ref={refHandler}>{props.children}</Wrap>;
}
};
@@ -0,0 +1,27 @@
import { GestureType } from '../gesture';
import { AttachedGestureState } from './types';
// Checks whether the gesture should be reattached to the view, this will happen when:
// - The number of gestures in the preparedGesture is different than the number of gestures in the gesture
// - The handlerName is different in any of the gestures
// - At least one of the gestures changed the thread it runs on
export function needsToReattach(
preparedGesture: AttachedGestureState,
newGestures: GestureType[]
) {
if (newGestures.length !== preparedGesture.attachedGestures.length) {
return true;
}
for (let i = 0; i < newGestures.length; i++) {
if (
newGestures[i].handlerName !==
preparedGesture.attachedGestures[i].handlerName ||
newGestures[i].shouldUseReanimated !==
preparedGesture.attachedGestures[i].shouldUseReanimated
) {
return true;
}
}
return false;
}
@@ -0,0 +1,32 @@
import { GestureType, HandlerCallbacks } from '../gesture';
import { SharedValue } from '../reanimatedWrapper';
import { HandlerStateChangeEvent } from '../../gestureHandlerCommon';
export interface AttachedGestureState {
// Array of gestures that should be attached to the view under that gesture detector
attachedGestures: GestureType[];
// Event handler for the gesture, returned by `useEvent` from Reanimated
animatedEventHandler: unknown;
// Shared value that's responsible for transferring the callbacks to the UI thread handler
animatedHandlers: SharedValue<
HandlerCallbacks<Record<string, unknown>>[] | null
> | null;
// Whether `useAnimatedGesture` should be called inside detector
shouldUseReanimated: boolean;
// Whether the GestureDetector is mounted
isMounted: boolean;
}
export interface GestureDetectorState {
firstRender: boolean;
viewRef: React.Component | null;
previousViewTag: number;
forceRebuildReanimatedEvent: boolean;
}
export interface WebEventHandler {
onGestureHandlerEvent: (event: HandlerStateChangeEvent<unknown>) => void;
onGestureHandlerStateChange?: (
event: HandlerStateChangeEvent<unknown>
) => void;
}
@@ -0,0 +1,92 @@
import { GestureType, HandlerCallbacks } from '../gesture';
import { registerHandler } from '../../handlersRegistry';
import RNGestureHandlerModule from '../../../RNGestureHandlerModule';
import { filterConfig, scheduleFlushOperations } from '../../utils';
import { ComposedGesture } from '../gestureComposition';
import { ghQueueMicrotask } from '../../../ghQueueMicrotask';
import { AttachedGestureState } from './types';
import {
extractGestureRelations,
checkGestureCallbacksForWorklets,
ALLOWED_PROPS,
} from './utils';
export function updateHandlers(
preparedGesture: AttachedGestureState,
gestureConfig: ComposedGesture | GestureType,
newGestures: GestureType[]
) {
gestureConfig.prepare();
for (let i = 0; i < newGestures.length; i++) {
const handler = preparedGesture.attachedGestures[i];
checkGestureCallbacksForWorklets(handler);
// Only update handlerTag when it's actually different, it may be the same
// if gesture config object is wrapped with useMemo
if (newGestures[i].handlerTag !== handler.handlerTag) {
newGestures[i].handlerTag = handler.handlerTag;
newGestures[i].handlers.handlerTag = handler.handlerTag;
}
}
// Store attached gestures to avoid crash when gestures changed after queueing micro task
const attachedGestures = preparedGesture.attachedGestures;
// Use queueMicrotask to extract handlerTags, because when it's ran, all refs should be updated
// and handlerTags in BaseGesture references should be updated in the loop above (we need to wait
// in case of external relations)
ghQueueMicrotask(() => {
if (!preparedGesture.isMounted) {
return;
}
// Stop if attached gestures changed after queueing micro task
if (attachedGestures !== preparedGesture.attachedGestures) {
return;
}
// If amount of gesture configs changes, we need to update the callbacks in shared value
let shouldUpdateSharedValueIfUsed =
attachedGestures.length !== newGestures.length;
for (let i = 0; i < newGestures.length; i++) {
const handler = attachedGestures[i];
// If the gestureId is different (gesture isn't wrapped with useMemo or its dependencies changed),
// we need to update the shared value, assuming the gesture runs on UI thread or the thread changed
if (
handler.handlers.gestureId !== newGestures[i].handlers.gestureId &&
(newGestures[i].shouldUseReanimated || handler.shouldUseReanimated)
) {
shouldUpdateSharedValueIfUsed = true;
}
handler.config = newGestures[i].config;
handler.handlers = newGestures[i].handlers;
RNGestureHandlerModule.updateGestureHandler(
handler.handlerTag,
filterConfig(
handler.config,
ALLOWED_PROPS,
extractGestureRelations(handler)
)
);
registerHandler(handler.handlerTag, handler, handler.config.testId);
}
if (preparedGesture.animatedHandlers && shouldUpdateSharedValueIfUsed) {
const newHandlersValue = attachedGestures
.filter((g) => g.shouldUseReanimated) // Ignore gestures that shouldn't run on UI
.map((g) => g.handlers) as unknown as HandlerCallbacks<
Record<string, unknown>
>[];
preparedGesture.animatedHandlers.value = newHandlersValue;
}
scheduleFlushOperations();
});
}
@@ -0,0 +1,206 @@
import { HandlerCallbacks, CALLBACK_TYPE } from '../gesture';
import { Reanimated } from '../reanimatedWrapper';
import {
GestureTouchEvent,
GestureUpdateEvent,
GestureStateChangeEvent,
} from '../../gestureHandlerCommon';
import {
GestureStateManager,
GestureStateManagerType,
} from '../gestureStateManager';
import { State } from '../../../State';
import { TouchEventType } from '../../../TouchEventType';
import { tagMessage } from '../../../utils';
import { AttachedGestureState } from './types';
function getHandler(
type: CALLBACK_TYPE,
gesture: HandlerCallbacks<Record<string, unknown>>
) {
'worklet';
switch (type) {
case CALLBACK_TYPE.BEGAN:
return gesture.onBegin;
case CALLBACK_TYPE.START:
return gesture.onStart;
case CALLBACK_TYPE.UPDATE:
return gesture.onUpdate;
case CALLBACK_TYPE.CHANGE:
return gesture.onChange;
case CALLBACK_TYPE.END:
return gesture.onEnd;
case CALLBACK_TYPE.FINALIZE:
return gesture.onFinalize;
case CALLBACK_TYPE.TOUCHES_DOWN:
return gesture.onTouchesDown;
case CALLBACK_TYPE.TOUCHES_MOVE:
return gesture.onTouchesMove;
case CALLBACK_TYPE.TOUCHES_UP:
return gesture.onTouchesUp;
case CALLBACK_TYPE.TOUCHES_CANCELLED:
return gesture.onTouchesCancelled;
}
}
function touchEventTypeToCallbackType(
eventType: TouchEventType
): CALLBACK_TYPE {
'worklet';
switch (eventType) {
case TouchEventType.TOUCHES_DOWN:
return CALLBACK_TYPE.TOUCHES_DOWN;
case TouchEventType.TOUCHES_MOVE:
return CALLBACK_TYPE.TOUCHES_MOVE;
case TouchEventType.TOUCHES_UP:
return CALLBACK_TYPE.TOUCHES_UP;
case TouchEventType.TOUCHES_CANCELLED:
return CALLBACK_TYPE.TOUCHES_CANCELLED;
}
return CALLBACK_TYPE.UNDEFINED;
}
function runWorklet(
type: CALLBACK_TYPE,
gesture: HandlerCallbacks<Record<string, unknown>>,
event: GestureStateChangeEvent | GestureUpdateEvent | GestureTouchEvent,
...args: unknown[]
) {
'worklet';
const handler = getHandler(type, gesture);
if (gesture.isWorklet[type]) {
// @ts-ignore Logic below makes sure the correct event is send to the
// correct handler.
handler?.(event, ...args);
} else if (handler) {
console.warn(tagMessage('Animated gesture callback must be a worklet'));
}
}
function isStateChangeEvent(
event: GestureUpdateEvent | GestureStateChangeEvent | GestureTouchEvent
): event is GestureStateChangeEvent {
'worklet';
// @ts-ignore Yes, the oldState prop is missing on GestureTouchEvent, that's the point
return event.oldState != null;
}
function isTouchEvent(
event: GestureUpdateEvent | GestureStateChangeEvent | GestureTouchEvent
): event is GestureTouchEvent {
'worklet';
return event.eventType != null;
}
export function useAnimatedGesture(
preparedGesture: AttachedGestureState,
needsRebuild: boolean
) {
if (!Reanimated) {
return;
}
// Hooks are called conditionally, but the condition is whether the
// react-native-reanimated is installed, which shouldn't change while running
// eslint-disable-next-line react-hooks/rules-of-hooks
const sharedHandlersCallbacks = Reanimated.useSharedValue<
HandlerCallbacks<Record<string, unknown>>[] | null
>(null);
// eslint-disable-next-line react-hooks/rules-of-hooks
const lastUpdateEvent = Reanimated.useSharedValue<
(GestureUpdateEvent | undefined)[]
>([]);
// not every gesture needs a state controller, init them lazily
const stateControllers: GestureStateManagerType[] = [];
const callback = (
event: GestureStateChangeEvent | GestureUpdateEvent | GestureTouchEvent
) => {
'worklet';
const currentCallback = sharedHandlersCallbacks.value;
if (!currentCallback) {
return;
}
for (let i = 0; i < currentCallback.length; i++) {
const gesture = currentCallback[i];
if (event.handlerTag !== gesture.handlerTag) {
continue;
}
if (isStateChangeEvent(event)) {
if (
event.oldState === State.UNDETERMINED &&
event.state === State.BEGAN
) {
runWorklet(CALLBACK_TYPE.BEGAN, gesture, event);
} else if (
(event.oldState === State.BEGAN ||
event.oldState === State.UNDETERMINED) &&
event.state === State.ACTIVE
) {
runWorklet(CALLBACK_TYPE.START, gesture, event);
lastUpdateEvent.value[gesture.handlerTag] = undefined;
} else if (
event.oldState !== event.state &&
event.state === State.END
) {
if (event.oldState === State.ACTIVE) {
runWorklet(CALLBACK_TYPE.END, gesture, event, true);
}
runWorklet(CALLBACK_TYPE.FINALIZE, gesture, event, true);
} else if (
(event.state === State.FAILED || event.state === State.CANCELLED) &&
event.state !== event.oldState
) {
if (event.oldState === State.ACTIVE) {
runWorklet(CALLBACK_TYPE.END, gesture, event, false);
}
runWorklet(CALLBACK_TYPE.FINALIZE, gesture, event, false);
}
} else if (isTouchEvent(event)) {
if (!stateControllers[i]) {
stateControllers[i] = GestureStateManager.create(event.handlerTag);
}
if (event.eventType !== TouchEventType.UNDETERMINED) {
runWorklet(
touchEventTypeToCallbackType(event.eventType),
gesture,
event,
stateControllers[i]
);
}
} else {
runWorklet(CALLBACK_TYPE.UPDATE, gesture, event);
if (gesture.onChange && gesture.changeEventCalculator) {
runWorklet(
CALLBACK_TYPE.CHANGE,
gesture,
gesture.changeEventCalculator?.(
event,
lastUpdateEvent.value[gesture.handlerTag]
)
);
lastUpdateEvent.value[gesture.handlerTag] = event;
}
}
}
};
// eslint-disable-next-line react-hooks/rules-of-hooks
const event = Reanimated.useEvent(
callback,
['onGestureHandlerStateChange', 'onGestureHandlerEvent'],
needsRebuild
);
preparedGesture.animatedEventHandler = event;
preparedGesture.animatedHandlers = sharedHandlersCallbacks;
}
@@ -0,0 +1,69 @@
import React, { useCallback } from 'react';
import { GestureType } from '../gesture';
import { ComposedGesture } from '../gestureComposition';
import {
AttachedGestureState,
GestureDetectorState,
WebEventHandler,
} from './types';
import { attachHandlers } from './attachHandlers';
import { updateHandlers } from './updateHandlers';
import { needsToReattach } from './needsToReattach';
import { dropHandlers } from './dropHandlers';
import { useForceRender, validateDetectorChildren } from './utils';
import findNodeHandle from '../../../findNodeHandle';
// Returns a function that's responsible for updating the attached gestures
// If the view has changed, it will reattach the handlers to the new view
// If the view remains the same, it will update the handlers with the new config
export function useDetectorUpdater(
state: GestureDetectorState,
preparedGesture: AttachedGestureState,
gesturesToAttach: GestureType[],
gestureConfig: ComposedGesture | GestureType,
webEventHandlersRef: React.RefObject<WebEventHandler>
) {
const forceRender = useForceRender();
const updateAttachedGestures = useCallback(
// skipConfigUpdate is used to prevent unnecessary updates when only checking if the view has changed
(skipConfigUpdate?: boolean) => {
// If the underlying view has changed we need to reattach handlers to the new view
const viewTag = findNodeHandle(state.viewRef) as number;
const didUnderlyingViewChange = viewTag !== state.previousViewTag;
if (
didUnderlyingViewChange ||
needsToReattach(preparedGesture, gesturesToAttach)
) {
validateDetectorChildren(state.viewRef);
dropHandlers(preparedGesture);
attachHandlers({
preparedGesture,
gestureConfig,
gesturesToAttach,
webEventHandlersRef,
viewTag,
});
if (didUnderlyingViewChange) {
state.previousViewTag = viewTag;
state.forceRebuildReanimatedEvent = true;
forceRender();
}
} else if (!skipConfigUpdate) {
updateHandlers(preparedGesture, gestureConfig, gesturesToAttach);
}
},
[
forceRender,
gestureConfig,
gesturesToAttach,
preparedGesture,
state,
webEventHandlersRef,
]
);
return updateAttachedGestures;
}
@@ -0,0 +1,51 @@
import { transformIntoHandlerTags } from '../../utils';
import { MountRegistry } from '../../../mountRegistry';
import { AttachedGestureState } from './types';
import { useEffect } from 'react';
import { GestureRef } from '../gesture';
function shouldUpdateDetector(
relation: GestureRef[] | undefined,
gesture: { handlerTag: number }
) {
if (relation === undefined) {
return false;
}
for (const tag of transformIntoHandlerTags(relation)) {
if (tag === gesture.handlerTag) {
return true;
}
}
return false;
}
export function useMountReactions(
updateDetector: () => void,
state: AttachedGestureState
) {
useEffect(() => {
return MountRegistry.addMountListener((gesture) => {
// At this point the ref in the gesture config should be updated, so we can check if one of the gestures
// set in a relation with the gesture got mounted. If so, we need to update the detector to propagate
// the changes to the native side.
for (const attachedGesture of state.attachedGestures) {
const blocksHandlers = attachedGesture.config.blocksHandlers;
const requireToFail = attachedGesture.config.requireToFail;
const simultaneousWith = attachedGesture.config.simultaneousWith;
if (
shouldUpdateDetector(blocksHandlers, gesture) ||
shouldUpdateDetector(requireToFail, gesture) ||
shouldUpdateDetector(simultaneousWith, gesture)
) {
updateDetector();
// We can safely return here, if any other gestures should be updated, they will be by the above call
return;
}
}
});
}, [updateDetector, state]);
}
@@ -0,0 +1,54 @@
import { isFabric, tagMessage } from '../../../utils';
import { getShadowNodeFromRef } from '../../../getShadowNodeFromRef';
import { GestureDetectorState } from './types';
import React, { useCallback } from 'react';
import findNodeHandle from '../../../findNodeHandle';
declare const global: {
isViewFlatteningDisabled: (node: unknown) => boolean | null; // JSI function
};
// Ref handler for the Wrap component attached under the GestureDetector.
// It's responsible for setting the viewRef on the state and triggering the reattaching of handlers
// if the view has changed.
export function useViewRefHandler(
state: GestureDetectorState,
updateAttachedGestures: (skipConfigUpdate?: boolean) => void
) {
const refHandler = useCallback(
(ref: React.Component | null) => {
if (ref === null) {
return;
}
state.viewRef = ref;
// if it's the first render, also set the previousViewTag to prevent reattaching gestures when not needed
if (state.previousViewTag === -1) {
state.previousViewTag = findNodeHandle(state.viewRef) as number;
}
// Pass true as `skipConfigUpdate`. Here we only want to trigger the eventual reattaching of handlers
// in case the view has changed. If the view doesn't change, the update will be handled by detector.
if (!state.firstRender) {
updateAttachedGestures(true);
}
if (__DEV__ && isFabric() && global.isViewFlatteningDisabled) {
const node = getShadowNodeFromRef(ref);
if (global.isViewFlatteningDisabled(node) === false) {
console.error(
tagMessage(
'GestureDetector has received a child that may get view-flattened. ' +
'\nTo prevent it from misbehaving you need to wrap the child with a `<View collapsable={false}>`.'
)
);
}
}
},
[state, updateAttachedGestures]
);
return refHandler;
}
@@ -0,0 +1,181 @@
import { Platform } from 'react-native';
import { isTestEnv, tagMessage } from '../../../utils';
import { GestureRef, BaseGesture, GestureType } from '../gesture';
import { flingGestureHandlerProps } from '../../FlingGestureHandler';
import { forceTouchGestureHandlerProps } from '../../ForceTouchGestureHandler';
import { longPressGestureHandlerProps } from '../../LongPressGestureHandler';
import {
panGestureHandlerProps,
panGestureHandlerCustomNativeProps,
} from '../../PanGestureHandler';
import { tapGestureHandlerProps } from '../../TapGestureHandler';
import { hoverGestureHandlerProps } from '../hoverGesture';
import { nativeViewGestureHandlerProps } from '../../NativeViewGestureHandler';
import {
HandlerStateChangeEvent,
baseGestureHandlerWithDetectorProps,
} from '../../gestureHandlerCommon';
import { isNewWebImplementationEnabled } from '../../../EnableNewWebImplementation';
import { RNRenderer } from '../../../RNRenderer';
import { useCallback, useRef, useState } from 'react';
import { Reanimated } from '../reanimatedWrapper';
import { onGestureHandlerEvent } from '../eventReceiver';
import { WebEventHandler } from './types';
export const ALLOWED_PROPS = [
...baseGestureHandlerWithDetectorProps,
...tapGestureHandlerProps,
...panGestureHandlerProps,
...panGestureHandlerCustomNativeProps,
...longPressGestureHandlerProps,
...forceTouchGestureHandlerProps,
...flingGestureHandlerProps,
...hoverGestureHandlerProps,
...nativeViewGestureHandlerProps,
];
function convertToHandlerTag(ref: GestureRef): number {
if (typeof ref === 'number') {
return ref;
} else if (ref instanceof BaseGesture) {
return ref.handlerTag;
} else {
// @ts-ignore in this case it should be a ref either to gesture object or
// a gesture handler component, in both cases handlerTag property exists
return ref.current?.handlerTag ?? -1;
}
}
function extractValidHandlerTags(interactionGroup: GestureRef[] | undefined) {
return (
interactionGroup?.map(convertToHandlerTag)?.filter((tag) => tag > 0) ?? []
);
}
export function extractGestureRelations(gesture: GestureType) {
const requireToFail = extractValidHandlerTags(gesture.config.requireToFail);
const simultaneousWith = extractValidHandlerTags(
gesture.config.simultaneousWith
);
const blocksHandlers = extractValidHandlerTags(gesture.config.blocksHandlers);
return {
waitFor: requireToFail,
simultaneousHandlers: simultaneousWith,
blocksHandlers: blocksHandlers,
};
}
export function checkGestureCallbacksForWorklets(gesture: GestureType) {
if (!__DEV__) {
return;
}
// If a gesture is explicitly marked to run on the JS thread there is no need to check
// if callbacks are worklets as the user is aware they will be ran on the JS thread
if (gesture.config.runOnJS) {
return;
}
const areSomeNotWorklets = gesture.handlers.isWorklet.includes(false);
const areSomeWorklets = gesture.handlers.isWorklet.includes(true);
// If some of the callbacks are worklets and some are not, and the gesture is not
// explicitly marked with `.runOnJS(true)` show an error
if (areSomeNotWorklets && areSomeWorklets) {
console.error(
tagMessage(
`Some of the callbacks in the gesture are worklets and some are not. Either make sure that all calbacks are marked as 'worklet' if you wish to run them on the UI thread or use '.runOnJS(true)' modifier on the gesture explicitly to run all callbacks on the JS thread.`
)
);
}
if (Reanimated === undefined) {
// If Reanimated is not available, we can't run worklets, so we shouldn't show the warning
return;
}
const areAllNotWorklets = !areSomeWorklets && areSomeNotWorklets;
// If none of the callbacks are worklets and the gesture is not explicitly marked with
// `.runOnJS(true)` show a warning
if (areAllNotWorklets && !isTestEnv()) {
console.warn(
tagMessage(
`None of the callbacks in the gesture are worklets. If you wish to run them on the JS thread use '.runOnJS(true)' modifier on the gesture to make this explicit. Otherwise, mark the callbacks as 'worklet' to run them on the UI thread.`
)
);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function validateDetectorChildren(ref: any) {
// Finds the first native view under the Wrap component and traverses the fiber tree upwards
// to check whether there is more than one native view as a pseudo-direct child of GestureDetector
// i.e. this is not ok:
// Wrap
// |
// / \
// / \
// / \
// / \
// NativeView NativeView
//
// but this is fine:
// Wrap
// |
// NativeView
// |
// / \
// / \
// / \
// / \
// NativeView NativeView
if (__DEV__ && Platform.OS !== 'web') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const wrapType =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
ref._reactInternals.elementType;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
let instance =
RNRenderer.findHostInstance_DEPRECATED(
ref
)._internalFiberInstanceHandleDEV;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
while (instance && instance.elementType !== wrapType) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (instance.sibling) {
throw new Error(
'GestureDetector has more than one native view as its children. This can happen if you are using a custom component that renders multiple views, like React.Fragment. You should wrap content of GestureDetector with a <View> or <Animated.View>.'
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
instance = instance.return;
}
}
}
export function useForceRender() {
const [renderState, setRenderState] = useState(false);
const forceRender = useCallback(() => {
setRenderState(!renderState);
}, [renderState, setRenderState]);
return forceRender;
}
export function useWebEventHandlers() {
return useRef<WebEventHandler>({
onGestureHandlerEvent: (e: HandlerStateChangeEvent<unknown>) => {
onGestureHandlerEvent(e.nativeEvent);
},
onGestureHandlerStateChange: isNewWebImplementationEnabled()
? (e: HandlerStateChangeEvent<unknown>) => {
onGestureHandlerEvent(e.nativeEvent);
}
: undefined,
});
}
@@ -0,0 +1,155 @@
import { DeviceEventEmitter, EmitterSubscription } from 'react-native';
import { State } from '../../State';
import { TouchEventType } from '../../TouchEventType';
import {
GestureTouchEvent,
GestureUpdateEvent,
GestureStateChangeEvent,
} from '../gestureHandlerCommon';
import { findHandler, findOldGestureHandler } from '../handlersRegistry';
import { BaseGesture } from './gesture';
import {
GestureStateManager,
GestureStateManagerType,
} from './gestureStateManager';
let gestureHandlerEventSubscription: EmitterSubscription | null = null;
let gestureHandlerStateChangeEventSubscription: EmitterSubscription | null =
null;
const gestureStateManagers: Map<number, GestureStateManagerType> = new Map<
number,
GestureStateManagerType
>();
const lastUpdateEvent: (GestureUpdateEvent | undefined)[] = [];
function isStateChangeEvent(
event: GestureUpdateEvent | GestureStateChangeEvent | GestureTouchEvent
): event is GestureStateChangeEvent {
// @ts-ignore oldState doesn't exist on GestureTouchEvent and that's the point
return event.oldState != null;
}
function isTouchEvent(
event: GestureUpdateEvent | GestureStateChangeEvent | GestureTouchEvent
): event is GestureTouchEvent {
return event.eventType != null;
}
export function onGestureHandlerEvent(
event: GestureUpdateEvent | GestureStateChangeEvent | GestureTouchEvent
) {
const handler = findHandler(event.handlerTag) as BaseGesture<
Record<string, unknown>
>;
if (handler) {
if (isStateChangeEvent(event)) {
if (
event.oldState === State.UNDETERMINED &&
event.state === State.BEGAN
) {
handler.handlers.onBegin?.(event);
} else if (
(event.oldState === State.BEGAN ||
event.oldState === State.UNDETERMINED) &&
event.state === State.ACTIVE
) {
handler.handlers.onStart?.(event);
lastUpdateEvent[handler.handlers.handlerTag] = event;
} else if (event.oldState !== event.state && event.state === State.END) {
if (event.oldState === State.ACTIVE) {
handler.handlers.onEnd?.(event, true);
}
handler.handlers.onFinalize?.(event, true);
lastUpdateEvent[handler.handlers.handlerTag] = undefined;
} else if (
(event.state === State.FAILED || event.state === State.CANCELLED) &&
event.oldState !== event.state
) {
if (event.oldState === State.ACTIVE) {
handler.handlers.onEnd?.(event, false);
}
handler.handlers.onFinalize?.(event, false);
gestureStateManagers.delete(event.handlerTag);
lastUpdateEvent[handler.handlers.handlerTag] = undefined;
}
} else if (isTouchEvent(event)) {
if (!gestureStateManagers.has(event.handlerTag)) {
gestureStateManagers.set(
event.handlerTag,
GestureStateManager.create(event.handlerTag)
);
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const manager = gestureStateManagers.get(event.handlerTag)!;
switch (event.eventType) {
case TouchEventType.TOUCHES_DOWN:
handler.handlers?.onTouchesDown?.(event, manager);
break;
case TouchEventType.TOUCHES_MOVE:
handler.handlers?.onTouchesMove?.(event, manager);
break;
case TouchEventType.TOUCHES_UP:
handler.handlers?.onTouchesUp?.(event, manager);
break;
case TouchEventType.TOUCHES_CANCELLED:
handler.handlers?.onTouchesCancelled?.(event, manager);
break;
}
} else {
handler.handlers.onUpdate?.(event);
if (handler.handlers.onChange && handler.handlers.changeEventCalculator) {
handler.handlers.onChange?.(
handler.handlers.changeEventCalculator?.(
event,
lastUpdateEvent[handler.handlers.handlerTag]
)
);
lastUpdateEvent[handler.handlers.handlerTag] = event;
}
}
} else {
const oldHandler = findOldGestureHandler(event.handlerTag);
if (oldHandler) {
const nativeEvent = { nativeEvent: event };
if (isStateChangeEvent(event)) {
oldHandler.onGestureStateChange(nativeEvent);
} else {
oldHandler.onGestureEvent(nativeEvent);
}
return;
}
}
}
export function startListening() {
stopListening();
gestureHandlerEventSubscription = DeviceEventEmitter.addListener(
'onGestureHandlerEvent',
onGestureHandlerEvent
);
gestureHandlerStateChangeEventSubscription = DeviceEventEmitter.addListener(
'onGestureHandlerStateChange',
onGestureHandlerEvent
);
}
export function stopListening() {
if (gestureHandlerEventSubscription) {
gestureHandlerEventSubscription.remove();
gestureHandlerEventSubscription = null;
}
if (gestureHandlerStateChangeEventSubscription) {
gestureHandlerStateChangeEventSubscription.remove();
gestureHandlerStateChangeEventSubscription = null;
}
}
@@ -0,0 +1,36 @@
import { BaseGesture, BaseGestureConfig } from './gesture';
import { FlingGestureConfig } from '../FlingGestureHandler';
import type { FlingGestureHandlerEventPayload } from '../GestureHandlerEventPayload';
export class FlingGesture extends BaseGesture<FlingGestureHandlerEventPayload> {
public config: BaseGestureConfig & FlingGestureConfig = {};
constructor() {
super();
this.handlerName = 'FlingGestureHandler';
}
/**
* Determine exact number of points required to handle the fling gesture.
* @param pointers
*/
numberOfPointers(pointers: number) {
this.config.numberOfPointers = pointers;
return this;
}
/**
* Expressed allowed direction of movement.
* Expected values are exported as constants in the Directions object.
* Arguments can be combined using `|` operator. Default value is set to `MouseButton.LEFT`.
* @param direction
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/fling-gesture/#directionvalue-directions
*/
direction(direction: number) {
this.config.direction = direction;
return this;
}
}
export type FlingGestureType = InstanceType<typeof FlingGesture>;
@@ -0,0 +1,95 @@
import { BaseGestureConfig, ContinousBaseGesture } from './gesture';
import { ForceTouchGestureConfig } from '../ForceTouchGestureHandler';
import type { ForceTouchGestureHandlerEventPayload } from '../GestureHandlerEventPayload';
import { GestureUpdateEvent } from '../gestureHandlerCommon';
/**
* @deprecated ForceTouch gesture is deprecated and will be removed in the future.
*/
export type ForceTouchGestureChangeEventPayload = {
forceChange: number;
};
function changeEventCalculator(
current: GestureUpdateEvent<ForceTouchGestureHandlerEventPayload>,
previous?: GestureUpdateEvent<ForceTouchGestureHandlerEventPayload>
) {
'worklet';
let changePayload: ForceTouchGestureChangeEventPayload;
if (previous === undefined) {
changePayload = {
forceChange: current.force,
};
} else {
changePayload = {
forceChange: current.force - previous.force,
};
}
return { ...current, ...changePayload };
}
/**
* @deprecated ForceTouch gesture is deprecated and will be removed in the future.
*/
export class ForceTouchGesture extends ContinousBaseGesture<
ForceTouchGestureHandlerEventPayload,
ForceTouchGestureChangeEventPayload
> {
public config: BaseGestureConfig & ForceTouchGestureConfig = {};
constructor() {
super();
this.handlerName = 'ForceTouchGestureHandler';
}
/**
* A minimal pressure that is required before gesture can activate.
* Should be a value from range [0.0, 1.0]. Default is 0.2.
* @param force
*/
minForce(force: number) {
this.config.minForce = force;
return this;
}
/**
* A maximal pressure that could be applied for gesture.
* If the pressure is greater, gesture fails. Should be a value from range [0.0, 1.0].
* @param force
*/
maxForce(force: number) {
this.config.maxForce = force;
return this;
}
/**
* Value defining if haptic feedback has to be performed on activation.
* @param value
*/
feedbackOnActivation(value: boolean) {
this.config.feedbackOnActivation = value;
return this;
}
onChange(
callback: (
event: GestureUpdateEvent<
GestureUpdateEvent<
ForceTouchGestureHandlerEventPayload &
ForceTouchGestureChangeEventPayload
>
>
) => void
) {
// @ts-ignore TS being overprotective, ForceTouchGestureHandlerEventPayload is Record
this.handlers.changeEventCalculator = changeEventCalculator;
return super.onChange(callback);
}
}
/**
* @deprecated ForceTouch gesture is deprecated and will be removed in the future.
*/
export type ForceTouchGestureType = InstanceType<typeof ForceTouchGesture>;
@@ -0,0 +1,472 @@
import {
HitSlop,
CommonGestureConfig,
GestureTouchEvent,
GestureStateChangeEvent,
GestureUpdateEvent,
ActiveCursor,
MouseButton,
} from '../gestureHandlerCommon';
import { getNextHandlerTag } from '../getNextHandlerTag';
import { GestureStateManagerType } from './gestureStateManager';
import type {
FlingGestureHandlerEventPayload,
ForceTouchGestureHandlerEventPayload,
LongPressGestureHandlerEventPayload,
PanGestureHandlerEventPayload,
PinchGestureHandlerEventPayload,
RotationGestureHandlerEventPayload,
TapGestureHandlerEventPayload,
NativeViewGestureHandlerPayload,
HoverGestureHandlerEventPayload,
} from '../GestureHandlerEventPayload';
import { isRemoteDebuggingEnabled } from '../../utils';
export type GestureType =
| BaseGesture<Record<string, unknown>>
| BaseGesture<Record<string, never>>
| BaseGesture<TapGestureHandlerEventPayload>
| BaseGesture<PanGestureHandlerEventPayload>
| BaseGesture<LongPressGestureHandlerEventPayload>
| BaseGesture<RotationGestureHandlerEventPayload>
| BaseGesture<PinchGestureHandlerEventPayload>
| BaseGesture<FlingGestureHandlerEventPayload>
| BaseGesture<ForceTouchGestureHandlerEventPayload>
| BaseGesture<NativeViewGestureHandlerPayload>
| BaseGesture<HoverGestureHandlerEventPayload>;
export type GestureRef =
| number
| GestureType
| React.RefObject<GestureType | undefined>
| React.RefObject<React.ComponentType | undefined>; // Allow adding a ref to a gesture handler
export interface BaseGestureConfig
extends CommonGestureConfig,
Record<string, unknown> {
ref?: React.MutableRefObject<GestureType | undefined>;
requireToFail?: GestureRef[];
simultaneousWith?: GestureRef[];
blocksHandlers?: GestureRef[];
needsPointerData?: boolean;
manualActivation?: boolean;
runOnJS?: boolean;
testId?: string;
cancelsTouchesInView?: boolean;
}
type TouchEventHandlerType = (
event: GestureTouchEvent,
stateManager: GestureStateManagerType
) => void;
export type HandlerCallbacks<EventPayloadT extends Record<string, unknown>> = {
gestureId: number;
handlerTag: number;
onBegin?: (event: GestureStateChangeEvent<EventPayloadT>) => void;
onStart?: (event: GestureStateChangeEvent<EventPayloadT>) => void;
onEnd?: (
event: GestureStateChangeEvent<EventPayloadT>,
success: boolean
) => void;
onFinalize?: (
event: GestureStateChangeEvent<EventPayloadT>,
success: boolean
) => void;
onUpdate?: (event: GestureUpdateEvent<EventPayloadT>) => void;
onChange?: (event: any) => void;
onTouchesDown?: TouchEventHandlerType;
onTouchesMove?: TouchEventHandlerType;
onTouchesUp?: TouchEventHandlerType;
onTouchesCancelled?: TouchEventHandlerType;
changeEventCalculator?: (
current: GestureUpdateEvent<Record<string, unknown>>,
previous?: GestureUpdateEvent<Record<string, unknown>>
) => GestureUpdateEvent<Record<string, unknown>>;
isWorklet: boolean[];
};
export const CALLBACK_TYPE = {
UNDEFINED: 0,
BEGAN: 1,
START: 2,
UPDATE: 3,
CHANGE: 4,
END: 5,
FINALIZE: 6,
TOUCHES_DOWN: 7,
TOUCHES_MOVE: 8,
TOUCHES_UP: 9,
TOUCHES_CANCELLED: 10,
} as const;
// Allow using CALLBACK_TYPE as object and type
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type CALLBACK_TYPE = (typeof CALLBACK_TYPE)[keyof typeof CALLBACK_TYPE];
export abstract class Gesture {
/**
* Return array of gestures, providing the same interface for creating and updating
* handlers, no matter which object was used to create gesture instance.
*/
abstract toGestureArray(): GestureType[];
/**
* Assign handlerTag to the gesture instance and set ref.current (if a ref is set)
*/
abstract initialize(): void;
/**
* Make sure that values of properties defining relations are arrays. Do any necessary
* preprocessing required to configure relations between handlers. Called just before
* updating the handler on the native side.
*/
abstract prepare(): void;
}
let nextGestureId = 0;
export abstract class BaseGesture<
EventPayloadT extends Record<string, unknown>,
> extends Gesture {
private gestureId = -1;
public handlerTag = -1;
public handlerName = '';
public config: BaseGestureConfig = {};
public handlers: HandlerCallbacks<EventPayloadT> = {
gestureId: -1,
handlerTag: -1,
isWorklet: [],
};
constructor() {
super();
// Used to check whether the gesture config has been updated when wrapping it
// with `useMemo`. Since every config will have a unique id, when the dependencies
// don't change, the config won't be recreated and the id will stay the same.
// If the id is different, it means that the config has changed and the gesture
// needs to be updated.
this.gestureId = nextGestureId++;
this.handlers.gestureId = this.gestureId;
}
private addDependency(
key: 'simultaneousWith' | 'requireToFail' | 'blocksHandlers',
gesture: Exclude<GestureRef, number>
) {
const value = this.config[key];
this.config[key] = value
? Array<GestureRef>().concat(value, gesture)
: [gesture];
}
/**
* Sets a `ref` to the gesture object, allowing for interoperability with the old API.
* @param ref
*/
withRef(ref: React.MutableRefObject<GestureType | undefined>) {
this.config.ref = ref;
return this;
}
// eslint-disable-next-line @typescript-eslint/ban-types
protected isWorklet(callback: Function) {
// @ts-ignore if callback is a worklet, the property will be available, if not then the check will return false
return callback.__workletHash !== undefined;
}
/**
* Set the callback that is being called when given gesture handler starts receiving touches.
* At the moment of this callback the handler is in `BEGAN` state and we don't know yet if it will recognize the gesture at all.
* @param callback
*/
onBegin(callback: (event: GestureStateChangeEvent<EventPayloadT>) => void) {
this.handlers.onBegin = callback;
this.handlers.isWorklet[CALLBACK_TYPE.BEGAN] = this.isWorklet(callback);
return this;
}
/**
* Set the callback that is being called when the gesture is recognized by the handler and it transitions to the `ACTIVE` state.
* @param callback
*/
onStart(callback: (event: GestureStateChangeEvent<EventPayloadT>) => void) {
this.handlers.onStart = callback;
this.handlers.isWorklet[CALLBACK_TYPE.START] = this.isWorklet(callback);
return this;
}
/**
* Set the callback that is being called when the gesture that was recognized by the handler finishes and handler reaches `END` state.
* It will be called only if the handler was previously in the `ACTIVE` state.
* @param callback
*/
onEnd(
callback: (
event: GestureStateChangeEvent<EventPayloadT>,
success: boolean
) => void
) {
this.handlers.onEnd = callback;
// @ts-ignore if callback is a worklet, the property will be available, if not then the check will return false
this.handlers.isWorklet[CALLBACK_TYPE.END] = this.isWorklet(callback);
return this;
}
/**
* Set the callback that is being called when the handler finalizes handling gesture - the gesture was recognized and has finished or it failed to recognize.
* @param callback
*/
onFinalize(
callback: (
event: GestureStateChangeEvent<EventPayloadT>,
success: boolean
) => void
) {
this.handlers.onFinalize = callback;
// @ts-ignore if callback is a worklet, the property will be available, if not then the check will return false
this.handlers.isWorklet[CALLBACK_TYPE.FINALIZE] = this.isWorklet(callback);
return this;
}
/**
* Set the `onTouchesDown` callback which is called every time a pointer is placed on the screen.
* @param callback
*/
onTouchesDown(callback: TouchEventHandlerType) {
this.config.needsPointerData = true;
this.handlers.onTouchesDown = callback;
this.handlers.isWorklet[CALLBACK_TYPE.TOUCHES_DOWN] =
this.isWorklet(callback);
return this;
}
/**
* Set the `onTouchesMove` callback which is called every time a pointer is moved on the screen.
* @param callback
*/
onTouchesMove(callback: TouchEventHandlerType) {
this.config.needsPointerData = true;
this.handlers.onTouchesMove = callback;
this.handlers.isWorklet[CALLBACK_TYPE.TOUCHES_MOVE] =
this.isWorklet(callback);
return this;
}
/**
* Set the `onTouchesUp` callback which is called every time a pointer is lifted from the screen.
* @param callback
*/
onTouchesUp(callback: TouchEventHandlerType) {
this.config.needsPointerData = true;
this.handlers.onTouchesUp = callback;
this.handlers.isWorklet[CALLBACK_TYPE.TOUCHES_UP] =
this.isWorklet(callback);
return this;
}
/**
* Set the `onTouchesCancelled` callback which is called every time a pointer stops being tracked, for example when the gesture finishes.
* @param callback
*/
onTouchesCancelled(callback: TouchEventHandlerType) {
this.config.needsPointerData = true;
this.handlers.onTouchesCancelled = callback;
this.handlers.isWorklet[CALLBACK_TYPE.TOUCHES_CANCELLED] =
this.isWorklet(callback);
return this;
}
/**
* Indicates whether the given handler should be analyzing stream of touch events or not.
* @param enabled
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture#enabledvalue-boolean
*/
enabled(enabled: boolean) {
this.config.enabled = enabled;
return this;
}
/**
* When true the handler will cancel or fail recognition (depending on its current state) whenever the finger leaves the area of the connected view.
* @param value
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture#shouldcancelwhenoutsidevalue-boolean
*/
shouldCancelWhenOutside(value: boolean) {
this.config.shouldCancelWhenOutside = value;
return this;
}
/**
* This parameter enables control over what part of the connected view area can be used to begin recognizing the gesture.
* When a negative number is provided the bounds of the view will reduce the area by the given number of points in each of the sides evenly.
* @param hitSlop
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture#hitslopsettings
*/
hitSlop(hitSlop: HitSlop) {
this.config.hitSlop = hitSlop;
return this;
}
/**
* #### Web only
* This parameter allows to specify which `cursor` should be used when gesture activates.
* Supports all CSS cursor values (e.g. `"grab"`, `"zoom-in"`). Default value is set to `"auto"`.
* @param activeCursor
*/
activeCursor(activeCursor: ActiveCursor) {
this.config.activeCursor = activeCursor;
return this;
}
/**
* #### Web & Android only
* Allows users to choose which mouse button should handler respond to.
* Arguments can be combined using `|` operator, e.g. `mouseButton(MouseButton.LEFT | MouseButton.RIGHT)`.
* Default value is set to `MouseButton.LEFT`.
* @param mouseButton
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture#mousebuttonvalue-mousebutton-web--android-only
*/
mouseButton(mouseButton: MouseButton) {
this.config.mouseButton = mouseButton;
return this;
}
/**
* When `react-native-reanimated` is installed, the callbacks passed to the gestures are automatically workletized and run on the UI thread when called.
* This option allows for changing this behavior: when `true`, all the callbacks will be run on the JS thread instead of the UI thread, regardless of whether they are worklets or not.
* Defaults to `false`.
* @param runOnJS
*/
runOnJS(runOnJS: boolean) {
this.config.runOnJS = runOnJS;
return this;
}
/**
* Allows gestures across different components to be recognized simultaneously.
* @param gestures
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/gesture-composition/#simultaneouswithexternalgesture
*/
simultaneousWithExternalGesture(...gestures: Exclude<GestureRef, number>[]) {
for (const gesture of gestures) {
this.addDependency('simultaneousWith', gesture);
}
return this;
}
/**
* Allows to delay activation of the handler until all handlers passed as arguments to this method fail (or don't begin at all).
* @param gestures
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/gesture-composition/#requireexternalgesturetofail
*/
requireExternalGestureToFail(...gestures: Exclude<GestureRef, number>[]) {
for (const gesture of gestures) {
this.addDependency('requireToFail', gesture);
}
return this;
}
/**
* Works similarily to `requireExternalGestureToFail` but the direction of the relation is reversed - instead of being one-to-many relation, it's many-to-one.
* @param gestures
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/gesture-composition/#blocksexternalgesture
*/
blocksExternalGesture(...gestures: Exclude<GestureRef, number>[]) {
for (const gesture of gestures) {
this.addDependency('blocksHandlers', gesture);
}
return this;
}
/**
* Sets a `testID` property for gesture object, allowing for querying for it in tests.
* @param id
*/
withTestId(id: string) {
this.config.testId = id;
return this;
}
/**
* #### iOS only
* When `true`, the handler will cancel touches for native UI components (`UIButton`, `UISwitch`, etc) it's attached to when it becomes `ACTIVE`.
* Default value is `true`.
* @param value
*/
cancelsTouchesInView(value: boolean) {
this.config.cancelsTouchesInView = value;
return this;
}
initialize() {
this.handlerTag = getNextHandlerTag();
this.handlers = { ...this.handlers, handlerTag: this.handlerTag };
if (this.config.ref) {
this.config.ref.current = this as GestureType;
}
}
toGestureArray(): GestureType[] {
return [this as GestureType];
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
prepare() {}
get shouldUseReanimated(): boolean {
// Use Reanimated when runOnJS isn't set explicitly,
// all defined callbacks are worklets
// and remote debugging is disabled
return (
this.config.runOnJS !== true &&
!this.handlers.isWorklet.includes(false) &&
!isRemoteDebuggingEnabled()
);
}
}
export abstract class ContinousBaseGesture<
EventPayloadT extends Record<string, unknown>,
EventChangePayloadT extends Record<string, unknown>,
> extends BaseGesture<EventPayloadT> {
/**
* Set the callback that is being called every time the gesture receives an update while it's active.
* @param callback
*/
onUpdate(callback: (event: GestureUpdateEvent<EventPayloadT>) => void) {
this.handlers.onUpdate = callback;
this.handlers.isWorklet[CALLBACK_TYPE.UPDATE] = this.isWorklet(callback);
return this;
}
/**
* Set the callback that is being called every time the gesture receives an update while it's active.
* This callback will receive information about change in value in relation to the last received event.
* @param callback
*/
onChange(
callback: (
event: GestureUpdateEvent<EventPayloadT & EventChangePayloadT>
) => void
) {
this.handlers.onChange = callback;
this.handlers.isWorklet[CALLBACK_TYPE.CHANGE] = this.isWorklet(callback);
return this;
}
/**
* When `true` the handler will not activate by itself even if its activation criteria are met.
* Instead you can manipulate its state using state manager.
* @param manualActivation
*/
manualActivation(manualActivation: boolean) {
this.config.manualActivation = manualActivation;
return this;
}
}
@@ -0,0 +1,124 @@
import { BaseGesture, Gesture, GestureRef, GestureType } from './gesture';
function extendRelation(
currentRelation: GestureRef[] | undefined,
extendWith: GestureType[]
) {
if (currentRelation === undefined) {
return [...extendWith];
} else {
return [...currentRelation, ...extendWith];
}
}
export class ComposedGesture extends Gesture {
protected gestures: Gesture[] = [];
protected simultaneousGestures: GestureType[] = [];
protected requireGesturesToFail: GestureType[] = [];
constructor(...gestures: Gesture[]) {
super();
this.gestures = gestures;
}
protected prepareSingleGesture(
gesture: Gesture,
simultaneousGestures: GestureType[],
requireGesturesToFail: GestureType[]
) {
if (gesture instanceof BaseGesture) {
const newConfig = { ...gesture.config };
// No need to extend `blocksHandlers` here, because it's not changed in composition.
// The same effect is achieved by reversing the order of 2 gestures in `Exclusive`
newConfig.simultaneousWith = extendRelation(
newConfig.simultaneousWith,
simultaneousGestures
);
newConfig.requireToFail = extendRelation(
newConfig.requireToFail,
requireGesturesToFail
);
gesture.config = newConfig;
} else if (gesture instanceof ComposedGesture) {
gesture.simultaneousGestures = simultaneousGestures;
gesture.requireGesturesToFail = requireGesturesToFail;
gesture.prepare();
}
}
prepare() {
for (const gesture of this.gestures) {
this.prepareSingleGesture(
gesture,
this.simultaneousGestures,
this.requireGesturesToFail
);
}
}
initialize() {
for (const gesture of this.gestures) {
gesture.initialize();
}
}
toGestureArray(): GestureType[] {
return this.gestures.flatMap((gesture) => gesture.toGestureArray());
}
}
export class SimultaneousGesture extends ComposedGesture {
prepare() {
// This piece of magic works something like this:
// for every gesture in the array
const simultaneousArrays = this.gestures.map((gesture) =>
// we take the array it's in
this.gestures
// and make a copy without it
.filter((x) => x !== gesture)
// then we flatmap the result to get list of raw (not composed) gestures
// this way we don't make the gestures simultaneous with themselves, which is
// important when the gesture is `ExclusiveGesture` - we don't want to make
// exclusive gestures simultaneous
.flatMap((x) => x.toGestureArray())
);
for (let i = 0; i < this.gestures.length; i++) {
this.prepareSingleGesture(
this.gestures[i],
simultaneousArrays[i],
this.requireGesturesToFail
);
}
}
}
export class ExclusiveGesture extends ComposedGesture {
prepare() {
// Transforms the array of gestures into array of grouped raw (not composed) gestures
// i.e. [gesture1, gesture2, ComposedGesture(gesture3, gesture4)] -> [[gesture1], [gesture2], [gesture3, gesture4]]
const gestureArrays = this.gestures.map((gesture) =>
gesture.toGestureArray()
);
let requireToFail: GestureType[] = [];
for (let i = 0; i < this.gestures.length; i++) {
this.prepareSingleGesture(
this.gestures[i],
this.simultaneousGestures,
this.requireGesturesToFail.concat(requireToFail)
);
// Every group gets to wait for all groups before it
requireToFail = requireToFail.concat(gestureArrays[i]);
}
}
}
export type ComposedGestureType = InstanceType<typeof ComposedGesture>;
export type RaceGestureType = ComposedGestureType;
export type SimultaneousGestureType = InstanceType<typeof SimultaneousGesture>;
export type ExclusiveGestureType = InstanceType<typeof ExclusiveGesture>;
@@ -0,0 +1,143 @@
import { FlingGesture } from './flingGesture';
import { ForceTouchGesture } from './forceTouchGesture';
import { Gesture } from './gesture';
import {
ComposedGesture,
ExclusiveGesture,
SimultaneousGesture,
} from './gestureComposition';
import { LongPressGesture } from './longPressGesture';
import { PanGesture } from './panGesture';
import { PinchGesture } from './pinchGesture';
import { RotationGesture } from './rotationGesture';
import { TapGesture } from './tapGesture';
import { NativeGesture } from './nativeGesture';
import { ManualGesture } from './manualGesture';
import { HoverGesture } from './hoverGesture';
/**
* `Gesture` is the object that allows you to create and compose gestures.
*
* ### Remarks
* - Consider wrapping your gesture configurations with `useMemo`, as it will reduce the amount of work Gesture Handler has to do under the hood when updating gestures.
*
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/gesture
*/
export const GestureObjects = {
/**
* A discrete gesture that recognizes one or many taps.
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/tap-gesture
*/
Tap: () => {
return new TapGesture();
},
/**
* A continuous gesture that can recognize a panning (dragging) gesture and track its movement.
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture
*/
Pan: () => {
return new PanGesture();
},
/**
* A continuous gesture that recognizes pinch gesture. It allows for tracking the distance between two fingers and use that information to scale or zoom your content.
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pinch-gesture
*/
Pinch: () => {
return new PinchGesture();
},
/**
* A continuous gesture that can recognize rotation and track its movement.
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/rotation-gesture
*/
Rotation: () => {
return new RotationGesture();
},
/**
* A discrete gesture that activates when the movement is sufficiently fast.
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/fling-gesture
*/
Fling: () => {
return new FlingGesture();
},
/**
* A discrete gesture that activates when the corresponding view is pressed for a sufficiently long time.
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/long-press-gesture
*/
LongPress: () => {
return new LongPressGesture();
},
/**
* @deprecated ForceTouch gesture is deprecated and will be removed in the future.
*
* #### iOS only
* A continuous gesture that recognizes force of a touch. It allows for tracking pressure of touch on some iOS devices.
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/force-touch-gesture
*/
ForceTouch: () => {
return new ForceTouchGesture();
},
/**
* A gesture that allows other touch handling components to participate in RNGH's gesture system.
* When used, the other component should be the direct child of a `GestureDetector`.
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/native-gesture
*/
Native: () => {
return new NativeGesture();
},
/**
* A plain gesture that has no specific activation criteria nor event data set.
* Its state has to be controlled manually using a state manager.
* It will not fail when all the pointers are lifted from the screen.
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/manual-gesture
*/
Manual: () => {
return new ManualGesture();
},
/**
* A continuous gesture that can recognize hovering above the view it's attached to.
* The hover effect may be activated by moving a mouse or a stylus over the view.
*
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/hover-gesture
*/
Hover: () => {
return new HoverGesture();
},
/**
* Builds a composed gesture consisting of gestures provided as parameters.
* The first one that becomes active cancels the rest of gestures.
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/gesture-composition/#race
*/
Race: (...gestures: Gesture[]) => {
return new ComposedGesture(...gestures);
},
/**
* Builds a composed gesture that allows all base gestures to run simultaneously.
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/gesture-composition/#simultaneous
*/
Simultaneous(...gestures: Gesture[]) {
return new SimultaneousGesture(...gestures);
},
/**
* Builds a composed gesture where only one of the provided gestures can become active.
* Priority is decided through the order of gestures: the first one has higher priority
* than the second one, second one has higher priority than the third one, and so on.
* For example, to make a gesture that recognizes both single and double tap you need
* to call Exclusive(doubleTap, singleTap).
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/gesture-composition/#exclusive
*/
Exclusive(...gestures: Gesture[]) {
return new ExclusiveGesture(...gestures);
},
};
@@ -0,0 +1,72 @@
import { Reanimated } from './reanimatedWrapper';
import { State } from '../../State';
import { tagMessage } from '../../utils';
export interface GestureStateManagerType {
begin: () => void;
activate: () => void;
fail: () => void;
end: () => void;
}
const warningMessage = tagMessage(
'react-native-reanimated is required in order to use synchronous state management'
);
// Check if reanimated module is available, but look for useSharedValue as conditional
// require of reanimated can sometimes return content of `utils.ts` file (?)
const REANIMATED_AVAILABLE = Reanimated?.useSharedValue !== undefined;
const setGestureState = Reanimated?.setGestureState;
function create(handlerTag: number): GestureStateManagerType {
'worklet';
return {
begin: () => {
'worklet';
if (REANIMATED_AVAILABLE) {
// When Reanimated is available, setGestureState should be defined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
setGestureState!(handlerTag, State.BEGAN);
} else {
console.warn(warningMessage);
}
},
activate: () => {
'worklet';
if (REANIMATED_AVAILABLE) {
// When Reanimated is available, setGestureState should be defined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
setGestureState!(handlerTag, State.ACTIVE);
} else {
console.warn(warningMessage);
}
},
fail: () => {
'worklet';
if (REANIMATED_AVAILABLE) {
// When Reanimated is available, setGestureState should be defined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
setGestureState!(handlerTag, State.FAILED);
} else {
console.warn(warningMessage);
}
},
end: () => {
'worklet';
if (REANIMATED_AVAILABLE) {
// When Reanimated is available, setGestureState should be defined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
setGestureState!(handlerTag, State.END);
} else {
console.warn(warningMessage);
}
},
};
}
export const GestureStateManager = {
create,
};
@@ -0,0 +1,24 @@
import NodeManager from '../../web/tools/NodeManager';
import { GestureStateManagerType } from './gestureStateManager';
export const GestureStateManager = {
create(handlerTag: number): GestureStateManagerType {
return {
begin: () => {
NodeManager.getHandler(handlerTag).begin();
},
activate: () => {
NodeManager.getHandler(handlerTag).activate(true);
},
fail: () => {
NodeManager.getHandler(handlerTag).fail();
},
end: () => {
NodeManager.getHandler(handlerTag).end();
},
};
},
};
@@ -0,0 +1,77 @@
import { BaseGestureConfig, ContinousBaseGesture } from './gesture';
import { GestureUpdateEvent } from '../gestureHandlerCommon';
import type { HoverGestureHandlerEventPayload } from '../GestureHandlerEventPayload';
export type HoverGestureChangeEventPayload = {
changeX: number;
changeY: number;
};
export enum HoverEffect {
NONE = 0,
LIFT = 1,
HIGHLIGHT = 2,
}
export interface HoverGestureConfig {
hoverEffect?: HoverEffect;
}
export const hoverGestureHandlerProps = ['hoverEffect'] as const;
function changeEventCalculator(
current: GestureUpdateEvent<HoverGestureHandlerEventPayload>,
previous?: GestureUpdateEvent<HoverGestureHandlerEventPayload>
) {
'worklet';
let changePayload: HoverGestureChangeEventPayload;
if (previous === undefined) {
changePayload = {
changeX: current.x,
changeY: current.y,
};
} else {
changePayload = {
changeX: current.x - previous.x,
changeY: current.y - previous.y,
};
}
return { ...current, ...changePayload };
}
export class HoverGesture extends ContinousBaseGesture<
HoverGestureHandlerEventPayload,
HoverGestureChangeEventPayload
> {
public config: BaseGestureConfig & HoverGestureConfig = {};
constructor() {
super();
this.handlerName = 'HoverGestureHandler';
}
/**
* #### iOS only
* Sets the visual hover effect.
*/
effect(effect: HoverEffect) {
this.config.hoverEffect = effect;
return this;
}
onChange(
callback: (
event: GestureUpdateEvent<
HoverGestureHandlerEventPayload & HoverGestureChangeEventPayload
>
) => void
) {
// @ts-ignore TS being overprotective, HoverGestureHandlerEventPayload is Record
this.handlers.changeEventCalculator = changeEventCalculator;
return super.onChange(callback);
}
}
export type HoverGestureType = InstanceType<typeof HoverGesture>;
@@ -0,0 +1,45 @@
import { BaseGesture, BaseGestureConfig } from './gesture';
import { LongPressGestureConfig } from '../LongPressGestureHandler';
import type { LongPressGestureHandlerEventPayload } from '../GestureHandlerEventPayload';
export class LongPressGesture extends BaseGesture<LongPressGestureHandlerEventPayload> {
public config: BaseGestureConfig & LongPressGestureConfig = {};
constructor() {
super();
this.handlerName = 'LongPressGestureHandler';
this.shouldCancelWhenOutside(true);
}
/**
* Minimum time, expressed in milliseconds, that a finger must remain pressed on the corresponding view.
* The default value is 500.
* @param duration
*/
minDuration(duration: number) {
this.config.minDurationMs = duration;
return this;
}
/**
* Maximum distance, expressed in points, that defines how far the finger is allowed to travel during a long press gesture.
* @param distance
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/long-press-gesture#maxdistancevalue-number
*/
maxDistance(distance: number) {
this.config.maxDist = distance;
return this;
}
/**
* Determine exact number of points required to handle the long press gesture.
* @param pointers
*/
numberOfPointers(pointers: number) {
this.config.numberOfPointers = pointers;
return this;
}
}
export type LongPressGestureType = InstanceType<typeof LongPressGesture>;
@@ -0,0 +1,31 @@
import { GestureUpdateEvent } from '../gestureHandlerCommon';
import { ContinousBaseGesture } from './gesture';
function changeEventCalculator(
current: GestureUpdateEvent<Record<string, never>>,
_previous?: GestureUpdateEvent<Record<string, never>>
) {
'worklet';
return current;
}
export class ManualGesture extends ContinousBaseGesture<
Record<string, never>,
Record<string, never>
> {
constructor() {
super();
this.handlerName = 'ManualGestureHandler';
}
onChange(
callback: (event: GestureUpdateEvent<Record<string, never>>) => void
) {
// @ts-ignore TS being overprotective, Record<string, never> is Record
this.handlers.changeEventCalculator = changeEventCalculator;
return super.onChange(callback);
}
}
export type ManualGestureType = InstanceType<typeof ManualGesture>;
@@ -0,0 +1,33 @@
import { BaseGestureConfig, BaseGesture } from './gesture';
import { NativeViewGestureConfig } from '../NativeViewGestureHandler';
import type { NativeViewGestureHandlerPayload } from '../GestureHandlerEventPayload';
export class NativeGesture extends BaseGesture<NativeViewGestureHandlerPayload> {
public config: BaseGestureConfig & NativeViewGestureConfig = {};
constructor() {
super();
this.handlerName = 'NativeViewGestureHandler';
}
/**
* When true, underlying handler will activate unconditionally when in `BEGAN` or `UNDETERMINED` state.
* @param value
*/
shouldActivateOnStart(value: boolean) {
this.config.shouldActivateOnStart = value;
return this;
}
/**
* When true, cancels all other gesture handlers when this `NativeViewGestureHandler` receives an `ACTIVE` state event.
* @param value
*/
disallowInterruption(value: boolean) {
this.config.disallowInterruption = value;
return this;
}
}
export type NativeGestureType = InstanceType<typeof NativeGesture>;
@@ -0,0 +1,221 @@
import { BaseGestureConfig, ContinousBaseGesture } from './gesture';
import { GestureUpdateEvent } from '../gestureHandlerCommon';
import { PanGestureConfig } from '../PanGestureHandler';
import type { PanGestureHandlerEventPayload } from '../GestureHandlerEventPayload';
export type PanGestureChangeEventPayload = {
changeX: number;
changeY: number;
};
function changeEventCalculator(
current: GestureUpdateEvent<PanGestureHandlerEventPayload>,
previous?: GestureUpdateEvent<PanGestureHandlerEventPayload>
) {
'worklet';
let changePayload: PanGestureChangeEventPayload;
if (previous === undefined) {
changePayload = {
changeX: current.translationX,
changeY: current.translationY,
};
} else {
changePayload = {
changeX: current.translationX - previous.translationX,
changeY: current.translationY - previous.translationY,
};
}
return { ...current, ...changePayload };
}
export class PanGesture extends ContinousBaseGesture<
PanGestureHandlerEventPayload,
PanGestureChangeEventPayload
> {
public config: BaseGestureConfig & PanGestureConfig = {};
constructor() {
super();
this.handlerName = 'PanGestureHandler';
}
/**
* Range along Y axis (in points) where fingers travels without activation of gesture.
* @param offset
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture#activeoffsetyvalue-number--number
*/
activeOffsetY(
offset: number | [activeOffsetYStart: number, activeOffsetYEnd: number]
) {
if (Array.isArray(offset)) {
this.config.activeOffsetYStart = offset[0];
this.config.activeOffsetYEnd = offset[1];
} else if (offset < 0) {
this.config.activeOffsetYStart = offset;
} else {
this.config.activeOffsetYEnd = offset;
}
return this;
}
/**
* Range along X axis (in points) where fingers travels without activation of gesture.
* @param offset
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture#activeoffsetxvalue-number--number
*/
activeOffsetX(
offset: number | [activeOffsetXStart: number, activeOffsetXEnd: number]
) {
if (Array.isArray(offset)) {
this.config.activeOffsetXStart = offset[0];
this.config.activeOffsetXEnd = offset[1];
} else if (offset < 0) {
this.config.activeOffsetXStart = offset;
} else {
this.config.activeOffsetXEnd = offset;
}
return this;
}
/**
* When the finger moves outside this range (in points) along Y axis and gesture hasn't yet activated it will fail recognizing the gesture.
* @param offset
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture#failoffsetyvalue-number--number
*/
failOffsetY(
offset: number | [failOffsetYStart: number, failOffsetYEnd: number]
) {
if (Array.isArray(offset)) {
this.config.failOffsetYStart = offset[0];
this.config.failOffsetYEnd = offset[1];
} else if (offset < 0) {
this.config.failOffsetYStart = offset;
} else {
this.config.failOffsetYEnd = offset;
}
return this;
}
/**
* When the finger moves outside this range (in points) along X axis and gesture hasn't yet activated it will fail recognizing the gesture.
* @param offset
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture#failoffsetxvalue-number--number
*/
failOffsetX(
offset: number | [failOffsetXStart: number, failOffsetXEnd: number]
) {
if (Array.isArray(offset)) {
this.config.failOffsetXStart = offset[0];
this.config.failOffsetXEnd = offset[1];
} else if (offset < 0) {
this.config.failOffsetXStart = offset;
} else {
this.config.failOffsetXEnd = offset;
}
return this;
}
/**
* A number of fingers that is required to be placed before gesture can activate. Should be a higher or equal to 0 integer.
* @param minPointers
*/
minPointers(minPointers: number) {
this.config.minPointers = minPointers;
return this;
}
/**
* When the given number of fingers is placed on the screen and gesture hasn't yet activated it will fail recognizing the gesture.
* Should be a higher or equal to 0 integer.
* @param maxPointers
*/
maxPointers(maxPointers: number) {
this.config.maxPointers = maxPointers;
return this;
}
/**
* Minimum distance the finger (or multiple finger) need to travel before the gesture activates.
* Expressed in points.
* @param distance
*/
minDistance(distance: number) {
this.config.minDist = distance;
return this;
}
/**
* Minimum velocity the finger has to reach in order to activate handler.
* @param velocity
*/
minVelocity(velocity: number) {
this.config.minVelocity = velocity;
return this;
}
/**
* Minimum velocity along X axis the finger has to reach in order to activate handler.
* @param velocity
*/
minVelocityX(velocity: number) {
this.config.minVelocityX = velocity;
return this;
}
/**
* Minimum velocity along Y axis the finger has to reach in order to activate handler.
* @param velocity
*/
minVelocityY(velocity: number) {
this.config.minVelocityY = velocity;
return this;
}
/**
* #### Android only
* Android, by default, will calculate translation values based on the position of the leading pointer (the first one that was placed on the screen).
* This modifier allows that behavior to be changed to the one that is default on iOS - the averaged position of all active pointers will be used to calculate the translation values.
* @param value
*/
averageTouches(value: boolean) {
this.config.avgTouches = value;
return this;
}
/**
* #### iOS only
* Enables two-finger gestures on supported devices, for example iPads with trackpads.
* @param value
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture/#enabletrackpadtwofingergesturevalue-boolean-ios-only
*/
enableTrackpadTwoFingerGesture(value: boolean) {
this.config.enableTrackpadTwoFingerGesture = value;
return this;
}
/**
* Duration in milliseconds of the LongPress gesture before Pan is allowed to activate.
* @param duration
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture/#activateafterlongpressduration-number
*/
activateAfterLongPress(duration: number) {
this.config.activateAfterLongPress = duration;
return this;
}
onChange(
callback: (
event: GestureUpdateEvent<
PanGestureHandlerEventPayload & PanGestureChangeEventPayload
>
) => void
) {
// @ts-ignore TS being overprotective, PanGestureHandlerEventPayload is Record
this.handlers.changeEventCalculator = changeEventCalculator;
return super.onChange(callback);
}
}
export type PanGestureType = InstanceType<typeof PanGesture>;
@@ -0,0 +1,51 @@
import { ContinousBaseGesture } from './gesture';
import type { PinchGestureHandlerEventPayload } from '../GestureHandlerEventPayload';
import { GestureUpdateEvent } from '../gestureHandlerCommon';
export type PinchGestureChangeEventPayload = {
scaleChange: number;
};
function changeEventCalculator(
current: GestureUpdateEvent<PinchGestureHandlerEventPayload>,
previous?: GestureUpdateEvent<PinchGestureHandlerEventPayload>
) {
'worklet';
let changePayload: PinchGestureChangeEventPayload;
if (previous === undefined) {
changePayload = {
scaleChange: current.scale,
};
} else {
changePayload = {
scaleChange: current.scale / previous.scale,
};
}
return { ...current, ...changePayload };
}
export class PinchGesture extends ContinousBaseGesture<
PinchGestureHandlerEventPayload,
PinchGestureChangeEventPayload
> {
constructor() {
super();
this.handlerName = 'PinchGestureHandler';
}
onChange(
callback: (
event: GestureUpdateEvent<
PinchGestureHandlerEventPayload & PinchGestureChangeEventPayload
>
) => void
) {
// @ts-ignore TS being overprotective, PinchGestureHandlerEventPayload is Record
this.handlers.changeEventCalculator = changeEventCalculator;
return super.onChange(callback);
}
}
export type PinchGestureType = InstanceType<typeof PinchGesture>;
@@ -0,0 +1,57 @@
import { ComponentClass } from 'react';
import {
GestureUpdateEvent,
GestureStateChangeEvent,
} from '../gestureHandlerCommon';
import { tagMessage } from '../../utils';
export interface SharedValue<T> {
value: T;
}
let Reanimated:
| {
default: {
// Slightly modified definition copied from 'react-native-reanimated'
createAnimatedComponent<P extends object>(
component: ComponentClass<P>,
options?: unknown
): ComponentClass<P>;
};
useEvent: (
callback: (event: GestureUpdateEvent | GestureStateChangeEvent) => void,
events: string[],
rebuild: boolean
) => unknown;
useSharedValue: <T>(value: T) => SharedValue<T>;
setGestureState: (handlerTag: number, newState: number) => void;
}
| undefined;
try {
Reanimated = require('react-native-reanimated');
} catch (e) {
// When 'react-native-reanimated' is not available we want to quietly continue
// @ts-ignore TS demands the variable to be initialized
Reanimated = undefined;
}
if (!Reanimated?.useSharedValue) {
// @ts-ignore Make sure the loaded module is actually Reanimated, if it's not
// reset the module to undefined so we can fallback to the default implementation
Reanimated = undefined;
}
if (Reanimated !== undefined && !Reanimated.setGestureState) {
// The loaded module is Reanimated but it doesn't have the setGestureState defined
Reanimated.setGestureState = () => {
'worklet';
console.warn(
tagMessage(
'Please use newer version of react-native-reanimated in order to control state of the gestures.'
)
);
};
}
export { Reanimated };
@@ -0,0 +1,51 @@
import { ContinousBaseGesture } from './gesture';
import type { RotationGestureHandlerEventPayload } from '../GestureHandlerEventPayload';
import { GestureUpdateEvent } from '../gestureHandlerCommon';
type RotationGestureChangeEventPayload = {
rotationChange: number;
};
function changeEventCalculator(
current: GestureUpdateEvent<RotationGestureHandlerEventPayload>,
previous?: GestureUpdateEvent<RotationGestureHandlerEventPayload>
) {
'worklet';
let changePayload: RotationGestureChangeEventPayload;
if (previous === undefined) {
changePayload = {
rotationChange: current.rotation,
};
} else {
changePayload = {
rotationChange: current.rotation - previous.rotation,
};
}
return { ...current, ...changePayload };
}
export class RotationGesture extends ContinousBaseGesture<
RotationGestureHandlerEventPayload,
RotationGestureChangeEventPayload
> {
constructor() {
super();
this.handlerName = 'RotationGestureHandler';
}
onChange(
callback: (
event: GestureUpdateEvent<
RotationGestureHandlerEventPayload & RotationGestureChangeEventPayload
>
) => void
) {
// @ts-ignore TS being overprotective, RotationGestureHandlerEventPayload is Record
this.handlers.changeEventCalculator = changeEventCalculator;
return super.onChange(callback);
}
}
export type RotationGestureType = InstanceType<typeof RotationGesture>;
@@ -0,0 +1,86 @@
import { BaseGestureConfig, BaseGesture } from './gesture';
import { TapGestureConfig } from '../TapGestureHandler';
import type { TapGestureHandlerEventPayload } from '../GestureHandlerEventPayload';
export class TapGesture extends BaseGesture<TapGestureHandlerEventPayload> {
public config: BaseGestureConfig & TapGestureConfig = {};
constructor() {
super();
this.handlerName = 'TapGestureHandler';
this.shouldCancelWhenOutside(true);
}
/**
* Minimum number of pointers (fingers) required to be placed before the gesture activates.
* Should be a positive integer. The default value is 1.
* @param minPointers
*/
minPointers(minPointers: number) {
this.config.minPointers = minPointers;
return this;
}
/**
* Number of tap gestures required to activate the gesture.
* The default value is 1.
* @param count
*/
numberOfTaps(count: number) {
this.config.numberOfTaps = count;
return this;
}
/**
* Maximum distance, expressed in points, that defines how far the finger is allowed to travel during a tap gesture.
* @param maxDist
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/tap-gesture#maxdistancevalue-number
*/
maxDistance(maxDist: number) {
this.config.maxDist = maxDist;
return this;
}
/**
* Maximum time, expressed in milliseconds, that defines how fast a finger must be released after a touch.
* The default value is 500.
* @param duration
*/
maxDuration(duration: number) {
this.config.maxDurationMs = duration;
return this;
}
/**
* Maximum time, expressed in milliseconds, that can pass before the next tap — if many taps are required.
* The default value is 500.
* @param delay
*/
maxDelay(delay: number) {
this.config.maxDelayMs = delay;
return this;
}
/**
* Maximum distance, expressed in points, that defines how far the finger is allowed to travel along the X axis during a tap gesture.
* @param delta
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/tap-gesture#maxdeltaxvalue-number
*/
maxDeltaX(delta: number) {
this.config.maxDeltaX = delta;
return this;
}
/**
* Maximum distance, expressed in points, that defines how far the finger is allowed to travel along the Y axis during a tap gesture.
* @param delta
* @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/tap-gesture#maxdeltayvalue-number
*/
maxDeltaY(delta: number) {
this.config.maxDeltaY = delta;
return this;
}
}
export type TapGestureType = InstanceType<typeof TapGesture>;

Some files were not shown because too many files have changed in this diff Show More