react-native-screen-transitions
Version:
Easy screen transitions for React Native and Expo
144 lines (119 loc) • 4.05 kB
text/typescript
import type {
GestureStateChangeEvent,
PanGestureHandlerEventPayload,
} from "react-native-gesture-handler";
import { clamp } from "react-native-reanimated";
import type { AnimationMap } from "../../stores/animations";
interface CalculateProgressProps {
animations: AnimationMap;
shouldDismiss: boolean;
event: GestureStateChangeEvent<PanGestureHandlerEventPayload>;
dimensions: { width: number; height: number };
directions: {
horizontal: boolean;
horizontalInverted: boolean;
vertical: boolean;
verticalInverted: boolean;
};
}
const MAX_VELOCITY_MAGNITUDE = 3.2;
const NEAR_ZERO_THRESHOLD = 0.01;
/**
* Converts velocity from pixels/second to normalized units/second (0-1 range)
* and caps the result for stability
*/
const normalize = (velocityPixelsPerSecond: number, screenSize: number) => {
"worklet";
return clamp(
velocityPixelsPerSecond / Math.max(1, screenSize),
-MAX_VELOCITY_MAGNITUDE,
MAX_VELOCITY_MAGNITUDE,
);
};
/**
* Calculates a normalized velocity that moves the current value toward zero.
* Used for spring-back animations when dismissing gestures.
*/
const calculateRestoreVelocity = (
currentValueNormalized: number,
baseVelocityNormalized: number,
) => {
"worklet";
if (Math.abs(currentValueNormalized) < NEAR_ZERO_THRESHOLD) return 0;
const directionTowardZero = Math.sign(currentValueNormalized) || 1;
const clampedVelocity = Math.min(Math.abs(baseVelocityNormalized), 1);
return -directionTowardZero * clampedVelocity;
};
const calculateProgressVelocity = ({
animations,
shouldDismiss,
event,
dimensions,
directions,
}: CalculateProgressProps) => {
"worklet";
const currentProgress = animations.progress.value;
const targetProgress = shouldDismiss ? 0 : 1;
const progressDelta = targetProgress - currentProgress;
const progressDirection = progressDelta === 0 ? 0 : Math.sign(progressDelta);
const normalizedVelocityX = normalize(event.velocityX, dimensions.width);
const normalizedVelocityY = normalize(event.velocityY, dimensions.height);
const normalizedTranslationX = Math.abs(
event.translationX / dimensions.width,
);
const normalizedTranslationY = Math.abs(
event.translationY / dimensions.height,
);
const supportsHorizontalGestures =
directions.horizontal || directions.horizontalInverted;
const supportsVerticalGestures =
directions.vertical || directions.verticalInverted;
let progressVelocityMagnitude = 0;
// Determine which axis should drive the progress velocity
if (
supportsHorizontalGestures &&
(!supportsVerticalGestures ||
normalizedTranslationX >= normalizedTranslationY)
) {
progressVelocityMagnitude = Math.abs(normalizedVelocityX);
} else if (supportsVerticalGestures) {
progressVelocityMagnitude = Math.abs(normalizedVelocityY);
} else {
progressVelocityMagnitude = Math.max(
Math.abs(normalizedVelocityX),
Math.abs(normalizedVelocityY),
);
}
// Apply direction and clamp to prevent overly energetic springs
return (
progressDirection *
clamp(progressVelocityMagnitude, 0, MAX_VELOCITY_MAGNITUDE)
);
};
/**
* Determines if a gesture should trigger dismissal based on combined
* translation and velocity in normalized screen units (0-1 range).
*
* Formula: |translation/screen + clamp(velocity/screen, ±1) * velocityWeight| > 0.5
*/
const shouldPassDismissalThreshold = (
translationPixels: number,
velocityPixelsPerSecond: number,
screenSize: number,
velocityWeight: number,
) => {
"worklet";
const normalizedTranslation = translationPixels / Math.max(1, screenSize);
const normalizedVelocity = normalize(velocityPixelsPerSecond, screenSize);
const projectedNormalizedPosition =
normalizedTranslation + normalizedVelocity * velocityWeight;
const exceedsThreshold = Math.abs(projectedNormalizedPosition) > 0.5;
const hasMovement = translationPixels !== 0 || velocityPixelsPerSecond !== 0;
return exceedsThreshold && hasMovement;
};
export const velocity = {
normalize,
calculateRestoreVelocity,
calculateProgressVelocity,
shouldPassDismissalThreshold,
};