react-native-gesture-handler
Version:
Declarative API exposing native platform touch and gesture system to React Native
398 lines (391 loc) • 16.2 kB
JavaScript
;
import * as React from 'react';
import { View } from 'react-native';
import { ActionType } from '../ActionType';
import { normalizeHitSlop } from '../handlers/hitSlop';
import { PointerType } from '../PointerType';
import RNGestureHandlerModule from '../RNGestureHandlerModule.web';
import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect';
import { NativeGestureRole } from '../web/interfaces';
import { ButtonEventName } from '../web/tools/ButtonEvents';
import { GestureLifecycleEvent } from '../web/tools/GestureLifecycleEvents';
import { calculateViewScale, getEffectiveBoundingRect, PointerTypeMapping } from '../web/utils';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
const prefersReducedMotion = () => typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
const noopGestureEvent = () => undefined;
// Same coordinate basis as the press path (see PointerEventManager's
// `mapEvent`), so the two payloads agree. Both reads below force a layout flush,
// hence the caller-side gate on a hover callback existing.
const buttonEventFromPointerEvent = event => {
const view = event.currentTarget;
const rect = getEffectiveBoundingRect(view);
const {
scaleX,
scaleY
} = calculateViewScale(view);
const absoluteX = event.nativeEvent.clientX ?? 0;
const absoluteY = event.nativeEvent.clientY ?? 0;
return {
pointerInside: absoluteX >= rect.left && absoluteX <= rect.right && absoluteY >= rect.top && absoluteY <= rect.bottom,
x: (absoluteX - rect.left) / scaleX,
y: (absoluteY - rect.top) / scaleY,
absoluteX,
absoluteY,
numberOfPointers: 1,
pointerType: PointerTypeMapping.get(event.nativeEvent.pointerType ?? '') ?? PointerType.OTHER
};
};
export const ButtonComponent = ({
ref: externalRef,
enabled = true,
tapAnimationInDuration = 50,
tapAnimationOutDuration = 100,
longPressDuration = -1,
longPressAnimationOutDuration = 100,
hoverAnimationInDuration = 50,
hoverAnimationOutDuration = 100,
activeOpacity = 1,
activeScale = 1,
activeUnderlayOpacity = 0,
hoverOpacity: hoverOpacityProp,
hoverScale: hoverScaleProp,
hoverUnderlayOpacity: hoverUnderlayOpacityProp,
defaultOpacity = 1,
defaultScale = 1,
defaultUnderlayOpacity = 0,
underlayColor,
hasLongPressHandler = false,
moduleId: _moduleId,
handlerTag,
cancelOnLeave = true,
gestureTestID,
gestureHitSlop,
onButtonPress,
onButtonPressIn,
onButtonPressOut,
onButtonLongPress,
onButtonHoverIn,
onButtonHoverOut,
onButtonInteractionFinished,
style,
children,
...rest
}) => {
const hoverOpacity = hoverOpacityProp ?? defaultOpacity;
const hoverScale = hoverScaleProp ?? defaultScale;
const hoverUnderlayOpacity = hoverUnderlayOpacityProp ?? defaultUnderlayOpacity;
const [pressed, setPressed] = React.useState(false);
const [hovered, setHovered] = React.useState(false);
const [currentDuration, setCurrentDuration] = React.useState(tapAnimationInDuration);
const pressInTimestamp = React.useRef(0);
const pressOutTimer = React.useRef(null);
const gestureEnabledRef = React.useRef(true);
// Hover events outlive the pointer event behind them (`enabled` flipping while
// hovered), so the payload is copied out.
const hoverSample = React.useRef(null);
// The hover state JS was last told about, which drifts from the effective one
// on purpose — see `dispatchHoverEventIfNeeded`.
const hoverReported = React.useRef(false);
const viewRef = React.useRef(null);
const gesturePropsRef = React.useRef({
// Managed button handlers dispatch their events through DOM CustomEvents,
// so ActionType.NONE prevents these callbacks from being used.
// They remain present because the web module shares its attachment API
// with regular JS-driven gesture handlers.
onGestureHandlerEvent: noopGestureEvent,
onGestureHandlerStateChange: noopGestureEvent,
onGestureHandlerTouchEvent: noopGestureEvent
});
const managedGestureConfigRef = React.useRef({
enabled,
shouldCancelWhenOutside: cancelOnLeave,
shouldActivateOnStart: false,
disallowInterruption: true,
yieldsToContinuousGestures: true,
testID: gestureTestID,
hitSlop: normalizeHitSlop(gestureHitSlop),
hasLongPressHandler,
longPressDuration
});
managedGestureConfigRef.current = {
enabled,
shouldCancelWhenOutside: cancelOnLeave,
shouldActivateOnStart: false,
disallowInterruption: true,
yieldsToContinuousGestures: true,
testID: gestureTestID,
hitSlop: normalizeHitSlop(gestureHitSlop),
hasLongPressHandler,
longPressDuration
};
const setRef = React.useCallback(node => {
viewRef.current = node;
if (typeof externalRef === 'function') {
externalRef(node);
} else if (externalRef != null) {
externalRef.current = node;
}
}, [externalRef]);
useIsomorphicLayoutEffect(() => {
const node = viewRef.current;
const handleGestureBegan = () => {
gestureEnabledRef.current = true;
};
const handleGestureCanceled = () => {
gestureEnabledRef.current = false;
if (pressOutTimer.current != null) {
clearTimeout(pressOutTimer.current);
pressOutTimer.current = null;
}
pressInTimestamp.current = 0;
setPressed(false);
};
node?.addEventListener(GestureLifecycleEvent.Began, handleGestureBegan);
node?.addEventListener(GestureLifecycleEvent.Canceled, handleGestureCanceled);
return () => {
node?.removeEventListener(GestureLifecycleEvent.Began, handleGestureBegan);
node?.removeEventListener(GestureLifecycleEvent.Canceled, handleGestureCanceled);
if (pressOutTimer.current != null) {
clearTimeout(pressOutTimer.current);
}
};
}, []);
const pressIn = React.useCallback(event => {
const isManagedButtonEvent = event === undefined;
if (!enabled || !isManagedButtonEvent && !gestureEnabledRef.current) {
return;
}
// Managed button events are emitted before the Began lifecycle event.
// Unmanaged buttons are still driven by their wrapping native gesture,
// so they must keep honoring its cancellation state.
if (isManagedButtonEvent) {
gestureEnabledRef.current = true;
}
event?.stopPropagation();
if (pressOutTimer.current != null) {
clearTimeout(pressOutTimer.current);
pressOutTimer.current = null;
}
pressInTimestamp.current = performance.now();
setCurrentDuration(tapAnimationInDuration);
setPressed(true);
}, [enabled, tapAnimationInDuration]);
const pressOut = React.useCallback(event => {
// Only release if a press-in was actually recorded — guards against
// stray pointer events and lets us complete the release cycle even if
// `enabled` flipped to false between press-in and press-out.
if (pressInTimestamp.current === 0 || !gestureEnabledRef.current) {
return;
}
event?.stopPropagation();
if (pressOutTimer.current != null) {
clearTimeout(pressOutTimer.current);
pressOutTimer.current = null;
}
const elapsed = performance.now() - pressInTimestamp.current;
pressInTimestamp.current = 0;
if (longPressDuration >= 0 && elapsed >= longPressDuration) {
// Long-press release — use the configured long-press out duration.
setCurrentDuration(longPressAnimationOutDuration);
setPressed(false);
} else if (elapsed >= tapAnimationInDuration) {
// Press-in animation fully finished - release with the configured out duration.
setCurrentDuration(tapAnimationOutDuration);
setPressed(false);
// elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play
} else if (elapsed * 2 >= tapAnimationOutDuration) {
setCurrentDuration(elapsed);
setPressed(false);
} else {
// Let the in-progress CSS press-in transition continue; schedule press-out after remaining time.
const remaining = tapAnimationInDuration - elapsed;
pressOutTimer.current = setTimeout(() => {
pressOutTimer.current = null;
setCurrentDuration(tapAnimationOutDuration);
setPressed(false);
}, prefersReducedMotion() ? 0 : remaining);
}
}, [longPressDuration, longPressAnimationOutDuration, tapAnimationInDuration, tapAnimationOutDuration]);
useIsomorphicLayoutEffect(() => {
const node = viewRef.current;
if (handlerTag === undefined || node == null) {
return;
}
const wrapEvent = event => ({
nativeEvent: event.detail
});
const handlePress = event => {
onButtonPress?.(wrapEvent(event));
};
const handlePressIn = event => {
pressIn();
onButtonPressIn?.(wrapEvent(event));
};
const handlePressOut = event => {
pressOut();
onButtonPressOut?.(wrapEvent(event));
};
const handleLongPress = event => {
onButtonLongPress?.(wrapEvent(event));
};
const handleInteractionFinished = event => {
onButtonInteractionFinished?.(wrapEvent(event));
};
node.addEventListener(ButtonEventName.Press, handlePress);
node.addEventListener(ButtonEventName.PressIn, handlePressIn);
node.addEventListener(ButtonEventName.PressOut, handlePressOut);
node.addEventListener(ButtonEventName.LongPress, handleLongPress);
node.addEventListener(ButtonEventName.InteractionFinished, handleInteractionFinished);
return () => {
node.removeEventListener(ButtonEventName.Press, handlePress);
node.removeEventListener(ButtonEventName.PressIn, handlePressIn);
node.removeEventListener(ButtonEventName.PressOut, handlePressOut);
node.removeEventListener(ButtonEventName.LongPress, handleLongPress);
node.removeEventListener(ButtonEventName.InteractionFinished, handleInteractionFinished);
};
}, [handlerTag, onButtonInteractionFinished, onButtonLongPress, onButtonPress, onButtonPressIn, onButtonPressOut, pressIn, pressOut]);
useIsomorphicLayoutEffect(() => {
const node = viewRef.current;
if (handlerTag === undefined || node === null) {
return;
}
RNGestureHandlerModule.createGestureHandler('NativeViewGestureHandler', handlerTag, managedGestureConfigRef.current);
RNGestureHandlerModule.attachGestureHandler(handlerTag, node, ActionType.NONE, gesturePropsRef);
return () => {
RNGestureHandlerModule.detachGestureHandler(handlerTag);
RNGestureHandlerModule.dropGestureHandler(handlerTag);
};
}, [handlerTag]);
useIsomorphicLayoutEffect(() => {
if (handlerTag === undefined) {
return;
}
RNGestureHandlerModule.setGestureHandlerConfig(handlerTag, managedGestureConfigRef.current);
}, [cancelOnLeave, enabled, gestureHitSlop, gestureTestID, handlerTag, hasLongPressHandler, longPressDuration]);
// The payload costs a layout read, so only sample when someone is listening.
// Truthiness, so `onHoverIn={cond && handler}` can't slip a `false` past.
const hasHoverCallbacks = Boolean(onButtonHoverIn) || Boolean(onButtonHoverOut);
// Emits the balancing hover event whenever `hoverReported` drifts from the
// effective hover state. Recomputed from an argument rather than read off the
// render-scope `effectiveHovered`, because a handler reports a flag React
// hasn't committed to `hovered` yet.
const dispatchHoverEventIfNeeded = React.useCallback(isHovered => {
const effectiveHovered = isHovered && enabled;
const sample = hoverSample.current;
if (effectiveHovered === hoverReported.current || sample === null) {
return;
}
hoverReported.current = effectiveHovered;
const event = {
nativeEvent: sample
};
if (effectiveHovered) {
onButtonHoverIn?.(event);
} else {
onButtonHoverOut?.(event);
}
}, [enabled, onButtonHoverIn, onButtonHoverOut]);
const handlePointerEnter = React.useCallback(event => {
if (event.nativeEvent.pointerType === 'touch') {
return;
}
if (hasHoverCallbacks) {
hoverSample.current = buttonEventFromPointerEvent(event);
}
// From the handler rather than the effect below, so a leave and a
// re-enter batched into one render still produce both events.
dispatchHoverEventIfNeeded(true);
// Skip duration update while pressed so the press transition owns it.
if (!pressed) {
setCurrentDuration(hoverAnimationInDuration);
}
// Tracked regardless of `enabled`, which is masked at render and when
// reporting — so hover resumes if it flips back while inside.
setHovered(true);
}, [hasHoverCallbacks, hoverAnimationInDuration, pressed, dispatchHoverEventIfNeeded]);
const handlePointerLeave = React.useCallback(event => {
if (handlerTag === undefined) {
pressOut(event);
}
if (event.nativeEvent.pointerType === 'touch') {
return;
}
if (hasHoverCallbacks) {
hoverSample.current = buttonEventFromPointerEvent(event);
}
dispatchHoverEventIfNeeded(false);
if (!pressed) {
setCurrentDuration(hoverAnimationOutDuration);
}
setHovered(false);
}, [handlerTag, hasHoverCallbacks, hoverAnimationOutDuration, pressOut, pressed, dispatchHoverEventIfNeeded]);
// The one transition the pointer handlers can't see: `enabled` flipping while
// the pointer is inside. Plain `useEffect` keeps a consumer that toggles
// `enabled` from its own hover callback out of the commit phase.
React.useEffect(() => {
dispatchHoverEventIfNeeded(hovered);
}, [hovered, dispatchHoverEventIfNeeded]);
// Mask hover at render rather than clearing the state. Avoids a state
// write inside an effect, and lets hover resume naturally when `enabled`
// flips back to true while the pointer is still inside.
const effectiveHovered = hovered && enabled;
const currentUnderlayOpacity = pressed ? activeUnderlayOpacity : effectiveHovered ? hoverUnderlayOpacity : defaultUnderlayOpacity;
const hasUnderlay = underlayColor != null && underlayColor !== 'transparent';
const hasOpacity = activeOpacity !== 1 || hoverOpacity !== 1 || defaultOpacity !== 1;
const currentOpacity = pressed ? activeOpacity : effectiveHovered ? hoverOpacity : defaultOpacity;
const hasScale = activeScale !== 1 || hoverScale !== 1 || defaultScale !== 1;
const currentScale = pressed ? activeScale : effectiveHovered ? hoverScale : defaultScale;
const easing = 'cubic-bezier(0.5, 1, 0.89, 1)';
const effectiveDuration = prefersReducedMotion() ? 0 : currentDuration;
const transitionProps = [];
if (hasOpacity) {
transitionProps.push(`opacity ${effectiveDuration}ms ${easing}`);
}
if (hasScale) {
transitionProps.push(`transform ${effectiveDuration}ms ${easing}`);
}
const transition = transitionProps.join(', ');
return /*#__PURE__*/_jsxs(View, {
...rest,
ref: setRef,
accessibilityRole: "button",
style: [style, {
...(hasOpacity && {
opacity: currentOpacity
}),
...(hasScale && {
transform: [{
scale: currentScale
}]
}),
// @ts-ignore - web-only CSS property
transition,
// Clip the underlay to the view bounds (respects borderRadius).
...(hasUnderlay && {
overflow: 'hidden'
})
}],
onPointerEnter: handlePointerEnter,
onPointerDown: handlerTag === undefined ? pressIn : undefined,
onPointerUp: handlerTag === undefined ? pressOut : undefined,
onPointerCancel: handlerTag === undefined ? pressOut : undefined,
onPointerLeave: handlePointerLeave,
children: [hasUnderlay && /*#__PURE__*/_jsx(View, {
style: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: underlayColor,
opacity: currentUnderlayOpacity,
// @ts-ignore - web-only CSS properties
transition: `opacity ${effectiveDuration}ms ${easing}`,
pointerEvents: 'none'
}
}), children]
});
};
ButtonComponent.displayName = NativeGestureRole.Button;
export default ButtonComponent;
//# sourceMappingURL=GestureHandlerButton.web.js.map