diff --git a/.changeset/blue-news-stare.md b/.changeset/blue-news-stare.md new file mode 100644 index 00000000..a79fa760 --- /dev/null +++ b/.changeset/blue-news-stare.md @@ -0,0 +1,6 @@ +--- +"react-native-reanimated-carousel": minor +--- + +- add `itemWidth`/`itemHeight` props so horizontal and vertical carousels can define their snapping step explicitly (e.g. to show multiple cards per page) +- default behaviour still falls back to the carousel container size or legacy `width`/`height` props diff --git a/.changeset/odd-news-carry.md b/.changeset/odd-news-carry.md new file mode 100644 index 00000000..02fc093e --- /dev/null +++ b/.changeset/odd-news-carry.md @@ -0,0 +1,29 @@ +--- +"react-native-reanimated-carousel": minor +--- + +## ✨ Style API refresh + +- `style` now controls the outer carousel container (positioning, width/height, margins). +- New `contentContainerStyle` replaces `containerStyle` for styling the scrollable content. +- `width` and `height` props are deprecated; define size via `style` instead. + +### Migration Example + +```tsx +// Before + + +// After + +``` + +- Any layout logic still works; simply move `width`/`height` into `style` and container tweaks into `contentContainerStyle`. +- `contentContainerStyle` runs on the JS thread—avoid adding `opacity` / `transform` there if you rely on built-in animations. diff --git a/.gitignore b/.gitignore index 307cddfe..a1f18ac4 100644 --- a/.gitignore +++ b/.gitignore @@ -81,4 +81,7 @@ coverage/ .issue-tasks/ # Agents Docs -*.local.md \ No newline at end of file +*.local.md + +# Claude +CLAUDE.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa436bc..8ff85d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ - [#850](https://github.com/dohooo/react-native-reanimated-carousel/pull/850) [`9b388e6`](https://github.com/dohooo/react-native-reanimated-carousel/commit/9b388e6f6237126c4ed25c2442c4b788aad7adf6) Thanks [@dohooo](https://github.com/dohooo)! - # 🎯 Support for Expo 54 & Dynamic Sizing +- Style props refactoring: + + - **BREAKING CHANGE**: The styling props have been refactored for clarity and consistency with React Native's `ScrollView`. + - **DEPRECATED**: `width` and `height` props are now deprecated. Please use the `style` prop to define component size. + - **NEW**: The `style` prop now controls the outer container's style and is the primary way to set `width` and `height`. + - **NEW**: A new `contentContainerStyle` prop has been added to control the style of the inner scrollable content. + + #### Migration Guide + + **Before:** + ```jsx + + ``` + + **After:** + ```jsx + + ``` + ## ✨ Major Features ### Dynamic Sizing Support diff --git a/example/app/app/demos/basic-layouts/left-align/demo.tsx b/example/app/app/demos/basic-layouts/left-align/demo.tsx index c1ff73b7..d0c40fc8 100644 --- a/example/app/app/demos/basic-layouts/left-align/demo.tsx +++ b/example/app/app/demos/basic-layouts/left-align/demo.tsx @@ -10,13 +10,11 @@ function Index() { console.log("current index:", index)} renderItem={renderItem({ rounded: true, style: { marginRight: 8 } })} /> diff --git a/example/app/app/demos/basic-layouts/left-align/index.tsx b/example/app/app/demos/basic-layouts/left-align/index.tsx index be59d7f5..653214d6 100644 --- a/example/app/app/demos/basic-layouts/left-align/index.tsx +++ b/example/app/app/demos/basic-layouts/left-align/index.tsx @@ -6,7 +6,8 @@ import { CaptureWrapper } from "@/store/CaptureProvider"; import { renderItem } from "@/utils/render-item"; import * as React from "react"; import { View } from "react-native"; -import type { ICarouselInstance } from "react-native-reanimated-carousel"; +import type { StyleProp, ViewStyle } from "react-native"; +import type { ICarouselInstance, TCarouselProps } from "react-native-reanimated-carousel"; import Carousel from "react-native-reanimated-carousel"; function Index() { @@ -18,23 +19,31 @@ function Index() { autoPlayInterval: 2000, autoPlayReverse: false, data: defaultDataWith6Colors, - height: 258, loop: true, pagingEnabled: true, snapEnabled: true, vertical: false, - width: window.width, }, }); + const { + width: _ignoredWidth, + height: _ignoredHeight, + ...restSettings + } = advancedSettings as TCarouselProps; + return ( console.log("current index:", index)} renderItem={renderItem({ rounded: true, style: { marginRight: 8 } })} /> diff --git a/example/app/app/demos/basic-layouts/normal/demo.tsx b/example/app/app/demos/basic-layouts/normal/demo.tsx index fd26ec45..81bb9e81 100644 --- a/example/app/app/demos/basic-layouts/normal/demo.tsx +++ b/example/app/app/demos/basic-layouts/normal/demo.tsx @@ -4,6 +4,8 @@ import { View } from "react-native"; import { useSharedValue } from "react-native-reanimated"; import Carousel from "react-native-reanimated-carousel"; +import { window } from "@/constants/sizes"; + const defaultDataWith6Colors = ["#B0604D", "#899F9C", "#B3C680", "#5C6265", "#F5D399", "#F1F1F1"]; function Index() { @@ -14,14 +16,12 @@ function Index() { { console.log("Scroll start"); }} diff --git a/example/app/app/demos/basic-layouts/normal/index.tsx b/example/app/app/demos/basic-layouts/normal/index.tsx index 66c37054..4303a00b 100644 --- a/example/app/app/demos/basic-layouts/normal/index.tsx +++ b/example/app/app/demos/basic-layouts/normal/index.tsx @@ -5,8 +5,9 @@ import { useAdvancedSettings } from "@/hooks/useSettings"; import { CaptureWrapper } from "@/store/CaptureProvider"; import { renderItem } from "@/utils/render-item"; import * as React from "react"; +import type { StyleProp, ViewStyle } from "react-native"; import { useSharedValue } from "react-native-reanimated"; -import type { ICarouselInstance } from "react-native-reanimated-carousel"; +import type { ICarouselInstance, TCarouselProps } from "react-native-reanimated-carousel"; import Carousel from "react-native-reanimated-carousel"; import { Stack } from "tamagui"; @@ -20,12 +21,10 @@ function Index() { autoPlayInterval: 2000, autoPlayReverse: false, data: defaultDataWith6Colors, - height: 258, loop: true, pagingEnabled: true, snapEnabled: true, vertical: false, - width: window.width, }, }); @@ -37,7 +36,10 @@ function Index() { ref={ref} defaultScrollOffsetValue={scrollOffsetValue} testID={"xxx"} - style={{ width: "100%" }} + style={{ + height: 258, + width: window.width, + }} onScrollStart={() => { console.log("Scroll start"); }} diff --git a/example/app/app/demos/basic-layouts/parallax/demo.tsx b/example/app/app/demos/basic-layouts/parallax/demo.tsx index c443c33d..e7ec5048 100644 --- a/example/app/app/demos/basic-layouts/parallax/demo.tsx +++ b/example/app/app/demos/basic-layouts/parallax/demo.tsx @@ -15,20 +15,21 @@ function Index() { { + progress.value = absoluteProgress; + }} renderItem={renderItem({ rounded: true })} /> diff --git a/example/app/app/demos/basic-layouts/parallax/index.tsx b/example/app/app/demos/basic-layouts/parallax/index.tsx index 596be8cd..c5242116 100644 --- a/example/app/app/demos/basic-layouts/parallax/index.tsx +++ b/example/app/app/demos/basic-layouts/parallax/index.tsx @@ -23,12 +23,10 @@ function Index() { autoPlayInterval: 2000, autoPlayReverse: false, data: defaultDataWith6Colors, - height: 258, loop: true, pagingEnabled: true, snapEnabled: true, vertical: false, - width: PAGE_WIDTH, }, }); @@ -40,13 +38,16 @@ function Index() { {...advancedSettings} style={{ width: PAGE_WIDTH, + height: 258, }} mode="parallax" modeConfig={{ parallaxScrollingScale: 0.9, parallaxScrollingOffset: 50, }} - onProgressChange={progress} + onProgressChange={(offsetProgress, absoluteProgress) => { + progress.value = absoluteProgress; + }} renderItem={renderItem({ rounded: true })} /> diff --git a/example/app/app/demos/basic-layouts/stack/demo.tsx b/example/app/app/demos/basic-layouts/stack/demo.tsx index ddb55587..958aad69 100644 --- a/example/app/app/demos/basic-layouts/stack/demo.tsx +++ b/example/app/app/demos/basic-layouts/stack/demo.tsx @@ -15,16 +15,14 @@ function Index() { ref={ref} autoPlayInterval={2000} data={defaultDataWith6Colors} - height={220} loop={true} pagingEnabled={true} snapEnabled={true} - width={430 * 0.75} style={{ + width: 430 * 0.75, + height: 220, alignItems: "center", justifyContent: "center", - width: "100%", - height: 240, }} mode={"horizontal-stack"} modeConfig={{ diff --git a/example/app/app/demos/basic-layouts/stack/index.tsx b/example/app/app/demos/basic-layouts/stack/index.tsx index 4fee1793..9e6ce85f 100644 --- a/example/app/app/demos/basic-layouts/stack/index.tsx +++ b/example/app/app/demos/basic-layouts/stack/index.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { View } from "react-native"; -import Carousel, { ICarouselInstance } from "react-native-reanimated-carousel"; +import type { StyleProp, ViewStyle } from "react-native"; +import Carousel, { ICarouselInstance, TCarouselProps } from "react-native-reanimated-carousel"; import { CustomSelectActionItem } from "@/components/ActionItems"; import { CarouselAdvancedSettingsPanel } from "@/components/CarouselAdvancedSettingsPanel"; @@ -31,20 +32,39 @@ function Index() { }, }); + const { + width, + height, + style: advancedStyle, + ...restSettings + } = advancedSettings as { + width?: number; + height?: number; + style?: StyleProp; + } & TCarouselProps; + + const baseDimensions = React.useMemo(() => { + if (typeof width === "number" || typeof height === "number") { + return { width, height }; + } + return { width: 280, height: 210 }; + }, [width, height]); + + const mergedStyle = React.useMemo>( + () => + [baseDimensions, { alignItems: "center", justifyContent: "center" }, advancedStyle].filter( + Boolean + ) as StyleProp[], + [baseDimensions, advancedStyle] + ); + return ( = ({ index, animationValue }) => { ); }; function Index() { - const animationStyle: TAnimationStyle = React.useCallback((value: number) => { + const [isAutoPlay, setIsAutoPlay] = React.useState(false); + + const animationStyle: TAnimationStyle = React.useCallback((value: number, index: number) => { "worklet"; - const zIndex = interpolate(value, [-1, 0, 1], [10, 20, 30]); + const zIndex = interpolate( + value, + value > 0 ? [0, 1] : [-1, 0], + value > 0 ? [10, 20] : [-10, 0], + Extrapolation.CLAMP + ); const translateX = interpolate(value, [-2, 0, 1], [-PAGE_WIDTH, 0, PAGE_WIDTH]); return { @@ -66,9 +78,9 @@ function Index() { > { return ; diff --git a/example/app/app/demos/custom-animations/advanced-parallax/index.tsx b/example/app/app/demos/custom-animations/advanced-parallax/index.tsx index d5e88fe8..f2c015a1 100644 --- a/example/app/app/demos/custom-animations/advanced-parallax/index.tsx +++ b/example/app/app/demos/custom-animations/advanced-parallax/index.tsx @@ -1,6 +1,13 @@ import * as React from "react"; import { View } from "react-native"; -import Animated, { interpolate, interpolateColor, useAnimatedStyle } from "react-native-reanimated"; +import Animated, { + Extrapolation, + interpolate, + interpolateColor, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, +} from "react-native-reanimated"; import type { SharedValue } from "react-native-reanimated"; import Carousel, { TAnimationStyle } from "react-native-reanimated-carousel"; @@ -8,6 +15,7 @@ import { SBItem } from "@/components/SBItem"; import SButton from "@/components/SButton"; import { ElementsText, window } from "@/constants/sizes"; import { CaptureWrapper } from "@/store/CaptureProvider"; +import { scheduleOnRN } from "react-native-worklets"; const PAGE_WIDTH = window.width; @@ -15,6 +23,7 @@ interface ItemProps { index: number; animationValue: SharedValue; } + const CustomItem: React.FC = ({ index, animationValue }) => { const maskStyle = useAnimatedStyle(() => { const backgroundColor = interpolateColor( @@ -47,12 +56,19 @@ const CustomItem: React.FC = ({ index, animationValue }) => { ); }; + function Index() { const [isAutoPlay, setIsAutoPlay] = React.useState(false); - const animationStyle: TAnimationStyle = React.useCallback((value: number) => { + + const animationStyle: TAnimationStyle = React.useCallback((value: number, index: number) => { "worklet"; - const zIndex = interpolate(value, [-1, 0, 1], [10, 20, 30]); + const zIndex = interpolate( + value, + value > 0 ? [0, 1] : [-1, 0], + value > 0 ? [10, 20] : [-10, 0], + Extrapolation.CLAMP + ); const translateX = interpolate(value, [-2, 0, 1], [-PAGE_WIDTH, 0, PAGE_WIDTH]); return { @@ -65,10 +81,9 @@ function Index() { { return ; @@ -77,6 +92,7 @@ function Index() { scrollAnimationDuration={1200} /> + { setIsAutoPlay(!isAutoPlay); diff --git a/example/app/app/demos/custom-animations/anim-tab-bar/demo.tsx b/example/app/app/demos/custom-animations/anim-tab-bar/demo.tsx index a8ea864c..92285d4f 100644 --- a/example/app/app/demos/custom-animations/anim-tab-bar/demo.tsx +++ b/example/app/app/demos/custom-animations/anim-tab-bar/demo.tsx @@ -27,13 +27,11 @@ function Index() { return ( - { - return ( - - r.current?.scrollTo({ - count: animationValue.value, - animated: true, - }) - } - /> - ); - }} - autoPlay={AutoPLay.status} - /> + > + { + return ( + + r.current?.scrollTo({ + count: animationValue.value, + animated: true, + }) + } + /> + ); + }} + autoPlay={AutoPLay.status} + /> + ); } diff --git a/example/app/app/demos/custom-animations/anim-tab-bar/index.tsx b/example/app/app/demos/custom-animations/anim-tab-bar/index.tsx index b9083dcc..6a96feaf 100644 --- a/example/app/app/demos/custom-animations/anim-tab-bar/index.tsx +++ b/example/app/app/demos/custom-animations/anim-tab-bar/index.tsx @@ -45,8 +45,6 @@ function Index() { borderBottomWidth: 1, borderBottomColor: "#002a57", }} - width={PAGE_WIDTH} - height={PAGE_HEIGHT} data={DATA} renderItem={({ item, animationValue }) => { return ( diff --git a/example/app/app/demos/custom-animations/blur-parallax/demo.tsx b/example/app/app/demos/custom-animations/blur-parallax/demo.tsx index e3e7a211..0ad70b48 100644 --- a/example/app/app/demos/custom-animations/blur-parallax/demo.tsx +++ b/example/app/app/demos/custom-animations/blur-parallax/demo.tsx @@ -18,20 +18,28 @@ const PAGE_WIDTH = window.width / 2; function Index() { return ( - + { - return ; + contentContainerStyle={{ + width: PAGE_WIDTH, + overflow: "visible", }} + data={[...fruitItems, ...fruitItems]} + renderItem={({ index, animationValue }) => ( + + )} customAnimation={parallaxLayout( { size: PAGE_WIDTH, diff --git a/example/app/app/demos/custom-animations/blur-parallax/index.tsx b/example/app/app/demos/custom-animations/blur-parallax/index.tsx index 17b26323..913e2161 100644 --- a/example/app/app/demos/custom-animations/blur-parallax/index.tsx +++ b/example/app/app/demos/custom-animations/blur-parallax/index.tsx @@ -24,33 +24,47 @@ function Index() { return ( - { - return ; - }} - customAnimation={parallaxLayout( - { - size: PAGE_WIDTH, - vertical: false, - }, - { - parallaxScrollingScale: 1, - parallaxAdjacentItemScale: 0.5, - parallaxScrollingOffset: 40, - } - )} - scrollAnimationDuration={1200} - /> + > + { + return ; + }} + customAnimation={parallaxLayout( + { + size: PAGE_WIDTH, + vertical: false, + }, + { + parallaxScrollingScale: 1, + parallaxAdjacentItemScale: 0.5, + parallaxScrollingOffset: 40, + } + )} + scrollAnimationDuration={1200} + /> + { diff --git a/example/app/app/demos/custom-animations/blur-rotate/demo.tsx b/example/app/app/demos/custom-animations/blur-rotate/demo.tsx index 50b0140a..d34ffe92 100644 --- a/example/app/app/demos/custom-animations/blur-rotate/demo.tsx +++ b/example/app/app/demos/custom-animations/blur-rotate/demo.tsx @@ -10,33 +10,37 @@ import { parallaxLayout } from "@/features/custom-animations/blur-rotate/paralla import { SlideItem } from "@/components/SlideItem"; import { PURPLE_IMAGES } from "@/constants/purple-images"; -import { HEADER_HEIGHT, window } from "@/constants/sizes"; +import { window } from "@/constants/sizes"; const BlurView = Animated.createAnimatedComponent(_BlurView); function Index() { const PAGE_WIDTH = window.width; - const PAGE_HEIGHT = window.height - HEADER_HEIGHT; const ITEM_WIDTH = PAGE_WIDTH * 0.8; return ( - + { - return ; - }} + renderItem={({ index, animationValue }) => ( + + )} customAnimation={parallaxLayout({ size: ITEM_WIDTH, })} diff --git a/example/app/app/demos/custom-animations/blur-rotate/index.tsx b/example/app/app/demos/custom-animations/blur-rotate/index.tsx index 6820d5c7..a3c76e2d 100644 --- a/example/app/app/demos/custom-animations/blur-rotate/index.tsx +++ b/example/app/app/demos/custom-animations/blur-rotate/index.tsx @@ -11,7 +11,7 @@ import { parallaxLayout } from "@/features/custom-animations/blur-rotate/paralla import SButton from "@/components/SButton"; import { SlideItem } from "@/components/SlideItem"; import { PURPLE_IMAGES } from "@/constants/purple-images"; -import { ElementsText, HEADER_HEIGHT, window } from "@/constants/sizes"; +import { ElementsText, window } from "@/constants/sizes"; import { CaptureWrapper } from "@/store/CaptureProvider"; const BlurView = Animated.createAnimatedComponent(_BlurView); @@ -19,7 +19,6 @@ const BlurView = Animated.createAnimatedComponent(_BlurView); function Index() { const [isAutoPlay, setIsAutoPlay] = React.useState(false); const PAGE_WIDTH = window.width; - const PAGE_HEIGHT = window.height - HEADER_HEIGHT; const ITEM_WIDTH = PAGE_WIDTH * 0.8; return ( @@ -31,15 +30,16 @@ function Index() { autoPlay={isAutoPlay} style={{ width: PAGE_WIDTH, - height: PAGE_HEIGHT, - alignItems: "center", + height: ITEM_WIDTH, + }} + contentContainerStyle={{ + width: PAGE_WIDTH, + height: ITEM_WIDTH, }} - width={ITEM_WIDTH} - height={ITEM_WIDTH} pagingEnabled={false} snapEnabled={false} data={PURPLE_IMAGES} - renderItem={({ item, index, animationValue }) => { + renderItem={({ index, animationValue }) => { return ; }} customAnimation={parallaxLayout({ diff --git a/example/app/app/demos/custom-animations/circular/demo.tsx b/example/app/app/demos/custom-animations/circular/demo.tsx index ff075a2b..ebd7dfc6 100644 --- a/example/app/app/demos/custom-animations/circular/demo.tsx +++ b/example/app/app/demos/custom-animations/circular/demo.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { View } from "react-native"; -import { TouchableWithoutFeedback } from "react-native-gesture-handler"; +import { Pressable } from "react-native-gesture-handler"; import { interpolate } from "react-native-reanimated"; import Carousel, { TAnimationStyle } from "react-native-reanimated-carousel"; @@ -43,24 +43,25 @@ function Index() { ); return ( - + ( - { console.log(index); }} - containerStyle={{ flex: 1 }} - style={{ flex: 1 }} + style={{ width: itemSize, height: itemSize }} > - + )} customAnimation={animationStyle} /> diff --git a/example/app/app/demos/custom-animations/circular/index.tsx b/example/app/app/demos/custom-animations/circular/index.tsx index 31784186..bf68de17 100644 --- a/example/app/app/demos/custom-animations/circular/index.tsx +++ b/example/app/app/demos/custom-animations/circular/index.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { Text, View } from "react-native"; -import { TouchableWithoutFeedback } from "react-native-gesture-handler"; +import { Pressable } from "react-native-gesture-handler"; import { interpolate } from "react-native-reanimated"; import Carousel, { TAnimationStyle } from "react-native-reanimated-carousel"; @@ -26,24 +26,13 @@ function Index() { "worklet"; const itemGap = interpolate(value, [-3, -2, -1, 0, 1, 2, 3], [-30, -15, 0, 0, 0, 15, 30]); - const translateX = interpolate(value, [-1, 0, 1], [-itemSize, 0, itemSize]) + centerOffset - itemGap; - const translateY = interpolate(value, [-1, -0.5, 0, 0.5, 1], [60, 45, 40, 45, 60]); - const scale = interpolate(value, [-1, -0.5, 0, 0.5, 1], [0.8, 0.85, 1.1, 0.85, 0.8]); return { - transform: [ - { - translateX, - }, - { - translateY, - }, - { scale }, - ], + transform: [{ translateX }, { translateY }, { scale }], }; }, [centerOffset] @@ -53,8 +42,6 @@ function Index() { ( - { console.log(index); }} - containerStyle={{ flex: 1 }} - style={{ flex: 1 }} + style={{ width: itemSize, height: itemSize }} > - + )} customAnimation={animationStyle} /> diff --git a/example/app/app/demos/custom-animations/cube-3d/demo.tsx b/example/app/app/demos/custom-animations/cube-3d/demo.tsx index 11866d2b..a6646060 100644 --- a/example/app/app/demos/custom-animations/cube-3d/demo.tsx +++ b/example/app/app/demos/custom-animations/cube-3d/demo.tsx @@ -91,8 +91,6 @@ function CubeItem() { }} pagingEnabled={false} snapEnabled={false} - width={PAGE_WIDTH} - height={PAGE_HEIGHT} data={[...new Array(6).keys()]} renderItem={({ index, animationValue }) => { return ; diff --git a/example/app/app/demos/custom-animations/cube-3d/index.tsx b/example/app/app/demos/custom-animations/cube-3d/index.tsx index 1e210cf9..e046b149 100644 --- a/example/app/app/demos/custom-animations/cube-3d/index.tsx +++ b/example/app/app/demos/custom-animations/cube-3d/index.tsx @@ -90,8 +90,6 @@ function CubeItem() { }} pagingEnabled={false} snapEnabled={false} - width={PAGE_WIDTH} - height={PAGE_HEIGHT} data={[...new Array(6).keys()]} renderItem={({ index, animationValue }) => { return ; diff --git a/example/app/app/demos/custom-animations/curve/demo.tsx b/example/app/app/demos/custom-animations/curve/demo.tsx index a81eb50c..611fe566 100644 --- a/example/app/app/demos/custom-animations/curve/demo.tsx +++ b/example/app/app/demos/custom-animations/curve/demo.tsx @@ -11,28 +11,27 @@ const PAGE_WIDTH = window.width / 5; const colors = ["#26292E", "#899F9C", "#B3C680", "#5C6265", "#F5D399", "#F1F1F1"]; function Index() { - const baseOptions = { - vertical: false, - width: PAGE_WIDTH, - height: PAGE_WIDTH * 0.6, - } as const; + const containerHeight = window.width / 2; return ( (0); - const baseOptions = { - vertical: false, - width: PAGE_WIDTH, - height: PAGE_WIDTH * 0.6, - } as const; + const containerHeight = window.width / 2; return ( - + + { + data={[...new Array(10).keys()].map((v) => faker.animal.dog())} + renderItem={({ index, item }) => { return ( - + - {faker.animal.dog()} + {item} { + data={[...new Array(10).keys()].map((v) => faker.animal.dog())} + renderItem={({ index, item }) => { return ( - + - {faker.animal.dog()} + {item} + - + diff --git a/example/app/app/demos/custom-animations/fold/index.tsx b/example/app/app/demos/custom-animations/fold/index.tsx index c89ab213..293c10b6 100644 --- a/example/app/app/demos/custom-animations/fold/index.tsx +++ b/example/app/app/demos/custom-animations/fold/index.tsx @@ -76,13 +76,15 @@ function Index() { - + diff --git a/example/app/app/demos/custom-animations/marquee/index.tsx b/example/app/app/demos/custom-animations/marquee/index.tsx index 7ce1eb0f..7cf53aa0 100644 --- a/example/app/app/demos/custom-animations/marquee/index.tsx +++ b/example/app/app/demos/custom-animations/marquee/index.tsx @@ -62,11 +62,10 @@ function Index() { }} > + } + renderItem={({ index }) => ( + + + + )} /> ); diff --git a/example/app/app/demos/custom-animations/multiple/index.tsx b/example/app/app/demos/custom-animations/multiple/index.tsx index 57b2a762..a1f4a144 100644 --- a/example/app/app/demos/custom-animations/multiple/index.tsx +++ b/example/app/app/demos/custom-animations/multiple/index.tsx @@ -1,55 +1,58 @@ import * as React from "react"; import { View } from "react-native"; -import Carousel, { ICarouselInstance } from "react-native-reanimated-carousel"; +import Carousel from "react-native-reanimated-carousel"; -import { CarouselAdvancedSettingsPanel } from "@/components/CarouselAdvancedSettingsPanel"; -import { defaultDataWith6Colors } from "@/components/CarouselBasicSettingsPanel"; import { SBItem } from "@/components/SBItem"; -import { window } from "@/constants/sizes"; -import { useAdvancedSettings } from "@/hooks/useSettings"; +import SButton from "@/components/SButton"; +import { ElementsText, window } from "@/constants/sizes"; import { CaptureWrapper } from "@/store/CaptureProvider"; const PAGE_WIDTH = window.width; -const COUNT = 4; - function Index() { - const ref = React.useRef(null); - const { advancedSettings, onAdvancedSettingsChange } = useAdvancedSettings({ - // These values will be passed in the Carousel Component as default props - defaultSettings: { - autoPlay: false, - autoPlayInterval: 2000, - autoPlayReverse: false, - data: defaultDataWith6Colors, - height: 258, - loop: true, - pagingEnabled: true, - snapEnabled: true, - vertical: false, - width: PAGE_WIDTH / COUNT, - }, - }); + const [isFast, setIsFast] = React.useState(false); + const [isAutoPlay, setIsAutoPlay] = React.useState(false); return ( } + loop + style={{ + width: PAGE_WIDTH, + height: PAGE_WIDTH / 2, + }} + itemWidth={PAGE_WIDTH / 6} + itemHeight={PAGE_WIDTH / 2} + autoPlay={isAutoPlay} + autoPlayInterval={isFast ? 100 : 2000} + data={[...new Array(12).keys()]} + renderItem={({ index }) => ( + + + + )} /> - - + { + setIsFast(!isFast); + }} + > + {isFast ? "NORMAL" : "FAST"} + + { + setIsAutoPlay(!isAutoPlay); + }} + > + {ElementsText.AUTOPLAY}:{`${isAutoPlay}`} + ); } diff --git a/example/app/app/demos/custom-animations/parallax-layers/index.tsx b/example/app/app/demos/custom-animations/parallax-layers/index.tsx index 2a0a5635..e238a7de 100644 --- a/example/app/app/demos/custom-animations/parallax-layers/index.tsx +++ b/example/app/app/demos/custom-animations/parallax-layers/index.tsx @@ -19,8 +19,6 @@ function Index() { const baseOptions = { vertical: false, - width: PAGE_WIDTH, - height: PAGE_HEIGHT, } as const; return ( @@ -28,6 +26,10 @@ function Index() { { return ; diff --git a/example/app/app/demos/custom-animations/press-swipe/demo.tsx b/example/app/app/demos/custom-animations/press-swipe/demo.tsx index d90a2970..c0fc296d 100644 --- a/example/app/app/demos/custom-animations/press-swipe/demo.tsx +++ b/example/app/app/demos/custom-animations/press-swipe/demo.tsx @@ -34,7 +34,6 @@ function Index() { { pressAnim.value = withTiming(1); diff --git a/example/app/app/demos/custom-animations/press-swipe/index.tsx b/example/app/app/demos/custom-animations/press-swipe/index.tsx index 8730f31b..8b7b700f 100644 --- a/example/app/app/demos/custom-animations/press-swipe/index.tsx +++ b/example/app/app/demos/custom-animations/press-swipe/index.tsx @@ -39,7 +39,6 @@ function Index() { loop={true} autoPlay={isAutoPlay} style={{ width: PAGE_WIDTH, height: 240 }} - width={PAGE_WIDTH} data={PURPLE_IMAGES} onScrollStart={() => { pressAnim.value = withTiming(1); diff --git a/example/app/app/demos/custom-animations/quick-swipe/demo.tsx b/example/app/app/demos/custom-animations/quick-swipe/demo.tsx index 8bd2aa16..4dc90509 100644 --- a/example/app/app/demos/custom-animations/quick-swipe/demo.tsx +++ b/example/app/app/demos/custom-animations/quick-swipe/demo.tsx @@ -29,8 +29,6 @@ function Index() { const baseOptions = { vertical: false, - width: window.width, - height: window.width / 2, } as const; return ( @@ -46,7 +44,8 @@ function Index() { ref={ref} defaultScrollOffsetValue={scrollOffsetValue} testID={"xxx"} - style={{ width: "100%" }} + contentContainerStyle={{ width: "100%" }} + style={{ width: window.width, height: window.width / 2 }} autoPlay={false} autoPlayInterval={1000} data={data} diff --git a/example/app/app/demos/custom-animations/quick-swipe/index.tsx b/example/app/app/demos/custom-animations/quick-swipe/index.tsx index 98fe949a..5bbab2ad 100644 --- a/example/app/app/demos/custom-animations/quick-swipe/index.tsx +++ b/example/app/app/demos/custom-animations/quick-swipe/index.tsx @@ -31,8 +31,6 @@ function Index() { const baseOptions = { vertical: false, - width: window.width, - height: window.width / 2, } as const; return ( @@ -52,7 +50,8 @@ function Index() { ref={ref} defaultScrollOffsetValue={scrollOffsetValue} testID={"xxx"} - style={{ width: "100%" }} + contentContainerStyle={{ width: "100%" }} + style={{ width: window.width, height: window.width / 2 }} autoPlay={false} autoPlayInterval={1000} data={data} diff --git a/example/app/app/demos/custom-animations/rotate-fade-in-out/demo.tsx b/example/app/app/demos/custom-animations/rotate-fade-in-out/demo.tsx index 2c95d522..ea34432d 100644 --- a/example/app/app/demos/custom-animations/rotate-fade-in-out/demo.tsx +++ b/example/app/app/demos/custom-animations/rotate-fade-in-out/demo.tsx @@ -31,6 +31,7 @@ function Index() { { - return ; + return ( + + ); }} autoPlay customAnimation={animationStyle} diff --git a/example/app/app/demos/custom-animations/rotate-fade-in-out/index.tsx b/example/app/app/demos/custom-animations/rotate-fade-in-out/index.tsx index f6830604..4b4110e5 100644 --- a/example/app/app/demos/custom-animations/rotate-fade-in-out/index.tsx +++ b/example/app/app/demos/custom-animations/rotate-fade-in-out/index.tsx @@ -45,11 +45,15 @@ function Index() { justifyContent: "center", alignItems: "center", }} - width={PAGE_WIDTH} - height={PAGE_HEIGHT} data={[...new Array(6).keys()]} renderItem={({ index }) => { - return ; + return ( + + ); }} autoPlay={AutoPLay.status} customAnimation={animationStyle} diff --git a/example/app/app/demos/custom-animations/rotate-in-out/demo.tsx b/example/app/app/demos/custom-animations/rotate-in-out/demo.tsx index 03b8a262..7ffcb961 100644 --- a/example/app/app/demos/custom-animations/rotate-in-out/demo.tsx +++ b/example/app/app/demos/custom-animations/rotate-in-out/demo.tsx @@ -31,7 +31,11 @@ function Index() { }, []); return ( - + } + renderItem={({ index }) => { + return ( + + ); + }} autoPlay={AutoPLay.status} customAnimation={animationStyle} /> diff --git a/example/app/app/demos/custom-animations/rotate-in-out/index.tsx b/example/app/app/demos/custom-animations/rotate-in-out/index.tsx index d06e65eb..b748f097 100644 --- a/example/app/app/demos/custom-animations/rotate-in-out/index.tsx +++ b/example/app/app/demos/custom-animations/rotate-in-out/index.tsx @@ -42,11 +42,15 @@ function Index() { justifyContent: "center", alignItems: "center", }} - width={PAGE_WIDTH} - height={PAGE_HEIGHT} data={[...new Array(6).keys()]} renderItem={({ index }) => { - return ; + return ( + + ); }} autoPlay={AutoPLay.status} customAnimation={animationStyle} diff --git a/example/app/app/demos/custom-animations/scale-fade-in-out/demo.tsx b/example/app/app/demos/custom-animations/scale-fade-in-out/demo.tsx index 484ffc1f..73f6d881 100644 --- a/example/app/app/demos/custom-animations/scale-fade-in-out/demo.tsx +++ b/example/app/app/demos/custom-animations/scale-fade-in-out/demo.tsx @@ -27,6 +27,7 @@ function Index() { { return ; diff --git a/example/app/app/demos/custom-animations/scale-fade-in-out/index.tsx b/example/app/app/demos/custom-animations/scale-fade-in-out/index.tsx index 11c8a2b1..abd0c5aa 100644 --- a/example/app/app/demos/custom-animations/scale-fade-in-out/index.tsx +++ b/example/app/app/demos/custom-animations/scale-fade-in-out/index.tsx @@ -36,8 +36,6 @@ function Index() { alignItems: "center", }} autoPlay - width={PAGE_WIDTH * 0.7} - height={240 * 0.7} data={[...new Array(6).keys()]} renderItem={({ index }) => { return ; diff --git a/example/app/app/demos/custom-animations/stack-cards/index.tsx b/example/app/app/demos/custom-animations/stack-cards/index.tsx index 7993afc6..a6a11f79 100644 --- a/example/app/app/demos/custom-animations/stack-cards/index.tsx +++ b/example/app/app/demos/custom-animations/stack-cards/index.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { View } from "react-native"; import Animated, { + Extrapolation, interpolate, interpolateColor, useAnimatedReaction, @@ -25,20 +26,37 @@ function Index() { const animationStyle: TAnimationStyle = React.useCallback( (value: number) => { "worklet"; - const translateY = interpolate(value, [-1, 0, 1], [-PAGE_HEIGHT, 0, 0]); + const isVertical = directionAnim.value === ArrowDirection.IS_VERTICAL; + const STACK_SPACING = isVertical ? PAGE_HEIGHT * 0.08 : PAGE_WIDTH * 0.08; + const translateY = interpolate( + value, + [-1, 0, 1], + [-PAGE_HEIGHT, 0, STACK_SPACING], + Extrapolation.CLAMP + ); + const translateX = interpolate( + value, + [-1, 0, 1], + [-PAGE_WIDTH, 0, STACK_SPACING], + Extrapolation.CLAMP + ); - const translateX = interpolate(value, [-1, 0, 1], [-PAGE_WIDTH, 0, 0]); + const opacity = value <= 0 ? 1 : interpolate(value, [0, 1], [1, 0], Extrapolation.CLAMP); - const zIndex = interpolate(value, [-1, 0, 1], [300, 0, -300]); + const zIndex = + value <= 0 + ? interpolate(value, [-1, 0], [0, 200], Extrapolation.CLAMP) + : interpolate(value, [0, 1], [100, 0], Extrapolation.CLAMP); - const scale = interpolate(value, [-1, 0, 1], [1, 1, 0.85]); + const scale = interpolate(value, [-1, 0, 1], [1, 1, 0.85], Extrapolation.CLAMP); return { transform: [isVertical ? { translateY } : { translateX }, { scale }], + opacity, zIndex, }; }, - [PAGE_HEIGHT, PAGE_WIDTH, isVertical] + [PAGE_HEIGHT, PAGE_WIDTH, directionAnim] ); useAnimatedReaction( @@ -68,8 +86,6 @@ function Index() { backgroundColor: "black", }} vertical={isVertical} - width={PAGE_WIDTH} - height={PAGE_HEIGHT} data={[...new Array(6).keys()]} renderItem={({ index, animationValue }) => ( { + (value: number, index: number) => { "worklet"; const translateY = interpolate(value, [0, 1], [0, -18]); @@ -32,12 +32,7 @@ function Index() { const rotateZ = interpolate(value, [-1, 0], [15, 0], Extrapolation.CLAMP) * directionAnimVal.value; - const zIndex = interpolate( - value, - [0, 1, 2, 3, 4], - [0, 1, 2, 3, 4].map((v) => (data.length - v) * 10), - Extrapolation.CLAMP - ); + const zIndex = -10 * index; const scale = interpolate(value, [0, 1], [1, 0.95]); @@ -53,19 +48,24 @@ function Index() { ); return ( - + { g.onChange((e) => { diff --git a/example/app/app/demos/custom-animations/tinder/index.tsx b/example/app/app/demos/custom-animations/tinder/index.tsx index 3eda8ea7..e5adfa7c 100644 --- a/example/app/app/demos/custom-animations/tinder/index.tsx +++ b/example/app/app/demos/custom-animations/tinder/index.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Image, ImageSourcePropType, View } from "react-native"; +import { Image, ImageSourcePropType, View, ViewStyle } from "react-native"; import Animated, { Extrapolation, FadeInDown, @@ -22,7 +22,7 @@ function Index() { const directionAnimVal = useSharedValue(0); const animationStyle: TAnimationStyle = React.useCallback( - (value: number) => { + (value: number, index: number) => { "worklet"; const translateY = interpolate(value, [0, 1], [0, -18]); @@ -32,17 +32,16 @@ function Index() { const rotateZ = interpolate(value, [-1, 0], [15, 0], Extrapolation.CLAMP) * directionAnimVal.value; - const zIndex = interpolate( - value, - [0, 1, 2, 3, 4], - [0, 1, 2, 3, 4].map((v) => (data.length - v) * 10), - Extrapolation.CLAMP - ); + const zIndex = -10 * index; const scale = interpolate(value, [0, 1], [1, 0.95]); const opacity = interpolate(value, [-1, -0.8, 0, 1], [0, 0.9, 1, 0.85], Extrapolation.EXTEND); + if (index === 0 || index === 1 || index === 2) { + console.log(`index: ${index}, value: ${value}, zIndex: ${zIndex}`); + } + return { transform: [{ translateY }, { translateX }, { rotateZ: `${rotateZ}deg` }, { scale }], zIndex, @@ -65,8 +64,6 @@ function Index() { }} defaultIndex={0} vertical={false} - width={PAGE_WIDTH} - height={PAGE_HEIGHT} data={data} onConfigurePanGesture={(g) => { g.onChange((e) => { @@ -75,7 +72,9 @@ function Index() { }); }} fixedDirection="negative" - renderItem={({ index, item }) => } + renderItem={({ index, item }) => ( + + )} customAnimation={animationStyle} windowSize={5} /> @@ -84,14 +83,14 @@ function Index() { ); } -const Item: React.FC<{ img: ImageSourcePropType }> = ({ img }) => { +const Item: React.FC<{ img: ImageSourcePropType; style: ViewStyle }> = ({ img, style }) => { const width = window.width * 0.7; const height = window.height * 0.5; return ( (0); const baseOptions = { vertical: false, - width: PAGE_WIDTH, - height: PAGE_WIDTH * 0.6, } as const; const ref = React.useRef(null); @@ -41,8 +39,11 @@ function Index() { ref={ref} {...baseOptions} loop - onProgressChange={progress} - style={{ width: PAGE_WIDTH }} + onProgressChange={(offsetProgress, absoluteProgress) => { + progress.value = absoluteProgress; + }} + contentContainerStyle={{ width: PAGE_WIDTH }} + style={{ width: PAGE_WIDTH, height: PAGE_WIDTH * 0.6 }} data={defaultDataWith6Colors} renderItem={renderItem({ rounded: true })} /> diff --git a/example/app/app/demos/utils/pagination/index.tsx b/example/app/app/demos/utils/pagination/index.tsx index 1075d872..973dfe61 100644 --- a/example/app/app/demos/utils/pagination/index.tsx +++ b/example/app/app/demos/utils/pagination/index.tsx @@ -15,8 +15,6 @@ function Index() { const progress = useSharedValue(0); const baseOptions = { vertical: false, - width: PAGE_WIDTH, - height: PAGE_WIDTH * 0.6, } as const; const ref = React.useRef(null); @@ -39,8 +37,11 @@ function Index() { ref={ref} {...baseOptions} loop - onProgressChange={progress} - style={{ width: PAGE_WIDTH }} + onProgressChange={(offsetProgress, absoluteProgress) => { + progress.value = absoluteProgress; + }} + contentContainerStyle={{ width: PAGE_WIDTH }} + style={{ width: PAGE_WIDTH, height: PAGE_WIDTH * 0.6 }} data={defaultDataWith6Colors} renderItem={renderItem({ rounded: true })} /> diff --git a/example/app/components/CarouselBasicSettingsPanel.tsx b/example/app/components/CarouselBasicSettingsPanel.tsx index 1f89d206..ca6db5f9 100644 --- a/example/app/components/CarouselBasicSettingsPanel.tsx +++ b/example/app/components/CarouselBasicSettingsPanel.tsx @@ -24,17 +24,12 @@ export const getDefaultBasicSettings: () => BasicSettings = () => { autoPlayInterval: 2000, data: defaultDataWith6Colors, }; - const PAGE_WIDTH = window.width; const demission = settings.vertical ? ({ vertical: true, - width: PAGE_WIDTH * 0.86, - height: PAGE_WIDTH * 0.6, } as const) : ({ vertical: false, - width: PAGE_WIDTH, - height: PAGE_WIDTH * 0.6, } as const); return { @@ -54,8 +49,6 @@ export interface BasicSettings | "autoPlay" | "autoPlayInterval" | "autoPlayReverse" - | "width" - | "height" | "enabled" > {} diff --git a/example/website/pages/Examples/basic-layouts/left-align.mdx b/example/website/pages/Examples/basic-layouts/left-align.mdx index 55e5dce2..d71c4f72 100644 --- a/example/website/pages/Examples/basic-layouts/left-align.mdx +++ b/example/website/pages/Examples/basic-layouts/left-align.mdx @@ -67,13 +67,11 @@ function Index() { > console.log("current index:", index)} renderItem={renderItem({ rounded: true, style: { marginRight: 8 } })} /> diff --git a/example/website/pages/Examples/basic-layouts/normal.mdx b/example/website/pages/Examples/basic-layouts/normal.mdx index d4072be1..63a89576 100644 --- a/example/website/pages/Examples/basic-layouts/normal.mdx +++ b/example/website/pages/Examples/basic-layouts/normal.mdx @@ -51,6 +51,8 @@ import { View } from "react-native"; import { useSharedValue } from "react-native-reanimated"; import Carousel from "react-native-reanimated-carousel"; +import { window } from "@/constants/sizes"; + const defaultDataWith6Colors = [ "#B0604D", "#899F9C", @@ -71,14 +73,12 @@ function Index() { { console.log("Scroll start"); }} diff --git a/example/website/pages/Examples/basic-layouts/parallax.mdx b/example/website/pages/Examples/basic-layouts/parallax.mdx index a6a59f66..7ee4e20e 100644 --- a/example/website/pages/Examples/basic-layouts/parallax.mdx +++ b/example/website/pages/Examples/basic-layouts/parallax.mdx @@ -72,20 +72,21 @@ function Index() { { + progress.value = absoluteProgress; + }} renderItem={renderItem({ rounded: true })} /> diff --git a/example/website/pages/Examples/basic-layouts/stack.mdx b/example/website/pages/Examples/basic-layouts/stack.mdx index 0b5f4262..4f9475c1 100644 --- a/example/website/pages/Examples/basic-layouts/stack.mdx +++ b/example/website/pages/Examples/basic-layouts/stack.mdx @@ -72,16 +72,14 @@ function Index() { ref={ref} autoPlayInterval={2000} data={defaultDataWith6Colors} - height={220} loop={true} pagingEnabled={true} snapEnabled={true} - width={430 * 0.75} style={{ + width: 430 * 0.75, + height: 220, alignItems: "center", justifyContent: "center", - width: "100%", - height: 240, }} mode={"horizontal-stack"} modeConfig={{ diff --git a/example/website/pages/Examples/custom-animations/advanced-parallax.mdx b/example/website/pages/Examples/custom-animations/advanced-parallax.mdx index f9b3a558..05f17d14 100644 --- a/example/website/pages/Examples/custom-animations/advanced-parallax.mdx +++ b/example/website/pages/Examples/custom-animations/advanced-parallax.mdx @@ -48,10 +48,12 @@ import Demo from '@/components/Demo' import * as React from "react"; import { View } from "react-native"; import Animated, { + Extrapolation, interpolate, interpolateColor, useAnimatedStyle, } from "react-native-reanimated"; +import type { SharedValue } from "react-native-reanimated"; import Carousel, { TAnimationStyle } from "react-native-reanimated-carousel"; import { SBItem } from "@/components/SBItem"; @@ -62,7 +64,7 @@ const PAGE_WIDTH = window.width; interface ItemProps { index: number; - animationValue: Animated.SharedValue; + animationValue: SharedValue; } const CustomItem: React.FC = ({ index, animationValue }) => { const maskStyle = useAnimatedStyle(() => { @@ -102,21 +104,31 @@ const CustomItem: React.FC = ({ index, animationValue }) => { ); }; function Index() { - const animationStyle: TAnimationStyle = React.useCallback((value: number) => { - "worklet"; - - const zIndex = interpolate(value, [-1, 0, 1], [10, 20, 30]); - const translateX = interpolate( - value, - [-2, 0, 1], - [-PAGE_WIDTH, 0, PAGE_WIDTH], - ); - - return { - transform: [{ translateX }], - zIndex, - }; - }, []); + const [isAutoPlay, setIsAutoPlay] = React.useState(false); + + const animationStyle: TAnimationStyle = React.useCallback( + (value: number, index: number) => { + "worklet"; + + const zIndex = interpolate( + value, + value > 0 ? [0, 1] : [-1, 0], + value > 0 ? [10, 20] : [-10, 0], + Extrapolation.CLAMP, + ); + const translateX = interpolate( + value, + [-2, 0, 1], + [-PAGE_WIDTH, 0, PAGE_WIDTH], + ); + + return { + transform: [{ translateX }], + zIndex, + }; + }, + [], + ); return ( { return ( diff --git a/example/website/pages/Examples/custom-animations/anim-tab-bar.mdx b/example/website/pages/Examples/custom-animations/anim-tab-bar.mdx index c11fd322..57ef7ef1 100644 --- a/example/website/pages/Examples/custom-animations/anim-tab-bar.mdx +++ b/example/website/pages/Examples/custom-animations/anim-tab-bar.mdx @@ -57,6 +57,7 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; +import type { SharedValue } from "react-native-reanimated"; import type { ICarouselInstance } from "react-native-reanimated-carousel"; import Carousel from "react-native-reanimated-carousel"; @@ -73,13 +74,11 @@ function Index() { return ( - { - return ( - - r.current?.scrollTo({ - count: animationValue.value, - animated: true, - }) - } - /> - ); - }} - autoPlay={AutoPLay.status} - /> + > + { + return ( + + r.current?.scrollTo({ + count: animationValue.value, + animated: true, + }) + } + /> + ); + }} + autoPlay={AutoPLay.status} + /> + ); } @@ -114,7 +120,7 @@ function Index() { export default Index; interface Props { - animationValue: Animated.SharedValue; + animationValue: SharedValue; label: string; onPress?: () => void; } diff --git a/example/website/pages/Examples/custom-animations/blur-parallax.mdx b/example/website/pages/Examples/custom-animations/blur-parallax.mdx index 0d4c4c88..62e9d808 100644 --- a/example/website/pages/Examples/custom-animations/blur-parallax.mdx +++ b/example/website/pages/Examples/custom-animations/blur-parallax.mdx @@ -46,22 +46,20 @@ import Demo from '@/components/Demo' ```tsx copy import * as React from "react"; -import type { ImageSourcePropType } from "react-native"; -import { Image, StyleSheet, View } from "react-native"; +import { StyleSheet, View } from "react-native"; import Animated, { interpolate, useAnimatedStyle, } from "react-native-reanimated"; +import type { SharedValue } from "react-native-reanimated"; import Carousel from "react-native-reanimated-carousel"; import { BlurView as _BlurView } from "expo-blur"; -import { parallaxLayout } from "./parallax"; +import { parallaxLayout } from "@/features/custom-animations/blur-parallax/parallax"; -import SButton from "@/components/SButton"; import { SlideItem } from "@/components/SlideItem"; -import { ElementsText, window } from "@/constants/sizes"; -import { CaptureWrapper } from "@/store/CaptureProvider"; +import { window } from "@/constants/sizes"; import { fruitItems } from "@/utils/items"; const BlurView = Animated.createAnimatedComponent(_BlurView); @@ -73,26 +71,30 @@ function Index() { { - return ( - - ); + contentContainerStyle={{ + width: PAGE_WIDTH, + overflow: "visible", }} + data={[...fruitItems, ...fruitItems]} + renderItem={({ index, animationValue }) => ( + + )} customAnimation={parallaxLayout( { size: PAGE_WIDTH, @@ -112,7 +114,7 @@ function Index() { interface ItemProps { index: number; - animationValue: Animated.SharedValue; + animationValue: SharedValue; } const CustomItem: React.FC = ({ index, animationValue }) => { const maskStyle = useAnimatedStyle(() => { diff --git a/example/website/pages/Examples/custom-animations/blur-rotate.mdx b/example/website/pages/Examples/custom-animations/blur-rotate.mdx index f66e5534..9026a22f 100644 --- a/example/website/pages/Examples/custom-animations/blur-rotate.mdx +++ b/example/website/pages/Examples/custom-animations/blur-rotate.mdx @@ -46,60 +46,55 @@ import Demo from '@/components/Demo' ```tsx copy import * as React from "react"; -import type { ImageSourcePropType } from "react-native"; -import { Image, StyleSheet, View } from "react-native"; +import { StyleSheet, View } from "react-native"; import Animated, { interpolate, useAnimatedStyle, } from "react-native-reanimated"; +import type { SharedValue } from "react-native-reanimated"; import Carousel from "react-native-reanimated-carousel"; import { BlurView as _BlurView } from "expo-blur"; -import { parallaxLayout } from "./parallax"; +import { parallaxLayout } from "@/features/custom-animations/blur-rotate/parallax"; -import { SBItem } from "@/components/SBItem"; -import SButton from "@/components/SButton"; import { SlideItem } from "@/components/SlideItem"; import { PURPLE_IMAGES } from "@/constants/purple-images"; -import { ElementsText, HEADER_HEIGHT, window } from "@/constants/sizes"; -import { CaptureWrapper } from "@/store/CaptureProvider"; -import { fruitItems } from "@/utils/items"; +import { window } from "@/constants/sizes"; const BlurView = Animated.createAnimatedComponent(_BlurView); function Index() { const PAGE_WIDTH = window.width; - const PAGE_HEIGHT = window.height - HEADER_HEIGHT; const ITEM_WIDTH = PAGE_WIDTH * 0.8; return ( { - return ( - - ); - }} + renderItem={({ index, animationValue }) => ( + + )} customAnimation={parallaxLayout({ size: ITEM_WIDTH, })} @@ -111,7 +106,7 @@ function Index() { interface ItemProps { index: number; - animationValue: Animated.SharedValue; + animationValue: SharedValue; } const CustomItem: React.FC = ({ index, animationValue }) => { const maskStyle = useAnimatedStyle(() => { diff --git a/example/website/pages/Examples/custom-animations/circular.mdx b/example/website/pages/Examples/custom-animations/circular.mdx index 7deda2d0..9bf5caf6 100644 --- a/example/website/pages/Examples/custom-animations/circular.mdx +++ b/example/website/pages/Examples/custom-animations/circular.mdx @@ -47,7 +47,7 @@ import Demo from '@/components/Demo' ```tsx copy import * as React from "react"; import { View } from "react-native"; -import { TouchableWithoutFeedback } from "react-native-gesture-handler"; +import { Pressable } from "react-native-gesture-handler"; import { interpolate } from "react-native-reanimated"; import Carousel, { TAnimationStyle } from "react-native-reanimated-carousel"; @@ -107,24 +107,22 @@ function Index() { ( - { console.log(index); }} - containerStyle={{ flex: 1 }} - style={{ flex: 1 }} + style={{ width: itemSize, height: itemSize }} > - + )} customAnimation={animationStyle} /> diff --git a/example/website/pages/Examples/custom-animations/cube-3d.mdx b/example/website/pages/Examples/custom-animations/cube-3d.mdx index e74f52a3..81319bcf 100644 --- a/example/website/pages/Examples/custom-animations/cube-3d.mdx +++ b/example/website/pages/Examples/custom-animations/cube-3d.mdx @@ -46,13 +46,14 @@ import Demo from '@/components/Demo' ```tsx copy import * as React from "react"; -import { View, ViewStyle } from "react-native"; +import { View } from "react-native"; import Animated, { Extrapolation, interpolate, interpolateColor, useAnimatedStyle, } from "react-native-reanimated"; +import type { SharedValue } from "react-native-reanimated"; import Carousel, { TAnimationStyle } from "react-native-reanimated-carousel"; import { SBItem } from "@/components/SBItem"; @@ -142,8 +143,6 @@ function CubeItem() { }} pagingEnabled={false} snapEnabled={false} - width={PAGE_WIDTH} - height={PAGE_HEIGHT} data={[...new Array(6).keys()]} renderItem={({ index, animationValue }) => { return ( @@ -164,7 +163,7 @@ function CubeItem() { interface ItemProps { index: number; - animationValue: Animated.SharedValue; + animationValue: SharedValue; } const CustomItem: React.FC = ({ index, animationValue }) => { const maskStyle = useAnimatedStyle(() => { diff --git a/example/website/pages/Examples/custom-animations/curve.mdx b/example/website/pages/Examples/custom-animations/curve.mdx index 5b431c70..48c35781 100644 --- a/example/website/pages/Examples/custom-animations/curve.mdx +++ b/example/website/pages/Examples/custom-animations/curve.mdx @@ -65,28 +65,31 @@ const colors = [ ]; function Index() { - const baseOptions = { - vertical: false, - width: PAGE_WIDTH, - height: PAGE_WIDTH * 0.6, - } as const; + const containerHeight = window.width / 2; return ( { + data={[...new Array(10).keys()].map((v) => faker.animal.dog())} + renderItem={({ index, item }) => { return ( - + - {faker.animal.dog()} + {item} ; + animationValue: SharedValue; }> = ({ index }) => { return ( - + diff --git a/example/website/pages/Examples/custom-animations/multiple.mdx b/example/website/pages/Examples/custom-animations/multiple.mdx index 14f77bf1..b9538b8d 100644 --- a/example/website/pages/Examples/custom-animations/multiple.mdx +++ b/example/website/pages/Examples/custom-animations/multiple.mdx @@ -62,18 +62,21 @@ function Index() { } + renderItem={({ index }) => ( + + + + )} /> ); diff --git a/example/website/pages/Examples/custom-animations/press-swipe.mdx b/example/website/pages/Examples/custom-animations/press-swipe.mdx index 37c413d0..759cab08 100644 --- a/example/website/pages/Examples/custom-animations/press-swipe.mdx +++ b/example/website/pages/Examples/custom-animations/press-swipe.mdx @@ -53,6 +53,7 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; +import type { SharedValue } from "react-native-reanimated"; import Carousel, { TAnimationStyle } from "react-native-reanimated-carousel"; import { SlideItem } from "@/components/SlideItem"; @@ -87,7 +88,6 @@ function Index() { { pressAnim.value = withTiming(1); @@ -106,7 +106,7 @@ function Index() { } interface ItemProps { - pressAnim: Animated.SharedValue; + pressAnim: SharedValue; index: number; } diff --git a/example/website/pages/Examples/custom-animations/quick-swipe.mdx b/example/website/pages/Examples/custom-animations/quick-swipe.mdx index 076ff213..cc45ac4e 100644 --- a/example/website/pages/Examples/custom-animations/quick-swipe.mdx +++ b/example/website/pages/Examples/custom-animations/quick-swipe.mdx @@ -52,6 +52,7 @@ import Carousel from "react-native-reanimated-carousel"; import { SBItem } from "@/components/SBItem"; import { IS_WEB } from "@/constants/platform"; import { window } from "@/constants/sizes"; +import { getImages } from "@/features/custom-animations/quick-swipe/images"; import * as Haptics from "expo-haptics"; import { Image, ImageSourcePropType, View, ViewStyle } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; @@ -64,8 +65,8 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; +import type { SharedValue } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; -import { getImages } from "./images"; const data = getImages().slice(0, 68); @@ -75,8 +76,6 @@ function Index() { const baseOptions = { vertical: false, - width: window.width, - height: window.width / 2, } as const; return ( @@ -92,7 +91,8 @@ function Index() { ref={ref} defaultScrollOffsetValue={scrollOffsetValue} testID={"xxx"} - style={{ width: "100%" }} + contentContainerStyle={{ width: "100%" }} + style={{ width: window.width, height: window.width / 2 }} autoPlay={false} autoPlayInterval={1000} data={data} @@ -229,8 +229,8 @@ const ThumbnailPaginationItem: React.FC<{ source: ImageSourcePropType; containerWidth: number; totalItems: number; - activeIndex: Animated.SharedValue; - swipeProgress: Animated.SharedValue; + activeIndex: SharedValue; + swipeProgress: SharedValue; activeWidth: number; totalWidth: number; inactiveWidth: number; diff --git a/example/website/pages/Examples/custom-animations/rotate-fade-in-out.mdx b/example/website/pages/Examples/custom-animations/rotate-fade-in-out.mdx index e94aed0b..f2f47b52 100644 --- a/example/website/pages/Examples/custom-animations/rotate-fade-in-out.mdx +++ b/example/website/pages/Examples/custom-animations/rotate-fade-in-out.mdx @@ -82,6 +82,12 @@ function Index() { { - return ; + return ( + + ); }} autoPlay customAnimation={animationStyle} diff --git a/example/website/pages/Examples/custom-animations/rotate-in-out.mdx b/example/website/pages/Examples/custom-animations/rotate-in-out.mdx index abf4c8f0..1f59bb3e 100644 --- a/example/website/pages/Examples/custom-animations/rotate-in-out.mdx +++ b/example/website/pages/Examples/custom-animations/rotate-in-out.mdx @@ -85,6 +85,12 @@ function Index() { } + renderItem={({ index }) => { + return ( + + ); + }} autoPlay={AutoPLay.status} customAnimation={animationStyle} /> diff --git a/example/website/pages/Examples/custom-animations/scale-fade-in-out.mdx b/example/website/pages/Examples/custom-animations/scale-fade-in-out.mdx index c7fcb3ca..64608458 100644 --- a/example/website/pages/Examples/custom-animations/scale-fade-in-out.mdx +++ b/example/website/pages/Examples/custom-animations/scale-fade-in-out.mdx @@ -74,6 +74,12 @@ function Index() { { return ; diff --git a/example/website/pages/Examples/custom-animations/tinder.mdx b/example/website/pages/Examples/custom-animations/tinder.mdx index d8f1035c..02999932 100644 --- a/example/website/pages/Examples/custom-animations/tinder.mdx +++ b/example/website/pages/Examples/custom-animations/tinder.mdx @@ -69,7 +69,7 @@ function Index() { const directionAnimVal = useSharedValue(0); const animationStyle: TAnimationStyle = React.useCallback( - (value: number) => { + (value: number, index: number) => { "worklet"; const translateY = interpolate(value, [0, 1], [0, -18]); @@ -81,12 +81,7 @@ function Index() { interpolate(value, [-1, 0], [15, 0], Extrapolation.CLAMP) * directionAnimVal.value; - const zIndex = interpolate( - value, - [0, 1, 2, 3, 4], - [0, 1, 2, 3, 4].map((v) => (data.length - v) * 10), - Extrapolation.CLAMP, - ); + const zIndex = -10 * index; const scale = interpolate(value, [0, 1], [1, 0.95]); @@ -115,19 +110,21 @@ function Index() { { g.onChange((e) => { diff --git a/example/website/pages/Examples/utils/pagination.mdx b/example/website/pages/Examples/utils/pagination.mdx index 971769eb..061e8726 100644 --- a/example/website/pages/Examples/utils/pagination.mdx +++ b/example/website/pages/Examples/utils/pagination.mdx @@ -74,8 +74,6 @@ function Index() { const progress = useSharedValue(0); const baseOptions = { vertical: false, - width: PAGE_WIDTH, - height: PAGE_WIDTH * 0.6, } as const; const ref = React.useRef(null); @@ -102,8 +100,11 @@ function Index() { ref={ref} {...baseOptions} loop - onProgressChange={progress} - style={{ width: PAGE_WIDTH }} + onProgressChange={(offsetProgress, absoluteProgress) => { + progress.value = absoluteProgress; + }} + contentContainerStyle={{ width: PAGE_WIDTH }} + style={{ width: PAGE_WIDTH, height: PAGE_WIDTH * 0.6 }} data={defaultDataWith6Colors} renderItem={renderItem({ rounded: true })} /> diff --git a/example/website/pages/custom-animations.mdx b/example/website/pages/custom-animations.mdx index c2a437c5..5f78e8da 100644 --- a/example/website/pages/custom-animations.mdx +++ b/example/website/pages/custom-animations.mdx @@ -73,7 +73,6 @@ const animationStyle: TAnimationStyle = React.useCallback((value: number) => { { @@ -146,13 +145,11 @@ const animationStyle: TAnimationStyle = React.useCallback((value: number) => { { diff --git a/example/website/pages/migration-v4.mdx b/example/website/pages/migration-v4.mdx index b5e1b347..a35922f2 100644 --- a/example/website/pages/migration-v4.mdx +++ b/example/website/pages/migration-v4.mdx @@ -118,13 +118,13 @@ All gesture callbacks now run as worklets by default: ## New Features -### 1. Container Styling +### 1. Content Container Styling -A dedicated `containerStyle` prop has been added for styling the carousel container: +Use the `contentContainerStyle` prop to style the scrollable content area: ```tsx console.log('scroll started')} onConfigurePanGesture={(gesture) => { @@ -305,8 +304,8 @@ Here's a complete example showing the migration of a typical carousel implementa customAnimation={(value, index) => ({ opacity: value })} - containerStyle={{ + contentContainerStyle={{ borderRadius: 8 }} /> -``` \ No newline at end of file +``` diff --git a/example/website/pages/migration-v5.mdx b/example/website/pages/migration-v5.mdx index 0949b73a..3e9fa421 100644 --- a/example/website/pages/migration-v5.mdx +++ b/example/website/pages/migration-v5.mdx @@ -79,10 +79,9 @@ The most requested feature! Carousel now automatically measures container dimens // Carousel automatically measures container /> -// ✅ v5.x - Still works (explicit sizing) +// ✅ v5.x - Still works (explicit sizing via style prop) diff --git a/example/website/pages/props.mdx b/example/website/pages/props.mdx index 4697a83a..842e41ad 100644 --- a/example/website/pages/props.mdx +++ b/example/website/pages/props.mdx @@ -73,6 +73,8 @@ Layout items vertically instead of horizontally ### `width` +> @deprecated Use `style` prop instead. e.g. `style={{ width: 300 }}` + Specified carousel item width | type | default | required | @@ -81,6 +83,8 @@ Specified carousel item width ### `height` +> @deprecated Use `style` prop instead. e.g. `style={{ height: 200 }}` + Specified carousel item height | type | default | required | @@ -105,7 +109,31 @@ Different modes correspond to different configurations. For details, see below\[ ### `style` -Carousel container style +Style of the carousel container. This is the only place to define the component's size (`width`, `height`). + +| type | default | required | +| ------ | ------- | -------- | +| ViewStyle | \{\} | ❌ | + +### `itemWidth` + +Horizontal page size used for snapping and animations. Set this when you want multiple items visible within a single viewport. + +| type | default | required | +| ------ | ------- | -------- | +| number \| undefined | container width | ❌ | + +### `itemHeight` + +Vertical page size used for snapping and animations. Set this when you want multiple rows visible at once in vertical mode. + +| type | default | required | +| ------ | ------- | -------- | +| number \| undefined | container height | ❌ | + +### `contentContainerStyle` + +Style of the carousel content container. | type | default | required | | ------ | ------- | -------- | @@ -470,7 +498,7 @@ const Example = () => { ( {item.title} diff --git a/example/website/pages/usage.mdx b/example/website/pages/usage.mdx index 2c3067d3..060ec382 100644 --- a/example/website/pages/usage.mdx +++ b/example/website/pages/usage.mdx @@ -60,8 +60,7 @@ function App() { ( diff --git a/src/components/Carousel.test.tsx b/src/components/Carousel.test.tsx index ddac2666..19089174 100644 --- a/src/components/Carousel.test.tsx +++ b/src/components/Carousel.test.tsx @@ -52,12 +52,11 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp customProps: Partial> = {} ) => { const baseProps: Partial> = { - width: slideWidth, - height: slideHeight, data: createMockData(), defaultIndex: 0, - testID: "carousel-swipe-container", - onProgressChange: progressAnimVal, + onProgressChange: (offsetProgress, absoluteProgress) => { + progressAnimVal.value = absoluteProgress; + }, }; return { @@ -129,10 +128,168 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp ); }; + describe("TDD: Test upcoming refactoring for style props", () => { + beforeEach(() => { + jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + (console.warn as jest.Mock).mockRestore(); + }); + + it("should show a deprecation warning when using the `width` prop", () => { + const progress = { current: 0 }; + const Wrapper = createCarousel(progress); + render(); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("is deprecated")); + }); + + it("should take width from the new `style` prop", async () => { + const progress = { current: 0 }; + const Wrapper = createCarousel(progress); + const { getByTestId } = render( + + ); + await verifyInitialRender(getByTestId); + + const outerContainer = getByTestId("carousel-container"); + expect(outerContainer.props.style).toContainEqual({ width: 450, height: 200 }); + }); + + it("should apply styles from the new `contentContainerStyle` prop", async () => { + const progress = { current: 0 }; + const Wrapper = createCarousel(progress); + const { getByTestId } = render( + + ); + await verifyInitialRender(getByTestId); + + const contentContainer = getByTestId("carousel-content-container"); + expect(contentContainer.props.style).toContainEqual({ padding: 20 }); + }); + + it("should warn when `contentContainerStyle` contains conflicting props", async () => { + const progress = { current: 0 }; + const Wrapper = createCarousel(progress); + const { getByTestId } = render( + + ); + await verifyInitialRender(getByTestId); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("conflict with animations") + ); + }); + + it("should auto-size when no width is provided in `style`", async () => { + const progress = { current: 0 }; + const Wrapper = createCarousel(progress); + const { getByTestId } = render(); + + const contentContainer = getByTestId("carousel-content-container"); + + // Initially, width should be '100%' + expect(contentContainer.props.style[1].width).toBe("100%"); + expect(typeof contentContainer.props.onLayout).toBe("function"); + + // Simulate onLayout event + act(() => { + contentContainer.props.onLayout?.({ + nativeEvent: { layout: { width: 350, height: 200 } }, + } as any); + }); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + // No assertions on rendered items because reanimated mock does not process animated updates. + // Ensure invoking layout measurement does not throw and that the carousel exposes the expected + // measurement callback for auto-sizing scenarios. + }); + + it("should use itemWidth for snapping size when provided", async () => { + const progress = { current: 0 }; + const Wrapper = createCarousel(progress); + const { getByTestId } = render( + + ); + await verifyInitialRender(getByTestId); + + // The carousel should use itemWidth (350) for snapping instead of container width (700) + // This allows showing multiple items (2 items in this case: 700 / 350) + const contentContainer = getByTestId("carousel-content-container"); + expect(contentContainer).toBeTruthy(); + }); + + it("should use itemHeight for snapping size in vertical mode when provided", async () => { + const progress = { current: 0 }; + const Wrapper = createCarousel(progress); + const { getByTestId } = render( + + ); + await verifyInitialRender(getByTestId); + + // The carousel should use itemHeight (350) for snapping instead of container height (700) + const contentContainer = getByTestId("carousel-content-container"); + expect(contentContainer).toBeTruthy(); + }); + + it("should prioritize itemWidth over width prop", async () => { + const progress = { current: 0 }; + const Wrapper = createCarousel(progress); + const { getByTestId } = render( + + ); + await verifyInitialRender(getByTestId); + + // itemWidth (350) should take precedence + const contentContainer = getByTestId("carousel-content-container"); + expect(contentContainer).toBeTruthy(); + }); + + it("should support itemWidth for multiple visible items scenario", async () => { + const progress = { current: 0 }; + const Wrapper = createCarousel(progress); + const { getByTestId } = render( + + ); + await verifyInitialRender(getByTestId); + + // Container is 900px, itemWidth is 300px, so 3 items should be visible + // Verify items are rendered + expect(getByTestId("carousel-item-0")).toBeTruthy(); + expect(getByTestId("carousel-item-1")).toBeTruthy(); + expect(getByTestId("carousel-item-2")).toBeTruthy(); + }); + + it("should accept onLayout callback prop", async () => { + const progress = { current: 0 }; + const onLayout = jest.fn(); + const Wrapper = createCarousel(progress); + const { getByTestId } = render( + + ); + + const contentContainer = getByTestId("carousel-content-container"); + + // Verify that onLayout handler is attached to the content container + expect(typeof contentContainer.props.onLayout).toBe("function"); + }); + }); + it("`data` prop: should render correctly", async () => { const progress = { current: 0 }; const Wrapper = createCarousel(progress); - const { getByTestId } = render(); + const { getByTestId } = render( + + ); await verifyInitialRender(getByTestId); @@ -149,6 +306,7 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp const Wrapper = createCarousel(progress); const { getByTestId } = render( ( {item} )} @@ -161,7 +319,7 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp it("should swipe to the left", async () => { const progress = { current: 0 }; const Wrapper = createCarousel(progress); - const { getByTestId } = render(); + const { getByTestId } = render(); await verifyInitialRender(getByTestId); // Test swipe sequence @@ -175,7 +333,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp const progress = { current: 0 }; const Wrapper = createCarousel(progress); { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); await verifyInitialRender(getByTestId); // Test swipe sequence @@ -186,7 +346,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp } { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); await verifyInitialRender(getByTestId); fireGestureHandler(getByGestureTestId(gestureTestId), [ @@ -204,7 +366,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp const progress = { current: 0 }; const onSnapToItem = jest.fn(); const Wrapper = createCarousel(progress); - const { getByTestId } = render(); + const { getByTestId } = render( + + ); await verifyInitialRender(getByTestId); expect(onSnapToItem).not.toHaveBeenCalled(); @@ -221,7 +385,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp it("`autoPlay` prop: should swipe automatically when autoPlay is true", async () => { const progress = { current: 0 }; const Wrapper = createCarousel(progress); - const { getByTestId } = render(); + const { getByTestId } = render( + + ); await verifyInitialRender(getByTestId); await waitFor(() => expect(progress.current).toBe(1)); @@ -235,7 +401,13 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp const Wrapper = createCarousel(progress); render( - + ); const step = (expectedIndex: number) => { @@ -254,7 +426,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp it("`defaultIndex` prop: should render the correct item with the defaultIndex props", async () => { const progress = { current: 0 }; const Wrapper = createCarousel(progress); - const { getByTestId } = render(); + const { getByTestId } = render( + + ); await verifyInitialRender(getByTestId); await waitFor(() => expect(progress.current).toBe(2)); @@ -266,7 +440,12 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp const WrapperWithCustomProps = () => { const defaultScrollOffsetValue = useSharedValue(-slideWidth); - return ; + return ( + + ); }; render(); @@ -282,6 +461,7 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp }> = ({ refSetupCallback }) => { return ( { refSetupCallback(!!ref); }} @@ -298,14 +478,26 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp const progress = { current: 0 }; const Wrapper = createCarousel(progress); { - const { getAllByTestId } = render(); + const { getAllByTestId } = render( + + ); await waitFor(() => { expect(getAllByTestId("carousel-item-0").length).toBe(3); }); } { - const { getAllByTestId } = render(); + const { getAllByTestId } = render( + + ); await waitFor(() => { expect(getAllByTestId("carousel-item-0").length).toBe(1); }); @@ -316,7 +508,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp const progress = { current: 0 }; const Wrapper = createCarousel(progress); { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); await verifyInitialRender(getByTestId); fireGestureHandler(getByGestureTestId(gestureTestId), [ @@ -333,7 +527,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp } { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); await verifyInitialRender(getByTestId); fireGestureHandler(getByGestureTestId(gestureTestId), [ @@ -360,6 +556,7 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp let _pan: PanGesture | null = null; render( { _pan = pan; return pan; @@ -367,7 +564,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp /> ); - const { getByTestId } = render(); + const { getByTestId } = render( + + ); await verifyInitialRender(getByTestId); expect(_pan).not.toBeNull(); }); @@ -381,7 +580,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp startedProgress = progress.current; }; const Wrapper = createCarousel(progress); - const { getByTestId } = render(); + const { getByTestId } = render( + + ); await verifyInitialRender(getByTestId); fireGestureHandler(getByGestureTestId(gestureTestId), [ @@ -404,7 +605,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp endedProgress = progress.current; }); const Wrapper = createCarousel(progress); - const { getByTestId } = render(); + const { getByTestId } = render( + + ); await verifyInitialRender(getByTestId); fireGestureHandler(getByGestureTestId(gestureTestId), [ @@ -428,7 +631,11 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp }); const Wrapper = createCarousel(offsetProgressVal); const { getByTestId } = render( - + ); await verifyInitialRender(getByTestId); @@ -453,7 +660,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp { const progress = { current: 0 }; const Wrapper = createCarousel(progress); - const { getByTestId } = render(); + const { getByTestId } = render( + + ); await verifyInitialRender(getByTestId); swipeToLeftOnce({ velocityX: slideWidth }); @@ -465,7 +674,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp { const progress = { current: 0 }; const Wrapper = createCarousel(progress); - const { getByTestId } = render(); + const { getByTestId } = render( + + ); await verifyInitialRender(getByTestId); swipeToLeftOnce({ velocityX: -slideWidth }); @@ -479,6 +690,7 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp const Wrapper = createCarousel(progress); const { getByTestId } = render( { "worklet"; @@ -515,7 +727,6 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp it("`overscrollEnabled` prop: should respect overscrollEnabled=false and prevent scrolling beyond bounds", async () => { const containerWidth = slideWidth; const containerHeight = containerWidth / 2; - const itemWidth = containerWidth / 4; let nextSlide: (() => void) | undefined; const testId = "CarouselAnimatedView"; @@ -534,9 +745,7 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp if (ref) nextSlide = ref.next; }} vertical={false} - width={itemWidth} - height={containerHeight} - style={{ width: containerWidth }} + style={{ width: containerWidth, height: containerHeight }} testID={testId} loop={false} overscrollEnabled={false} @@ -548,7 +757,7 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp // Simulate layout act(() => { - getByTestId(testId).props.onLayout({ + getByTestId("carousel-content-container").props.onLayout({ nativeEvent: { layout: { width: containerWidth, height: containerHeight } }, }); }); @@ -556,22 +765,45 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp // Let the internal async initialization run TICK(1); + const getProgress = () => + Math.round(((progress.current % slideCount) + slideCount) % slideCount); + const captured: number[] = []; + const pushExpect = (expected: number) => { + captured.push(getProgress()); + expect(captured[captured.length - 1]).toBe(expected); + }; + // Initial: At the 0th page - expect(Math.round(((progress.current % slideCount) + slideCount) % slideCount)).toBe(0); + pushExpect(0); // next -> 1st page nextSlide?.(); TICK(); // Wait for the animation to end - expect(Math.round(((progress.current % slideCount) + slideCount) % slideCount)).toBe(1); + pushExpect(1); // next -> 2nd page nextSlide?.(); TICK(); - expect(Math.round(((progress.current % slideCount) + slideCount) % slideCount)).toBe(2); + pushExpect(2); + + // next -> 3rd page (still allowed; still enough content) + nextSlide?.(); + TICK(); + pushExpect(3); + + // next -> 4th page + nextSlide?.(); + TICK(); + pushExpect(0); + + // next -> 5th page (last item) + nextSlide?.(); + TICK(); + pushExpect(1); - // continue next(Already at the last visible page, and overscroll=false, should not move) + // continue next(Already at the last page, and overscroll=false, should not move) nextSlide?.(); TICK(); - expect(Math.round(((progress.current % slideCount) + slideCount) % slideCount)).toBe(2); + pushExpect(1); }); }); diff --git a/src/components/CarouselLayout.tsx b/src/components/CarouselLayout.tsx index b1d6e88b..8a36d6e6 100644 --- a/src/components/CarouselLayout.tsx +++ b/src/components/CarouselLayout.tsx @@ -16,8 +16,7 @@ import { ScrollViewGesture } from "./ScrollViewGesture"; export type TAnimationStyle = (value: number) => ViewStyle; export const CarouselLayout = React.forwardRef((_props, ref) => { - const { props, layout, common } = useGlobalState(); - const { itemDimensions } = layout; + const { props, common } = useGlobalState(); const { testID, @@ -31,9 +30,7 @@ export const CarouselLayout = React.forwardRef((_props, ref) rawDataLength, mode, style, - containerStyle, - width, - height, + contentContainerStyle, vertical, autoPlay, windowSize, @@ -155,27 +152,38 @@ export const CarouselLayout = React.forwardRef((_props, ref) const scrollViewGestureOnTouchEnd = React.useCallback(startAutoPlay, [startAutoPlay]); + const { opacity, transform, ...restContentContainerStyle } = + StyleSheet.flatten(contentContainerStyle) || {}; + const flattenedStyle = StyleSheet.flatten(style) || {}; + const layoutStyle = useAnimatedStyle(() => { + const { width, height } = flattenedStyle; + const measuredSize = resolvedSize.value ?? 0; + + const computedWidth = width ?? (vertical ? "100%" : measuredSize || "100%"); + const computedHeight = height ?? (vertical ? measuredSize || "100%" : "100%"); + return { - width: width || "100%", // [width is deprecated] - height: height || "100%", // [height is deprecated] + width: computedWidth, + height: computedHeight, opacity: isSizeReady.value ? 1 : 0, }; - }, [width, height, itemDimensions, isSizeReady]); + }, [flattenedStyle, isSizeReady, vertical, resolvedSize]); return ( - + ((_props, ref) const styles = StyleSheet.create({ layoutContainer: { display: "flex", + overflow: "hidden", }, contentContainer: { overflow: "hidden", diff --git a/src/components/ItemLayout.tsx b/src/components/ItemLayout.tsx index 7cbf1c60..ae7dc53e 100644 --- a/src/components/ItemLayout.tsx +++ b/src/components/ItemLayout.tsx @@ -1,7 +1,12 @@ import React from "react"; -import type { ViewStyle } from "react-native"; +import type { LayoutChangeEvent, ViewStyle } from "react-native"; import type { SharedValue } from "react-native-reanimated"; -import Animated, { useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; +import Animated, { + useAnimatedStyle, + useDerivedValue, + useSharedValue, +} from "react-native-reanimated"; +import { scheduleOnUI } from "react-native-worklets"; import { TCarouselProps } from "src/types"; import type { IOpts } from "../hooks/useOffsetX"; @@ -26,11 +31,40 @@ export const ItemLayout: React.FC<{ const { props: { loop, dataLength, width, height, vertical, customConfig, mode, modeConfig }, common, - // layout: { updateItemDimensions }, + layout: { updateItemDimensions }, } = useGlobalState(); + const measuredSize = useSharedValue<{ width: number | null; height: number | null }>({ + width: null, + height: null, + }); + const fallbackSize = common.size; - const size = (vertical ? height : width) ?? fallbackSize; + // NOTE: `width`/`height` props are deprecated from v5 onwards. We still read them here so + // existing apps keep their layout until the props are fully removed. + const explicitAxisSize = vertical ? height : width; + const size = (explicitAxisSize ?? fallbackSize) || 0; + const effectivePageSize = size > 0 ? size : undefined; + + const dimensionsStyle = useAnimatedStyle(() => { + const widthCandidate = vertical ? width : explicitAxisSize; + const heightCandidate = vertical ? explicitAxisSize : height; + + const computedWidth = + typeof widthCandidate === "number" + ? widthCandidate + : (measuredSize.value.width ?? (vertical ? "100%" : (effectivePageSize ?? "100%"))); + + const computedHeight = + typeof heightCandidate === "number" + ? heightCandidate + : (measuredSize.value.height ?? (vertical ? (effectivePageSize ?? "100%") : "100%")); + + return { + width: computedWidth, + height: computedHeight, + }; + }, [vertical, width, height, explicitAxisSize, effectivePageSize]); let offsetXConfig: IOpts = { handlerOffset, @@ -71,18 +105,49 @@ export const ItemLayout: React.FC<{ // updateItemDimensions(index, { width, height }); // } + const child = children({ animationValue }); + + type LayoutableProps = { + collapsable?: boolean; + onLayout?: (event: LayoutChangeEvent) => void; + }; + + const enhancedChild = React.isValidElement(child) + ? React.cloneElement(child, { + collapsable: false, + onLayout: (event: LayoutChangeEvent) => { + const { width: layoutWidth, height: layoutHeight } = event.nativeEvent.layout; + if (layoutWidth > 0 && layoutHeight > 0) { + scheduleOnUI(() => { + const { width: prevWidth, height: prevHeight } = measuredSize.value; + if (prevWidth === layoutWidth && prevHeight === layoutHeight) return; + + measuredSize.value = { + width: layoutWidth, + height: layoutHeight, + }; + updateItemDimensions(index, { + width: layoutWidth, + height: layoutHeight, + }); + }); + } + + child.props?.onLayout?.(event); + }, + }) + : child; + return ( - {children({ animationValue })} + {enhancedChild} ); }; diff --git a/src/components/ScrollViewGesture.tsx b/src/components/ScrollViewGesture.tsx index 14a19807..94b3fc35 100644 --- a/src/components/ScrollViewGesture.tsx +++ b/src/components/ScrollViewGesture.tsx @@ -54,7 +54,7 @@ const IScrollViewGesture: React.FC> = (props) => { minScrollDistancePerSwipe, fixedDirection, }, - common: { size, resolvedSize, sizePhase }, + common: { size, resolvedSize, sizePhase, sizeExplicit }, layout: { updateContainerSize }, } = useGlobalState(); @@ -467,11 +467,12 @@ const IScrollViewGesture: React.FC> = (props) => { const measuredHeight = e.nativeEvent.layout.height; const measuredSize = Math.round((vertical ? measuredHeight : measuredWidth) || 0); - if (measuredSize > 0) { + if (!sizeExplicit && measuredSize > 0) { const current = resolvedSize.value ?? 0; if (Math.abs(current - measuredSize) > 0) { sizePhase.value = current > 0 ? "updating" : sizePhase.value; resolvedSize.value = measuredSize; + sizePhase.value = "ready"; } } diff --git a/src/hooks/useCarouselController.tsx b/src/hooks/useCarouselController.tsx index 3965f74f..acb76f24 100644 --- a/src/hooks/useCarouselController.tsx +++ b/src/hooks/useCarouselController.tsx @@ -1,4 +1,5 @@ import React, { useRef } from "react"; +import { StyleSheet } from "react-native"; import { SharedValue, useAnimatedReaction, useSharedValue } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; @@ -59,7 +60,7 @@ export function useCarouselController(options: IOpts): ICarouselController { const globalState = useGlobalState(); const { - props: { overscrollEnabled, vertical, width, height }, + props: { overscrollEnabled, vertical, style, width, height }, layout: { containerSize }, common: { sizePhase, resolvedSize }, } = globalState; @@ -172,13 +173,21 @@ export function useCarouselController(options: IOpts): ICarouselController { [duration, withAnimation, onScrollEnd] ); + const flattenedStyle = StyleSheet.flatten(style) || {}; + const next = React.useCallback( (opts: TCarouselActionOptions = {}) => { "worklet"; const { count = 1, animated = true, onFinished } = opts; if (!canSliding()) return; - if (!loop && index.value >= dataInfo.length - 1) return; + if (!loop) { + const newIndex = index.value + count; + const isOutOfBounds = newIndex > dataInfo.length - 1; + if (isOutOfBounds) { + return; + } + } /* [Overscroll Protection Logic] @@ -211,9 +220,18 @@ export function useCarouselController(options: IOpts): ICarouselController { maintaining a clean UX without partial item visibility at the edges. */ // For overscroll calculation, use the intended item size, not the measured container size - // In the new dynamic size system, `size` might be the container size from layout measurement, - // but for overscroll we need the actual item size from the width/height props - const itemSize = vertical ? height || size : width || size; + // In the new dynamic size system, the measured `size` could reflect the container rather than + // each item, so prefer explicit width/height props and fall back to style-based dimensions. + const styleWidth = + typeof flattenedStyle.width === "number" ? flattenedStyle.width : undefined; + const styleHeight = + typeof flattenedStyle.height === "number" ? flattenedStyle.height : undefined; + const propWidth = typeof width === "number" ? width : undefined; + const propHeight = typeof height === "number" ? height : undefined; + + const itemSize = vertical + ? (propHeight ?? styleHeight ?? size) + : (propWidth ?? styleWidth ?? size); const visibleContentWidth = (dataInfo.length - index.value) * itemSize; // Get effective container width, with fallback for cases where containerSize @@ -224,12 +242,22 @@ export function useCarouselController(options: IOpts): ICarouselController { return containerSize.value.width; } - // 2. Fallback to props width/height when no measurement available - if (!vertical && width && width > 0) { - return width; + // 2. Fallback to style width/height when no measurement available + if (!vertical) { + if (propWidth && propWidth > 0) { + return propWidth; + } + if (styleWidth && styleWidth > 0) { + return styleWidth; + } } - if (vertical && height && height > 0) { - return height; + if (vertical) { + if (propHeight && propHeight > 0) { + return propHeight; + } + if (styleHeight && styleHeight > 0) { + return styleHeight; + } } // 3. Final fallback - assume multiple items are visible @@ -269,6 +297,7 @@ export function useCarouselController(options: IOpts): ICarouselController { overscrollEnabled, containerSize, vertical, + flattenedStyle, width, height, ] diff --git a/src/hooks/useCommonVariables.test.tsx b/src/hooks/useCommonVariables.test.tsx index 64619baf..3b96994b 100644 --- a/src/hooks/useCommonVariables.test.tsx +++ b/src/hooks/useCommonVariables.test.tsx @@ -1,254 +1,363 @@ import { act, renderHook } from "@testing-library/react-hooks"; - +import { StyleProp, ViewStyle } from "react-native"; import { SharedValue, useSharedValue } from "react-native-reanimated"; + import { useCommonVariables } from "./useCommonVariables"; -import { TInitializeCarouselProps } from "./useInitProps"; - -type UseCommonVariablesInput = Parameters[0]; - -const input = { - vertical: false, - width: 700, - height: 350, - loop: true, - enabled: true, - testID: "xxx", - style: { - width: "100%", - }, - autoPlay: false, - autoPlayInterval: 2000, - data: [0, 1, 2, 3], - renderItem: () => null, - pagingEnabled: true, - defaultIndex: 0, - autoFillData: true, - dataLength: 4, - rawData: [0, 1, 2, 3], - rawDataLength: 4, - scrollAnimationDuration: 500, - snapEnabled: true, - overscrollEnabled: true, -} as unknown as UseCommonVariablesInput; +import type { TInitializeCarouselProps } from "./useInitProps"; + +function createBaseProps( + overrides: Partial> = {} +): TInitializeCarouselProps { + return { + defaultIndex: 0, + loop: true, + scrollAnimationDuration: 500, + autoFillData: true, + autoPlayInterval: 2000, + autoPlay: false, + data: [0, 1, 2, 3], + dataLength: 4, + rawData: [0, 1, 2, 3], + rawDataLength: 4, + vertical: false, + style: { width: 700, height: 350 } as StyleProp, + renderItem: () => null, + pagingEnabled: true, + enabled: true, + overscrollEnabled: true, + snapEnabled: true, + testID: "carousel", + ...overrides, + } as TInitializeCarouselProps; +} describe("useCommonVariables", () => { - it("should return the correct values", async () => { - const hook = renderHook(() => useCommonVariables(input)); + it("returns expected values when style provides width", () => { + const props = createBaseProps(); + const { result } = renderHook(() => useCommonVariables(props)); + + expect(result.current.size).toBe(700); + expect(result.current.validLength).toBe(3); + expect(result.current.handlerOffset.value).toBeCloseTo(0); + expect(result.current.resolvedSize.value).toBe(700); + expect(result.current.sizePhase.value).toBe("ready"); + }); - expect(hook.result.current.size).toBe(700); - expect(hook.result.current.validLength).toBe(3); - expect(hook.result.current.handlerOffset.value).toBe(-0); - expect(hook.result.current.resolvedSize.value).toBe(700); - expect(hook.result.current.sizePhase.value).toBe("ready"); + it("uses style.height as size when vertical", () => { + const props = createBaseProps({ vertical: true, style: { height: 360 } }); + const { result } = renderHook(() => useCommonVariables(props)); + + expect(result.current.size).toBe(360); + expect(result.current.resolvedSize.value).toBe(360); }); - it("should handle vertical orientation", () => { - const verticalInput = { - ...input, - vertical: true, - } as TInitializeCarouselProps; + it("initializes handlerOffset when defaultIndex is non-zero", () => { + const props = createBaseProps({ defaultIndex: 2 }); + const { result } = renderHook(() => useCommonVariables(props)); - const hook = renderHook(() => useCommonVariables(verticalInput)); + expect(result.current.handlerOffset.value).toBe(-1400); + }); - expect(hook.result.current.size).toBe(350); // Should use height instead of width - expect(hook.result.current.validLength).toBe(3); - expect(hook.result.current.resolvedSize.value).toBe(350); - expect(hook.result.current.sizePhase.value).toBe("ready"); + it("respects custom defaultScrollOffsetValue", () => { + let shared!: SharedValue; + const { result } = renderHook(() => { + shared = useSharedValue(-500); + const props = createBaseProps({ defaultScrollOffsetValue: shared }); + return useCommonVariables(props); + }); + + expect(result.current.handlerOffset).toBe(shared); + expect(result.current.handlerOffset.value).toBe(-500); + }); + + it("sets validLength to 0 when dataLength is 1", () => { + const props = createBaseProps({ dataLength: 1, data: [0] }); + const { result } = renderHook(() => useCommonVariables(props)); + + expect(result.current.validLength).toBe(0); + }); + + it("sets validLength to -1 when dataLength is 0", () => { + const props = createBaseProps({ dataLength: 0, data: [] }); + const { result } = renderHook(() => useCommonVariables(props)); + + expect(result.current.validLength).toBe(-1); }); - it("should calculate defaultHandlerOffsetValue correctly with non-zero defaultIndex", () => { - const inputWithDefaultIndex = { - ...input, - defaultIndex: 2, - }; + it("handles negative defaultIndex", () => { + const props = createBaseProps({ defaultIndex: -1 }); + const { result } = renderHook(() => useCommonVariables(props)); - const hook = renderHook(() => useCommonVariables(inputWithDefaultIndex)); + expect(result.current.handlerOffset.value).toBe(-700); + }); + + it("keeps size calculation when loop is disabled", () => { + const props = createBaseProps({ loop: false }); + const { result } = renderHook(() => useCommonVariables(props)); - expect(hook.result.current.handlerOffset.value).toBe(-1400); // -2 * 700 + expect(result.current.size).toBe(700); + expect(result.current.validLength).toBe(3); }); - it("should use custom defaultScrollOffsetValue when provided", () => { - let _customOffset: SharedValue; - const hook = renderHook(() => { - const customOffset = useSharedValue(-500); - const inputWithCustomOffset = { - ...input, - defaultScrollOffsetValue: customOffset, - } satisfies UseCommonVariablesInput; - const vars = useCommonVariables(inputWithCustomOffset); - _customOffset = customOffset; - return vars; + it("syncs resolvedSize when style width changes", () => { + const initial = createBaseProps(); + const hook = renderHook(({ p }) => useCommonVariables(p), { initialProps: { p: initial } }); + expect(hook.result.current.resolvedSize.value).toBe(700); + + const updated = createBaseProps({ style: { width: 800 } }); + act(() => { + hook.rerender({ p: updated }); }); - expect(hook.result.current.handlerOffset).toBe(_customOffset!); - expect(hook.result.current.handlerOffset.value).toBe(-500); + expect(hook.result.current.resolvedSize.value).toBe(800); }); - it("should handle single data item", () => { - const singleItemInput = { - ...input, - dataLength: 1, - data: [0], - }; + it("updates validLength when dataLength changes", () => { + const hook = renderHook(({ p }) => useCommonVariables(p), { + initialProps: { p: createBaseProps() }, + }); + expect(hook.result.current.validLength).toBe(3); - const hook = renderHook(() => useCommonVariables(singleItemInput)); + act(() => { + hook.rerender({ p: createBaseProps({ dataLength: 6, data: [0, 1, 2, 3, 4, 5] }) }); + }); - expect(hook.result.current.validLength).toBe(0); // dataLength - 1 + expect(hook.result.current.validLength).toBe(5); }); - it("should handle zero dataLength", () => { - const emptyInput = { - ...input, - dataLength: 0, - data: [], - }; + it("remains pending when style lacks numeric size", () => { + const props = createBaseProps({ style: { width: "100%" } }); + const { result } = renderHook(() => useCommonVariables(props)); + + expect(result.current.size).toBe(0); + expect(result.current.resolvedSize.value).toBeNull(); + expect(result.current.sizePhase.value).toBe("pending"); + }); - const hook = renderHook(() => useCommonVariables(emptyInput)); + it("keeps handlerOffset at zero when width is 0", () => { + const props = createBaseProps({ style: { width: 0 } }); + const { result } = renderHook(() => useCommonVariables(props)); - expect(hook.result.current.validLength).toBe(-1); // dataLength - 1 + expect(result.current.size).toBe(0); + expect(result.current.handlerOffset.value).toBeCloseTo(0); }); - it("should handle negative defaultIndex", () => { - const negativeIndexInput = { - ...input, - defaultIndex: -1, - }; + it("handles large defaultIndex values", () => { + const props = createBaseProps({ defaultIndex: 10 }); + const { result } = renderHook(() => useCommonVariables(props)); + + expect(result.current.handlerOffset.value).toBe(-7000); + }); - const hook = renderHook(() => useCommonVariables(negativeIndexInput)); + it("returns zero size when vertical height is 0", () => { + const props = createBaseProps({ vertical: true, style: { height: 0 } }); + const { result } = renderHook(() => useCommonVariables(props)); - expect(hook.result.current.handlerOffset.value).toBe(-700); // -Math.abs(-1 * 700) + expect(result.current.size).toBe(0); }); - it("should handle loop disabled", () => { - const noLoopInput = { - ...input, - loop: false, - }; + it("accepts floating point dimensions", () => { + const props = createBaseProps({ style: { width: 700.5 } }); + const { result } = renderHook(() => useCommonVariables(props)); - const hook = renderHook(() => useCommonVariables(noLoopInput)); + expect(result.current.size).toBe(700.5); + expect(result.current.resolvedSize.value).toBe(700.5); + }); - expect(hook.result.current.size).toBe(700); - expect(hook.result.current.validLength).toBe(3); + it("stays pending when only height is provided", () => { + const props = createBaseProps({ style: { height: 200 } }); + const { result } = renderHook(() => useCommonVariables(props)); + + expect(result.current.size).toBe(0); + expect(result.current.sizePhase.value).toBe("pending"); }); - it("should update when props change", () => { - const hook = renderHook(({ props }) => useCommonVariables(props), { - initialProps: { props: input }, + describe("itemWidth/itemHeight props", () => { + it("uses itemWidth for horizontal carousel size when provided", () => { + const props = createBaseProps({ + style: { width: 700, height: 350 }, + itemWidth: 350, + }); + const { result } = renderHook(() => useCommonVariables(props)); + + expect(result.current.size).toBe(350); + expect(result.current.resolvedSize.value).toBe(350); + expect(result.current.sizePhase.value).toBe("ready"); }); - expect(hook.result.current.size).toBe(700); - expect(hook.result.current.resolvedSize.value).toBe(700); + it("uses itemHeight for vertical carousel size when provided", () => { + const props = createBaseProps({ + vertical: true, + style: { width: 700, height: 350 }, + itemHeight: 200, + }); + const { result } = renderHook(() => useCommonVariables(props)); + + expect(result.current.size).toBe(200); + expect(result.current.resolvedSize.value).toBe(200); + expect(result.current.sizePhase.value).toBe("ready"); + }); - // Update width - const updatedInput = { - ...input, - width: 800, - }; + it("prioritizes itemWidth over style.width for horizontal carousel", () => { + const props = createBaseProps({ + style: { width: 700 }, + itemWidth: 350, + }); + const { result } = renderHook(() => useCommonVariables(props)); - act(() => { - hook.rerender({ props: updatedInput }); + expect(result.current.size).toBe(350); }); - // resolvedSize should be updated immediately for manual size - expect(hook.result.current.resolvedSize.value).toBe(800); - // Note: size state update is async via useAnimatedReaction, - // which may not complete in test environment - }); + it("prioritizes itemHeight over style.height for vertical carousel", () => { + const props = createBaseProps({ + vertical: true, + style: { height: 400 }, + itemHeight: 200, + }); + const { result } = renderHook(() => useCommonVariables(props)); - it("should handle dataLength changes", () => { - const hook = renderHook(({ props }) => useCommonVariables(props), { - initialProps: { props: input }, + expect(result.current.size).toBe(200); }); - expect(hook.result.current.validLength).toBe(3); + it("prioritizes itemWidth over deprecated width prop", () => { + const props = createBaseProps({ + width: 700, + itemWidth: 350, + }); + const { result } = renderHook(() => useCommonVariables(props)); - // Update dataLength - const updatedInput = { - ...input, - dataLength: 6, - }; + expect(result.current.size).toBe(350); + }); - hook.rerender({ props: updatedInput }); - expect(hook.result.current.validLength).toBe(5); - }); + it("falls back to deprecated width prop when itemWidth not provided", () => { + const props = createBaseProps({ + width: 500, + }); + const { result } = renderHook(() => useCommonVariables(props)); - it("should handle zero size (edge case)", () => { - const zeroSizeInput = { - ...input, - width: 0, - }; + expect(result.current.size).toBe(500); + }); - const hook = renderHook(() => useCommonVariables(zeroSizeInput)); + it("falls back to deprecated height prop when itemHeight not provided in vertical mode", () => { + const props = createBaseProps({ + vertical: true, + height: 400, + }); + const { result } = renderHook(() => useCommonVariables(props)); - expect(hook.result.current.size).toBe(0); - expect(hook.result.current.handlerOffset.value).toBe(0); // -Math.abs(0 * 0) = 0 - }); + expect(result.current.size).toBe(400); + }); - it("should handle large defaultIndex", () => { - const largeIndexInput = { - ...input, - defaultIndex: 10, - }; + it("ignores zero or negative itemWidth", () => { + const props = createBaseProps({ + style: { width: 700 }, + itemWidth: 0, + }); + const { result } = renderHook(() => useCommonVariables(props)); - const hook = renderHook(() => useCommonVariables(largeIndexInput)); + expect(result.current.size).toBe(700); + }); - expect(hook.result.current.handlerOffset.value).toBe(-7000); // -Math.abs(10 * 700) - }); + it("ignores zero or negative itemHeight", () => { + const props = createBaseProps({ + vertical: true, + style: { height: 400 }, + itemHeight: -10, + }); + const { result } = renderHook(() => useCommonVariables(props)); - it("should handle vertical with zero height", () => { - const verticalZeroHeightInput = { - ...input, - vertical: true, - height: 0, - } as TInitializeCarouselProps; + expect(result.current.size).toBe(400); + }); + + it("calculates handlerOffset correctly with itemWidth", () => { + const props = createBaseProps({ + style: { width: 700 }, + itemWidth: 350, + defaultIndex: 2, + }); + const { result } = renderHook(() => useCommonVariables(props)); - const hook = renderHook(() => useCommonVariables(verticalZeroHeightInput)); + // defaultIndex 2 * itemWidth 350 = -700 + expect(result.current.handlerOffset.value).toBe(-700); + }); - expect(hook.result.current.size).toBe(0); + it("updates resolvedSize when itemWidth changes", () => { + const initial = createBaseProps({ style: { width: 700 }, itemWidth: 350 }); + const hook = renderHook(({ p }) => useCommonVariables(p), { initialProps: { p: initial } }); + expect(hook.result.current.size).toBe(350); + expect(hook.result.current.resolvedSize.value).toBe(350); + + const updated = createBaseProps({ style: { width: 700 }, itemWidth: 400 }); + act(() => { + hook.rerender({ p: updated }); + }); + + // resolvedSize (SharedValue) updates immediately via useEffect + expect(hook.result.current.resolvedSize.value).toBe(400); + // size (state) updates asynchronously via useAnimatedReaction + // In test environment, this requires waiting for the animated reaction to fire + }); }); - it("should handle floating point dimensions", () => { - const floatInput = { - ...input, - width: 700.5, - height: 350.25, - }; + describe("sizeExplicit flag", () => { + it("sets sizeExplicit to true when itemWidth is provided for horizontal carousel", () => { + const props = createBaseProps({ + style: { width: 700 }, + itemWidth: 350, + }); + const { result } = renderHook(() => useCommonVariables(props)); - const hook = renderHook(() => useCommonVariables(floatInput)); + expect(result.current.sizeExplicit).toBe(true); + }); - expect(hook.result.current.size).toBe(700.5); - }); + it("sets sizeExplicit to true when itemHeight is provided for vertical carousel", () => { + const props = createBaseProps({ + vertical: true, + style: { height: 400 }, + itemHeight: 200, + }); + const { result } = renderHook(() => useCommonVariables(props)); - it("should calculate validLength correctly for different data lengths", () => { - const testCases = [ - { dataLength: 0, expected: -1 }, - { dataLength: 1, expected: 0 }, - { dataLength: 5, expected: 4 }, - { dataLength: 100, expected: 99 }, - ]; + expect(result.current.sizeExplicit).toBe(true); + }); - for (const { dataLength, expected } of testCases) { - const testInput = { - ...input, - dataLength, - }; + it("sets sizeExplicit to false when only style.width is provided", () => { + const props = createBaseProps({ + style: { width: 700 }, + }); + const { result } = renderHook(() => useCommonVariables(props)); - const hook = renderHook(() => useCommonVariables(testInput)); - expect(hook.result.current.validLength).toBe(expected); - } - }); + expect(result.current.sizeExplicit).toBe(false); + }); + + it("sets sizeExplicit to false when only deprecated width prop is provided", () => { + const props = createBaseProps({ + width: 700, + }); + const { result } = renderHook(() => useCommonVariables(props)); - it("should handle undefined width/height with pending state", () => { - const noSizeInput = { - ...input, - width: undefined, - height: undefined, - }; + expect(result.current.sizeExplicit).toBe(false); + }); - const hook = renderHook(() => useCommonVariables(noSizeInput)); + it("sets sizeExplicit to false when itemWidth is zero", () => { + const props = createBaseProps({ + style: { width: 700 }, + itemWidth: 0, + }); + const { result } = renderHook(() => useCommonVariables(props)); - expect(hook.result.current.size).toBe(0); - expect(hook.result.current.resolvedSize.value).toBeNull(); - expect(hook.result.current.sizePhase.value).toBe("pending"); + expect(result.current.sizeExplicit).toBe(false); + }); + + it("sets sizeExplicit to false when itemHeight is negative", () => { + const props = createBaseProps({ + vertical: true, + style: { height: 400 }, + itemHeight: -10, + }); + const { result } = renderHook(() => useCommonVariables(props)); + + expect(result.current.sizeExplicit).toBe(false); + }); }); }); diff --git a/src/hooks/useCommonVariables.ts b/src/hooks/useCommonVariables.ts index 0318e157..3b4992eb 100644 --- a/src/hooks/useCommonVariables.ts +++ b/src/hooks/useCommonVariables.ts @@ -1,4 +1,5 @@ import React from "react"; +import { StyleSheet } from "react-native"; import type { SharedValue } from "react-native-reanimated"; import { useAnimatedReaction, useSharedValue } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; @@ -17,16 +18,40 @@ export interface ICommonVariables { handlerOffset: SharedValue; resolvedSize: SharedValue; sizePhase: SharedValue; + sizeExplicit: boolean; } export function useCommonVariables(props: TInitializeCarouselProps): ICommonVariables { - const { vertical, height, width, dataLength, defaultIndex, defaultScrollOffsetValue, loop } = - props; + const { + vertical, + style, + dataLength, + defaultIndex, + defaultScrollOffsetValue, + loop, + width: explicitWidth, + height: explicitHeight, + itemWidth: explicitItemWidth, + itemHeight: explicitItemHeight, + } = props; const manualSize = React.useMemo(() => { + const explicitPageSize = vertical ? explicitItemHeight : explicitItemWidth; + if (typeof explicitPageSize === "number" && explicitPageSize > 0) { + return explicitPageSize; + } + + // NOTE: `width`/`height` props are deprecated in v5. They are still respected here to + // maintain backwards compatibility with v4-style usage. Remove once the props are dropped. + const explicitCandidate = vertical ? explicitHeight : explicitWidth; + if (typeof explicitCandidate === "number" && explicitCandidate > 0) { + return explicitCandidate; + } + + const { width, height } = StyleSheet.flatten(style) || {}; const candidate = vertical ? height : width; return typeof candidate === "number" && candidate > 0 ? candidate : null; - }, [vertical, width, height]); + }, [vertical, style, explicitWidth, explicitHeight, explicitItemHeight, explicitItemWidth]); const resolvedSize = useSharedValue(manualSize); const sizePhase = useSharedValue(manualSize ? "ready" : "pending"); @@ -36,6 +61,10 @@ export function useCommonVariables(props: TInitializeCarouselProps): ICommo const handlerOffset = defaultScrollOffsetValue ?? _handlerOffset; const prevDataLength = useSharedValue(dataLength); const prevSize = useSharedValue(manualSize ?? 0); + const sizeExplicit = React.useMemo(() => { + const explicitPageSize = vertical ? explicitItemHeight : explicitItemWidth; + return typeof explicitPageSize === "number" && explicitPageSize > 0; + }, [explicitItemHeight, explicitItemWidth, vertical]); const [size, setSize] = React.useState(manualSize ?? 0); @@ -134,5 +163,6 @@ export function useCommonVariables(props: TInitializeCarouselProps): ICommo handlerOffset, resolvedSize, sizePhase, + sizeExplicit, }; } diff --git a/src/hooks/useInitProps.ts b/src/hooks/useInitProps.ts index a181383e..296700c1 100644 --- a/src/hooks/useInitProps.ts +++ b/src/hooks/useInitProps.ts @@ -31,10 +31,14 @@ export function useInitProps(props: TCarouselProps): TInitializeCarouselPr snapEnabled = props.enableSnap ?? true, width: _width, height: _height, + itemWidth: _itemWidth, + itemHeight: _itemHeight, } = props; const width = typeof _width === "number" ? Math.round(_width) : undefined; const height = typeof _height === "number" ? Math.round(_height) : undefined; + const itemWidth = typeof _itemWidth === "number" && _itemWidth > 0 ? _itemWidth : undefined; + const itemHeight = typeof _itemHeight === "number" && _itemHeight > 0 ? _itemHeight : undefined; const autoPlayInterval = Math.max(_autoPlayInterval, 0); const data = React.useMemo(() => { @@ -77,5 +81,7 @@ export function useInitProps(props: TCarouselProps): TInitializeCarouselPr overscrollEnabled, width, height, + itemWidth, + itemHeight, }; } diff --git a/src/hooks/useOnProgressChange.ts b/src/hooks/useOnProgressChange.ts index 67d729d2..bdbdb2fc 100644 --- a/src/hooks/useOnProgressChange.ts +++ b/src/hooks/useOnProgressChange.ts @@ -46,8 +46,11 @@ export function useOnProgressChange( if (value > 0) absoluteProgress = rawDataLength - absoluteProgress; if (onProgressChange) { - if (isFunc) scheduleOnRN(onProgressChange, value, absoluteProgress); - else onProgressChange.value = absoluteProgress; + if (isFunc) { + scheduleOnRN(onProgressChange, value, absoluteProgress); + } else { + (onProgressChange as SharedValue).value = absoluteProgress; + } } }, [loop, autoFillData, rawDataLength, onProgressChange, size] diff --git a/src/hooks/usePropsErrorBoundary.ts b/src/hooks/usePropsErrorBoundary.ts index a52cf5e4..7185bce0 100644 --- a/src/hooks/usePropsErrorBoundary.ts +++ b/src/hooks/usePropsErrorBoundary.ts @@ -1,4 +1,5 @@ import React from "react"; +import { StyleSheet } from "react-native"; import type { TCarouselProps } from "../types"; @@ -12,18 +13,43 @@ export function usePropsErrorBoundary(props: TCarouselProps & { dataLength: numb } } - // When the developer does not explicitly specify the main axis size, it will be automatically filled during runtime through layout measurement. - // Therefore, the exception is no longer forced to be thrown, only prompted once in development mode. if (__DEV__) { - if (!props.vertical && !props.width && !warnedRefs.horizontal) { + const { style, vertical, width, height, contentContainerStyle } = props; + const { width: styleWidth, height: styleHeight } = StyleSheet.flatten(style) || {}; + + // Deprecation warnings for width/height props + if (typeof width === "number" && !warnedRefs.width) { + console.warn( + "[react-native-reanimated-carousel] The `width` prop is deprecated. Please use `style={{ width: ... }}` instead." + ); + warnedRefs.width = true; + } + if (typeof height === "number" && !warnedRefs.height) { + console.warn( + "[react-native-reanimated-carousel] The `height` prop is deprecated. Please use `style={{ height: ... }}` instead." + ); + warnedRefs.height = true; + } + + // Conflict warning for contentContainerStyle + const { opacity, transform } = StyleSheet.flatten(contentContainerStyle) || {}; + if ((opacity !== undefined || transform !== undefined) && !warnedRefs.conflict) { + console.warn( + "[react-native-reanimated-carousel] Do not set 'opacity' or 'transform' on 'contentContainerStyle' as it may conflict with animations." + ); + warnedRefs.conflict = true; + } + + // Updated missing size warnings + if (!vertical && !width && !styleWidth && !warnedRefs.horizontal) { console.warn( - "[react-native-reanimated-carousel] Horizontal mode did not specify `width`, will fall back to automatic measurement mode." + "[react-native-reanimated-carousel] Horizontal mode did not specify `width` in `style`, will fall back to automatic measurement mode." ); warnedRefs.horizontal = true; } - if (props.vertical && !props.height && !warnedRefs.vertical) { + if (vertical && !height && !styleHeight && !warnedRefs.vertical) { console.warn( - "[react-native-reanimated-carousel] Vertical mode did not specify `height`, will fall back to automatic measurement mode." + "[react-native-reanimated-carousel] Vertical mode did not specify `height` in `style`, will fall back to automatic measurement mode." ); warnedRefs.vertical = true; } @@ -31,7 +57,10 @@ export function usePropsErrorBoundary(props: TCarouselProps & { dataLength: numb }, [props]); } -const warnedRefs: { horizontal: boolean; vertical: boolean } = { +const warnedRefs: { [key: string]: boolean } = { horizontal: false, vertical: false, + width: false, + height: false, + conflict: false, }; diff --git a/src/types.ts b/src/types.ts index 8e2c947f..7993eb6e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import type { StyleProp, ViewStyle } from "react-native"; +import type { LayoutChangeEvent, StyleProp, ViewStyle } from "react-native"; import type { PanGesture } from "react-native-gesture-handler"; import type { SharedValue, WithSpringConfig, WithTimingConfig } from "react-native-reanimated"; @@ -113,13 +113,38 @@ export type TCarouselProps = { */ scrollAnimationDuration?: number; /** - * Carousel content style + * Carousel container style. */ style?: StyleProp; /** - * Carousel container style + * Carousel content container style. */ - containerStyle?: StyleProp; + contentContainerStyle?: StyleProp; + /** + * Horizontal page size used for snapping and animations. + * Useful when you want multiple items visible in a single viewport. + * Defaults to the carousel container width when not provided. + */ + itemWidth?: number; + /** + * Vertical page size used for snapping and animations. + * Useful when you want multiple rows visible at once in vertical mode. + * Defaults to the carousel container height when not provided. + */ + itemHeight?: number; + + // Other props... + + /** + * @deprecated Previously controlled the outer container width (v4 behaviour). + * Move this value into `style`, e.g. `style={{ width: 300 }}`. + */ + width?: number; + /** + * @deprecated Previously controlled the outer container height (v4 behaviour). + * Move this value into `style`, e.g. `style={{ height: 200 }}`. + */ + height?: number; /** * PanGesture config * @test_coverage ✅ tested in Carousel.test.tsx > should call the onConfigurePanGesture callback @@ -217,9 +242,8 @@ export type TCarouselProps = { * * If you want to update a shared value automatically, you can use the shared value as a parameter directly. */ - onProgressChange?: - | ((offsetProgress: number, absoluteProgress: number) => void) - | SharedValue; + onProgressChange?: (offsetProgress: number, absoluteProgress: number) => void; + onLayout?: (event: LayoutChangeEvent) => void; // ============================== deprecated props ============================== /**