💡
Check out the quick-swipe
animation demo for the full source code here (opens in a new tab)
import * as React from "react";
import type { ICarouselInstance } from "react-native-reanimated-carousel";
import Carousel from "react-native-reanimated-carousel";
import { SBItem } from "@/components/SBItem";
import { window } from "@/constants/sizes";
import { Image, ImageSourcePropType, View, ViewStyle } from "react-native";
import Animated, {
Easing,
Extrapolation,
interpolate,
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import * as Haptics from "expo-haptics";
import { getImages } from "./images";
import { IS_WEB } from "@/constants/platform";
const data = getImages().slice(0, 68);
function Index() {
const scrollOffsetValue = useSharedValue<number>(0);
const ref = React.useRef<ICarouselInstance>(null);
const baseOptions = {
vertical: false,
width: window.width,
height: window.width / 2,
} as const;
return (
<View
id="carousel-component"
dataSet={{ kind: "custom-animations", name: "quick-swipe" }}
style={{ paddingVertical: 20 }}
>
<Carousel
{...baseOptions}
loop={false}
enabled // Default is true, just for demo
ref={ref}
defaultScrollOffsetValue={scrollOffsetValue}
testID={"xxx"}
style={{ width: "100%" }}
autoPlay={false}
autoPlayInterval={1000}
data={data}
onConfigurePanGesture={(g) => {
"worklet";
g.enabled(false);
}}
pagingEnabled
onSnapToItem={(index) => console.log("current index:", index)}
windowSize={2}
renderItem={({ index, item }) => {
return (
<Animated.View key={index} style={{ flex: 1 }}>
<SBItem showIndex={false} img={item} />
</Animated.View>
);
}}
/>
<ThumbnailPagination
style={{ marginVertical: 9 }}
onIndexChange={(index) => {
ref.current?.scrollTo({ index, animated: false });
!IS_WEB && Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}}
/>
</View>
);
}
const ThumbnailPagination: React.FC<{
style?: ViewStyle;
onIndexChange?: (index: number) => void;
}> = ({ style, onIndexChange }) => {
const [_containerWidth, setContainerWidth] = React.useState<number>(0);
const inactiveWidth = 30;
const activeWidth = inactiveWidth * 2;
const itemGap = 5;
const totalWidth =
inactiveWidth * (data.length - 1) +
activeWidth +
itemGap * (data.length - 1);
const swipeProgress = useSharedValue<number>(0);
const activeIndex = useSharedValue<number>(0);
const containerWidth = React.useMemo(() => {
if (totalWidth < _containerWidth) {
return totalWidth;
}
return _containerWidth;
}, [_containerWidth, totalWidth]);
const gesture = React.useMemo(
() =>
Gesture.Pan().onUpdate((event) => {
swipeProgress.value = Math.min(Math.max(event.x, 0), containerWidth);
}),
[activeWidth, inactiveWidth, containerWidth],
);
const animStyles = useAnimatedStyle(() => {
if (containerWidth <= 0) {
return {};
}
const isOverScroll = totalWidth > containerWidth;
if (!isOverScroll) {
return {
transform: [
{
translateX: 0,
},
],
};
}
return {
transform: [
{
translateX: -interpolate(
swipeProgress.value,
[0, containerWidth],
[0, totalWidth - containerWidth],
Extrapolation.CLAMP,
),
},
],
};
}, [containerWidth, totalWidth, containerWidth]);
useAnimatedReaction(
() => activeIndex.value,
(activeIndex) => onIndexChange && runOnJS(onIndexChange)(activeIndex),
[onIndexChange],
);
return (
<GestureDetector gesture={gesture}>
<Animated.View style={{ width: "100%", overflow: "hidden" }}>
<Animated.View
style={[{ flexDirection: "row" }, style, animStyles]}
onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
>
{containerWidth > 0 &&
data.map((item, index) => {
return (
<ThumbnailPaginationItem
key={index}
source={item}
totalItems={data.length}
swipeProgress={swipeProgress}
containerWidth={containerWidth}
activeIndex={activeIndex}
activeWidth={activeWidth}
itemGap={itemGap}
inactiveWidth={inactiveWidth}
totalWidth={totalWidth}
index={index}
style={{ marginRight: itemGap }}
onSwipe={() => {
console.log(`${item} swiped`);
}}
/>
);
})}
</Animated.View>
</Animated.View>
</GestureDetector>
);
};
const ThumbnailPaginationItem: React.FC<{
source: ImageSourcePropType;
containerWidth: number;
totalItems: number;
activeIndex: Animated.SharedValue<number>;
swipeProgress: Animated.SharedValue<number>;
activeWidth: number;
totalWidth: number;
inactiveWidth: number;
itemGap: number;
index: number;
onSwipe?: () => void;
style?: ViewStyle;
}> = ({
source,
containerWidth,
totalItems,
swipeProgress,
index,
itemGap = 0,
activeIndex,
activeWidth,
totalWidth,
inactiveWidth,
style,
}) => {
const isActive = useSharedValue(0);
useAnimatedReaction(
() => {
const onTheRight = index >= activeIndex.value;
const extraWidth = onTheRight ? activeWidth - inactiveWidth : 0;
const inputRange = [
index * (inactiveWidth + itemGap) +
(index === activeIndex.value ? 0 : extraWidth) -
0.1,
index * (inactiveWidth + itemGap) +
(index === activeIndex.value ? 0 : extraWidth),
(index + 1) * (inactiveWidth + itemGap) + extraWidth,
(index + 1) * (inactiveWidth + itemGap) + extraWidth + 0.1,
];
return interpolate(
(swipeProgress.value / containerWidth) * totalWidth,
inputRange,
[0, 1, 1, 0],
Extrapolation.CLAMP,
);
},
(_isActiveAnimVal) => {
isActive.value = _isActiveAnimVal;
},
[
containerWidth,
totalItems,
index,
activeIndex,
activeWidth,
inactiveWidth,
itemGap,
],
);
useAnimatedReaction(
() => {
return isActive.value;
},
(isActiveVal) => {
if (isActiveVal === 1) {
activeIndex.value = index;
}
},
[],
);
const animStyles = useAnimatedStyle(() => {
const widthAnimVal = interpolate(
isActive.value,
[0, 1, 1, 0],
[inactiveWidth, activeWidth, activeWidth, inactiveWidth],
Extrapolation.CLAMP,
);
return {
width: withTiming(widthAnimVal, { duration: 100, easing: Easing.bounce }),
height: 30,
borderRadius: 5,
overflow: "hidden",
};
}, [isActive, activeWidth, inactiveWidth]);
return (
<Animated.View style={[animStyles, style]}>
<Image source={source} style={{ width: "100%", height: "100%" }} />
</Animated.View>
);
};
export default Index;