react-native-reanimated-carousel
Version:
Simple carousel component.fully implemented using Reanimated 2.Infinitely scrolling, very smooth.
337 lines (293 loc) • 12.8 kB
JavaScript
import React, { useCallback } from "react";
import { GestureDetector } from "react-native-gesture-handler";
import Animated, { cancelAnimation, measure, runOnJS, useAnimatedReaction, useAnimatedRef, useDerivedValue, useSharedValue, withDecay } from "react-native-reanimated";
import { Easing } from "../constants";
import { usePanGestureProxy } from "../hooks/usePanGestureProxy";
import { useGlobalState } from "../store";
import { dealWithAnimation } from "../utils/deal-with-animation";
const IScrollViewGesture = props => {
const {
props: {
onConfigurePanGesture,
vertical,
pagingEnabled,
snapEnabled,
loop,
scrollAnimationDuration,
withAnimation,
enabled,
dataLength,
overscrollEnabled,
maxScrollDistancePerSwipe,
minScrollDistancePerSwipe,
fixedDirection
},
common: {
size
},
layout: {
updateContainerSize
}
} = useGlobalState();
const {
translation,
testID,
style = {},
onScrollStart,
onScrollEnd,
onTouchBegin,
onTouchEnd
} = props;
const maxPage = dataLength;
const isHorizontal = useDerivedValue(() => !vertical, [vertical]);
const max = useSharedValue(0);
const panOffset = useSharedValue(undefined); // set to undefined when not actively in a pan gesture
const touching = useSharedValue(false);
const validStart = useSharedValue(false);
const scrollEndTranslation = useSharedValue(0);
const scrollEndVelocity = useSharedValue(0);
const containerRef = useAnimatedRef();
const maxScrollDistancePerSwipeIsSet = typeof maxScrollDistancePerSwipe === "number";
const minScrollDistancePerSwipeIsSet = typeof minScrollDistancePerSwipe === "number"; // Get the limit of the scroll.
const getLimit = React.useCallback(() => {
"worklet";
if (!loop && !overscrollEnabled) {
const measurement = measure(containerRef);
const containerWidth = (measurement === null || measurement === void 0 ? void 0 : measurement.width) || 0; // If the item's total width is less than the container's width, then there is no need to scroll.
if (dataLength * size < containerWidth) return 0; // Disable the "overscroll" effect
return dataLength * size - containerWidth;
}
return dataLength * size;
}, [loop, size, dataLength, overscrollEnabled]);
const withSpring = React.useCallback((toValue, onFinished) => {
"worklet";
const defaultWithAnimation = {
type: "timing",
config: {
duration: scrollAnimationDuration + 100,
easing: Easing.easeOutQuart
}
};
return dealWithAnimation(withAnimation !== null && withAnimation !== void 0 ? withAnimation : defaultWithAnimation)(toValue, isFinished => {
"worklet";
if (isFinished) onFinished && runOnJS(onFinished)();
});
}, [scrollAnimationDuration, withAnimation]);
const endWithSpring = React.useCallback((scrollEndTranslationValue, scrollEndVelocityValue, onFinished) => {
"worklet";
const origin = translation.value;
const velocity = scrollEndVelocityValue; // Default to scroll in the direction of the slide (with deceleration)
let finalTranslation = withDecay({
velocity,
deceleration: 0.999
}); // If the distance of the swipe exceeds the max scroll distance, keep the view at the current position
if (maxScrollDistancePerSwipeIsSet && Math.abs(scrollEndTranslationValue) > maxScrollDistancePerSwipe) {
finalTranslation = origin;
} else {
/**
* The page size is the same as the item size.
* If direction is vertical, the page size is the height of the item.
* If direction is horizontal, the page size is the width of the item.
*
* `page size` equals to `size` variable.
* */
// calculate target "nextPage" based on the final pan position and the velocity of
// the pan gesture at termination; this allows for a quick "flick" to indicate a far
// off page change.
const nextPage = -Math.round((origin + velocity * 2) / size);
if (pagingEnabled) {
// we'll never go further than a single page away from the current page when paging
// is enabled.
// distance with direction
const offset = -(scrollEndTranslationValue >= 0 ? 1 : -1); // 1 or -1
const computed = offset < 0 ? Math.ceil : Math.floor;
const page = computed(-origin / size);
const velocityDirection = -Math.sign(velocity);
if (page === nextPage || velocityDirection !== offset) {
// not going anywhere! Velocity was insufficient to overcome the distance to get to a
// further page. Let's reset gently to the current page.
finalTranslation = withSpring(withProcessTranslation(-page * size), onFinished);
} else if (loop) {
const finalPage = page + offset;
finalTranslation = withSpring(withProcessTranslation(-finalPage * size), onFinished);
} else {
const finalPage = Math.min(maxPage - 1, Math.max(0, page + offset));
finalTranslation = withSpring(withProcessTranslation(-finalPage * size), onFinished);
}
}
if (!pagingEnabled && snapEnabled) {
// scroll to the nearest item
finalTranslation = withSpring(withProcessTranslation(-nextPage * size), onFinished);
}
}
translation.value = finalTranslation;
function withProcessTranslation(translation) {
if (!loop && !overscrollEnabled) {
const limit = getLimit();
const sign = Math.sign(translation);
return sign * Math.max(0, Math.min(limit, Math.abs(translation)));
}
return translation;
}
}, [withSpring, size, maxPage, loop, snapEnabled, translation, pagingEnabled, maxScrollDistancePerSwipe, maxScrollDistancePerSwipeIsSet]);
const onFinish = React.useCallback(isFinished => {
"worklet";
if (isFinished) {
touching.value = false;
onScrollEnd && runOnJS(onScrollEnd)();
}
}, [onScrollEnd, touching]);
const activeDecay = React.useCallback(() => {
"worklet";
touching.value = true;
translation.value = withDecay({
velocity: scrollEndVelocity.value
}, isFinished => onFinish(isFinished));
}, [onFinish, scrollEndVelocity, touching, translation]);
const resetBoundary = React.useCallback(() => {
"worklet";
if (touching.value) return;
if (translation.value > 0) {
if (scrollEndTranslation.value < 0) {
activeDecay();
return;
}
if (!loop) {
translation.value = withSpring(0);
return;
}
}
if (translation.value < -((maxPage - 1) * size)) {
if (scrollEndTranslation.value > 0) {
activeDecay();
return;
}
if (!loop) translation.value = withSpring(-((maxPage - 1) * size));
}
}, [touching, translation, maxPage, size, scrollEndTranslation, loop, activeDecay, withSpring]);
useAnimatedReaction(() => translation.value, () => {
if (!pagingEnabled) resetBoundary();
}, [pagingEnabled, resetBoundary]);
function withProcessTranslation(translation) {
"worklet";
if (!loop && !overscrollEnabled) {
const limit = getLimit();
const sign = Math.sign(translation);
return sign * Math.max(0, Math.min(limit, Math.abs(translation)));
}
return translation;
}
const onGestureStart = useCallback(_ => {
"worklet";
touching.value = true;
validStart.value = true;
onScrollStart && runOnJS(onScrollStart)();
max.value = (maxPage - 1) * size;
if (!loop && !overscrollEnabled) max.value = getLimit();
panOffset.value = translation.value;
}, [max, size, maxPage, loop, touching, panOffset, validStart, translation, overscrollEnabled, getLimit, onScrollStart]);
const onGestureUpdate = useCallback(e => {
"worklet";
if (panOffset.value === undefined) {
// This may happen if `onGestureStart` is called as a part of the
// JS thread (instead of the UI thread / worklet). If so, when
// `onGestureStart` sets panOffset.value, the set will be asynchronous,
// and so it may not actually occur before `onGestureUpdate` is called.
//
// Keeping this value as `undefined` when it is not active protects us
// from the situation where we may use the previous value for panOffset
// instead; this would cause a visual flicker in the carousel.
// console.warn("onGestureUpdate: panOffset is undefined");
return;
}
if (validStart.value) {
validStart.value = false;
cancelAnimation(translation);
}
touching.value = true;
const {
translationX,
translationY
} = e;
let panTranslation = isHorizontal.value ? translationX : translationY;
if (fixedDirection === "negative") panTranslation = -Math.abs(panTranslation);else if (fixedDirection === "positive") panTranslation = +Math.abs(panTranslation);
if (!loop) {
if (translation.value > 0 || translation.value < -max.value) {
const boundary = translation.value > 0 ? 0 : -max.value;
const fixed = boundary - panOffset.value;
const dynamic = panTranslation - fixed;
translation.value = boundary + dynamic * 0.5;
return;
}
}
const translationValue = panOffset.value + panTranslation;
translation.value = translationValue;
}, [isHorizontal, max, panOffset, loop, overscrollEnabled, fixedDirection, translation, validStart, touching]);
const onGestureEnd = useCallback((e, _success) => {
"worklet";
if (panOffset.value === undefined) {
// console.warn("onGestureEnd: panOffset is undefined");
return;
}
const {
velocityX,
velocityY,
translationX,
translationY
} = e;
const scrollEndVelocityValue = isHorizontal.value ? velocityX : velocityY;
scrollEndVelocity.value = scrollEndVelocityValue; // may update async: see https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue#remarks
let panTranslation = isHorizontal.value ? translationX : translationY;
if (fixedDirection === "negative") panTranslation = -Math.abs(panTranslation);else if (fixedDirection === "positive") panTranslation = +Math.abs(panTranslation);
scrollEndTranslation.value = panTranslation; // may update async: see https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue#remarks
const totalTranslation = scrollEndVelocityValue + panTranslation;
/**
* If the maximum scroll distance is set and the translation `exceeds the maximum scroll distance`,
* the carousel will keep the view at the current position.
*/
if (maxScrollDistancePerSwipeIsSet && Math.abs(totalTranslation) > maxScrollDistancePerSwipe) {
const nextPage = Math.round((panOffset.value + maxScrollDistancePerSwipe * Math.sign(totalTranslation)) / size) * size;
translation.value = withSpring(withProcessTranslation(nextPage), onScrollEnd);
} else if (
/**
* If the minimum scroll distance is set and the translation `didn't exceeds the minimum scroll distance`,
* the carousel will keep the view at the current position.
*/
minScrollDistancePerSwipeIsSet && Math.abs(totalTranslation) < minScrollDistancePerSwipe) {
const nextPage = Math.round((panOffset.value + minScrollDistancePerSwipe * Math.sign(totalTranslation)) / size) * size;
translation.value = withSpring(withProcessTranslation(nextPage), onScrollEnd);
} else {
endWithSpring(panTranslation, scrollEndVelocityValue, onScrollEnd);
}
if (!loop) touching.value = false;
panOffset.value = undefined;
}, [size, loop, touching, panOffset, translation, isHorizontal, scrollEndVelocity, scrollEndTranslation, fixedDirection, maxScrollDistancePerSwipeIsSet, maxScrollDistancePerSwipe, maxScrollDistancePerSwipeIsSet, minScrollDistancePerSwipe, endWithSpring, withSpring, onScrollEnd]);
const gesture = usePanGestureProxy({
onConfigurePanGesture,
onGestureStart,
onGestureUpdate,
onGestureEnd,
options: {
enabled
}
});
const onLayout = React.useCallback(e => {
"worklet";
updateContainerSize({
width: e.nativeEvent.layout.width,
height: e.nativeEvent.layout.height
});
}, [updateContainerSize]);
return /*#__PURE__*/React.createElement(GestureDetector, {
gesture: gesture
}, /*#__PURE__*/React.createElement(Animated.View, {
ref: containerRef,
testID: testID,
style: style,
onTouchStart: onTouchBegin,
onTouchEnd: onTouchEnd,
onLayout: onLayout
}, props.children));
};
export const ScrollViewGesture = IScrollViewGesture;
//# sourceMappingURL=ScrollViewGesture.js.map