react-native-gesture-handler
Version:
Declarative API exposing native platform touch and gesture system to React Native
207 lines (205 loc) • 8.04 kB
JavaScript
;
import * as React from 'react';
import { View } from 'react-native';
import { NativeGestureRole } from '../web/interfaces';
import { GestureLifecycleEvent } from '../web/tools/GestureLifecycleEvents';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
const prefersReducedMotion = () => typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
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,
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);
const viewRef = React.useRef(null);
const setRef = React.useCallback(node => {
viewRef.current = node;
if (typeof externalRef === 'function') {
externalRef(node);
} else if (externalRef != null) {
externalRef.current = node;
}
}, [externalRef]);
React.useEffect(() => {
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 => {
if (!enabled || !gestureEnabledRef.current) {
return;
}
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]);
const handlePointerEnter = React.useCallback(event => {
if (!enabled || event.nativeEvent.pointerType === 'touch') {
return;
}
// Skip duration update while pressed so the press transition owns it.
if (!pressed) {
setCurrentDuration(hoverAnimationInDuration);
}
setHovered(true);
}, [enabled, pressed, hoverAnimationInDuration]);
const handlePointerLeave = React.useCallback(event => {
pressOut(event);
if (event.nativeEvent.pointerType === 'touch') {
return;
}
if (!pressed) {
setCurrentDuration(hoverAnimationOutDuration);
}
setHovered(false);
}, [pressOut, pressed, hoverAnimationOutDuration]);
// 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: pressIn,
onPointerUp: pressOut,
onPointerCancel: pressOut,
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