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 653214d6..674fea63 100644 --- a/example/app/app/demos/basic-layouts/left-align/index.tsx +++ b/example/app/app/demos/basic-layouts/left-align/index.tsx @@ -38,12 +38,8 @@ function Index() { console.log("current index:", index)} renderItem={renderItem({ rounded: true, style: { marginRight: 8 } })} /> diff --git a/example/website/pages/usage.mdx b/example/website/pages/usage.mdx index 060ec382..59212045 100644 --- a/example/website/pages/usage.mdx +++ b/example/website/pages/usage.mdx @@ -89,3 +89,91 @@ function App() { export default App; ``` + +## Sizing Your Carousel + +The Carousel component supports multiple ways to define its size. Understanding these options helps you achieve the layout you need. + +### 1. Explicit Dimensions (Recommended) + +The most straightforward approach is setting explicit `width` and `height` via the `style` prop: + +```tsx + +``` + +This gives you full control over the carousel's dimensions and is the most predictable approach. + +### 2. Flex-based Sizing + +You can use flex layout to have the carousel fill its parent container: + +```tsx + + + +``` + +The carousel will measure its container and adapt automatically. This is useful when you want the carousel to respond to its parent's layout. + +### 3. Custom Snap Distance with `itemWidth` / `itemHeight` + +When you want items smaller than the container with custom snapping behavior, use `itemWidth` (horizontal) or `itemHeight` (vertical): + +```tsx +// Container is 400px wide, but snaps every 200px (showing ~2 items) + +``` + +This is particularly useful for: +- Showing multiple items at once +- Implementing "peek" effects where adjacent items are partially visible +- Custom snap intervals independent of container size + +### 4. Legacy Props (Deprecated) + +The `width` and `height` props are deprecated but still supported for backward compatibility: + +```tsx +// Deprecated - use style={{ width, height }} instead + +``` + +We recommend migrating to the `style` prop for new code. + +### Sizing Summary + +| Scenario | Props to Use | Example | +|----------|-------------|---------| +| Fixed size | `style={{ width, height }}` | `style={{ width: 300, height: 200 }}` | +| Fill parent | `style={{ flex: 1 }}` | Parent must have defined size | +| Custom snap | `style` + `itemWidth`/`itemHeight` | `style={{ width: 400 }} itemWidth={200}` | +| Vertical | `style` + `vertical` | `style={{ height: 400 }} vertical` | + +### Important Notes + +1. **At least one dimension is required**: The carousel needs to know its size either through explicit dimensions, flex layout, or legacy props. + +2. **For horizontal carousels**: Width is the primary dimension that controls snapping. + +3. **For vertical carousels**: Height is the primary dimension that controls snapping. + +4. **Performance**: The carousel waits for layout measurement before rendering items. With `style={{ flex: 1 }}`, there may be a brief moment before items appear as the component measures its container. diff --git a/src/components/Carousel.test.tsx b/src/components/Carousel.test.tsx index d5a5d2e0..e7eb9ea7 100644 --- a/src/components/Carousel.test.tsx +++ b/src/components/Carousel.test.tsx @@ -217,56 +217,81 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp it("should use itemWidth for snapping size when provided", async () => { const progress = { current: 0 }; const Wrapper = createCarousel(progress); + const containerWidth = 700; + const itemWidth = 350; 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) + // Verify items render and content container is set up correctly const contentContainer = getByTestId("carousel-content-container"); expect(contentContainer).toBeTruthy(); + expect(getByTestId("carousel-item-0")).toBeTruthy(); + expect(getByTestId("carousel-item-1")).toBeTruthy(); }); it("should use itemHeight for snapping size in vertical mode when provided", async () => { const progress = { current: 0 }; const Wrapper = createCarousel(progress); + const containerHeight = 700; + const itemHeight = 350; const { getByTestId } = render( - + ); await verifyInitialRender(getByTestId); // The carousel should use itemHeight (350) for snapping instead of container height (700) + // Verify items render - vertical mode uses the same snap logic const contentContainer = getByTestId("carousel-content-container"); expect(contentContainer).toBeTruthy(); + // Verify first item renders + expect(getByTestId("carousel-item-0")).toBeTruthy(); }); it("should prioritize itemWidth over width prop", async () => { const progress = { current: 0 }; const Wrapper = createCarousel(progress); + const containerWidth = 700; + const itemWidth = 350; const { getByTestId } = render( - + ); await verifyInitialRender(getByTestId); - // itemWidth (350) should take precedence + // itemWidth (350) should take precedence over deprecated width prop + // Verify items render correctly with multiple visible const contentContainer = getByTestId("carousel-content-container"); expect(contentContainer).toBeTruthy(); + expect(getByTestId("carousel-item-0")).toBeTruthy(); + expect(getByTestId("carousel-item-1")).toBeTruthy(); }); it("should support itemWidth for multiple visible items scenario", async () => { const progress = { current: 0 }; const Wrapper = createCarousel(progress); + const containerWidth = 900; + const itemWidth = 300; const { getByTestId } = render( - + ); await verifyInitialRender(getByTestId); // Container is 900px, itemWidth is 300px, so 3 items should be visible - // Verify items are rendered + // Verify multiple items are rendered (visible in the viewport) expect(getByTestId("carousel-item-0")).toBeTruthy(); expect(getByTestId("carousel-item-1")).toBeTruthy(); expect(getByTestId("carousel-item-2")).toBeTruthy(); + expect(getByTestId("carousel-item-3")).toBeTruthy(); }); it("should accept onLayout callback prop", async () => { @@ -889,4 +914,64 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp expect(handlerOffset.current).toBe(-slideWidth); }); }); + + describe("Carousel sizing and measurement", () => { + it("should render items even before onLayout provides size (flex-based sizing)", async () => { + const progress = { current: 0 }; + const Wrapper = createCarousel(progress); + + // Render with flex: 1 (no explicit width/height values) + const { queryByTestId } = render(); + + // Items should render immediately with initial visible ranges + // even if size measurement hasn't completed yet + await waitFor( + () => { + const item = queryByTestId("carousel-item-0"); + expect(item).toBeTruthy(); + }, + { timeout: 1000 * 3 } + ); + }); + + it("should render items with explicit style dimensions", async () => { + const progress = { current: 0 }; + const Wrapper = createCarousel(progress); + + const { queryByTestId } = render(); + + // Items should render with explicit dimensions + await waitFor( + () => { + const item = queryByTestId("carousel-item-0"); + expect(item).toBeTruthy(); + }, + { timeout: 1000 * 3 } + ); + }); + + it("should render items with itemWidth for custom snap distance", async () => { + const progress = { current: 0 }; + const Wrapper = createCarousel(progress); + const containerWidth = 600; + const itemWidth = 200; // 3 items visible + + const { getByTestId } = render( + + ); + + // Items should render with itemWidth configuration + await waitFor( + () => { + const item = getByTestId("carousel-item-0"); + expect(item).toBeTruthy(); + }, + { timeout: 1000 * 3 } + ); + + // Verify multiple items are visible due to smaller itemWidth + expect(getByTestId("carousel-item-1")).toBeTruthy(); + expect(getByTestId("carousel-item-2")).toBeTruthy(); + }); + }); }); diff --git a/src/components/CarouselLayout.tsx b/src/components/CarouselLayout.tsx index 8a36d6e6..f8fb5df3 100644 --- a/src/components/CarouselLayout.tsx +++ b/src/components/CarouselLayout.tsx @@ -168,7 +168,7 @@ export const CarouselLayout = React.forwardRef((_props, ref) height: computedHeight, opacity: isSizeReady.value ? 1 : 0, }; - }, [flattenedStyle, isSizeReady, vertical, resolvedSize]); + }, [flattenedStyle, isSizeReady, vertical, resolvedSize, sizePhase]); return ( diff --git a/src/components/ItemLayout.tsx b/src/components/ItemLayout.tsx index 6e25b9a4..ce9325f8 100644 --- a/src/components/ItemLayout.tsx +++ b/src/components/ItemLayout.tsx @@ -30,7 +30,19 @@ export const ItemLayout: React.FC<{ const { handlerOffset, index, children, visibleRanges, animationStyle } = props; const { - props: { loop, dataLength, width, height, vertical, customConfig, mode, modeConfig, style }, + props: { + loop, + dataLength, + width, + height, + vertical, + customConfig, + mode, + modeConfig, + style, + itemWidth, + itemHeight, + }, common, layout: { updateItemDimensions }, } = useGlobalState(); @@ -45,13 +57,18 @@ export const ItemLayout: React.FC<{ const { width: styleWidth, height: styleHeight } = StyleSheet.flatten(style) || {}; const styleWidthNumber = typeof styleWidth === "number" ? styleWidth : undefined; const styleHeightNumber = typeof styleHeight === "number" ? styleHeight : undefined; + + // When itemWidth/itemHeight is provided, use it for item dimensions (not container style) + const explicitItemSize = vertical ? itemHeight : itemWidth; const explicitAxisSize = vertical ? (styleHeightNumber ?? height) : (styleWidthNumber ?? width); - const size = (explicitAxisSize ?? fallbackSize) || 0; + // Use itemWidth/itemHeight if provided, otherwise fall back to container size + const size = (explicitItemSize ?? explicitAxisSize ?? fallbackSize) || 0; const effectivePageSize = size > 0 ? size : undefined; const dimensionsStyle = useAnimatedStyle(() => { - const widthCandidate = vertical ? width : explicitAxisSize; - const heightCandidate = vertical ? explicitAxisSize : height; + // When itemWidth/itemHeight is provided, use it for item width/height + const widthCandidate = vertical ? width : (explicitItemSize ?? explicitAxisSize); + const heightCandidate = vertical ? (explicitItemSize ?? explicitAxisSize) : height; const computedWidth = typeof widthCandidate === "number" @@ -67,7 +84,7 @@ export const ItemLayout: React.FC<{ width: computedWidth, height: computedHeight, }; - }, [vertical, width, height, explicitAxisSize, effectivePageSize]); + }, [vertical, width, height, explicitAxisSize, explicitItemSize, effectivePageSize]); let offsetXConfig: IOpts = { handlerOffset, diff --git a/src/components/ItemRenderer.tsx b/src/components/ItemRenderer.tsx index e59605d7..6ceb1156 100644 --- a/src/components/ItemRenderer.tsx +++ b/src/components/ItemRenderer.tsx @@ -52,7 +52,16 @@ export const ItemRenderer: FC = (props) => { loop, }); - const [displayedItems, setDisplayedItems] = React.useState(null!); + // Initialize with a sensible default to avoid blank render on first frame + const initialRanges: VisibleRanges = React.useMemo( + () => ({ + negativeRange: [0, 0], + positiveRange: [0, Math.min(dataLength - 1, (windowSize ?? dataLength) - 1)], + }), + [dataLength, windowSize] + ); + + const [displayedItems, setDisplayedItems] = React.useState(initialRanges); useAnimatedReaction( () => visibleRanges.value, @@ -60,8 +69,6 @@ export const ItemRenderer: FC = (props) => { [visibleRanges] ); - if (!displayedItems) return null; - return ( <> {data.map((item, index) => { diff --git a/src/components/ScrollViewGesture.tsx b/src/components/ScrollViewGesture.tsx index 94b3fc35..c7c9e941 100644 --- a/src/components/ScrollViewGesture.tsx +++ b/src/components/ScrollViewGesture.tsx @@ -133,7 +133,10 @@ const IScrollViewGesture: React.FC> = (props) => { onFinished?: () => void ) => { "worklet"; - if (size <= 0) { + // Use resolvedSize.value (SharedValue) instead of size (React state) + // to avoid race condition where sizeReady is true but size is still 0 + const currentSize = resolvedSize.value ?? 0; + if (currentSize <= 0) { return; } const origin = translation.value; @@ -153,13 +156,13 @@ const IScrollViewGesture: React.FC> = (props) => { * If direction is vertical, the page size is the height of the item. * If direction is horizontal, the page size is the width of the item. * - * `page size` equals to `size` variable. + * `page size` equals to `currentSize` variable. * */ // calculate target "nextPage" based on the final pan position and the velocity of // the pan gesture at termination; this allows for a quick "flick" to indicate a far // off page change. - const nextPage = -Math.round((origin + velocity * 2) / size); + const nextPage = -Math.round((origin + velocity * 2) / currentSize); if (pagingEnabled) { // we'll never go further than a single page away from the current page when paging @@ -168,25 +171,34 @@ const IScrollViewGesture: React.FC> = (props) => { // distance with direction const offset = -(scrollEndTranslationValue >= 0 ? 1 : -1); // 1 or -1 const computed = offset < 0 ? Math.ceil : Math.floor; - const page = computed(-origin / size); + const page = computed(-origin / currentSize); const velocityDirection = -Math.sign(velocity); if (page === nextPage || velocityDirection !== offset) { // not going anywhere! Velocity was insufficient to overcome the distance to get to a // further page. Let's reset gently to the current page. - finalTranslation = withSpring(withProcessTranslation(-page * size), onFinished); + finalTranslation = withSpring(withProcessTranslation(-page * currentSize), onFinished); } else if (loop) { const finalPage = page + offset; - finalTranslation = withSpring(withProcessTranslation(-finalPage * size), onFinished); + finalTranslation = withSpring( + withProcessTranslation(-finalPage * currentSize), + onFinished + ); } else { const finalPage = Math.min(maxPage - 1, Math.max(0, page + offset)); - finalTranslation = withSpring(withProcessTranslation(-finalPage * size), onFinished); + finalTranslation = withSpring( + withProcessTranslation(-finalPage * currentSize), + onFinished + ); } } if (!pagingEnabled && snapEnabled) { // scroll to the nearest item - finalTranslation = withSpring(withProcessTranslation(-nextPage * size), onFinished); + finalTranslation = withSpring( + withProcessTranslation(-nextPage * currentSize), + onFinished + ); } } @@ -204,7 +216,7 @@ const IScrollViewGesture: React.FC> = (props) => { }, [ withSpring, - size, + resolvedSize, maxPage, loop, snapEnabled, @@ -236,7 +248,10 @@ const IScrollViewGesture: React.FC> = (props) => { const resetBoundary = React.useCallback(() => { "worklet"; - if (size <= 0) return; + // Use resolvedSize.value (SharedValue) instead of size (React state) + // to avoid race condition where sizeReady is true but size is still 0 + const currentSize = resolvedSize.value ?? 0; + if (currentSize <= 0) return; if (touching.value) return; if (translation.value > 0) { @@ -250,14 +265,23 @@ const IScrollViewGesture: React.FC> = (props) => { } } - if (translation.value < -((maxPage - 1) * size)) { + if (translation.value < -((maxPage - 1) * currentSize)) { if (scrollEndTranslation.value > 0) { activeDecay(); return; } - if (!loop) translation.value = withSpring(-((maxPage - 1) * size)); + if (!loop) translation.value = withSpring(-((maxPage - 1) * currentSize)); } - }, [touching, translation, maxPage, size, scrollEndTranslation, loop, activeDecay, withSpring]); + }, [ + touching, + translation, + maxPage, + resolvedSize, + scrollEndTranslation, + loop, + activeDecay, + withSpring, + ]); useAnimatedReaction( () => translation.value, @@ -282,21 +306,23 @@ const IScrollViewGesture: React.FC> = (props) => { const onGestureStart = useCallback( (_: PanGestureHandlerEventPayload) => { "worklet"; - if (!sizeReady.value || size <= 0) { + // Use resolvedSize.value (SharedValue) instead of size (React state) + // to avoid race condition where sizeReady is true but size is still 0 + const currentSize = resolvedSize.value ?? 0; + if (!sizeReady.value || currentSize <= 0) { return; } touching.value = true; validStart.value = true; onScrollStart && scheduleOnRN(onScrollStart); - max.value = (maxPage - 1) * size; + max.value = (maxPage - 1) * currentSize; if (!loop && !overscrollEnabled) max.value = getLimit(); panOffset.value = translation.value; }, [ max, - size, maxPage, loop, touching, @@ -307,13 +333,15 @@ const IScrollViewGesture: React.FC> = (props) => { getLimit, onScrollStart, sizeReady, + resolvedSize, ] ); const onGestureUpdate = useCallback( (e: PanGestureHandlerEventPayload) => { "worklet"; - if (!sizeReady.value || size <= 0) { + const currentSize = resolvedSize.value ?? 0; + if (!sizeReady.value || currentSize <= 0) { return; } if (panOffset.value === undefined) { @@ -366,14 +394,17 @@ const IScrollViewGesture: React.FC> = (props) => { validStart, touching, sizeReady, + resolvedSize, ] ); const onGestureEnd = useCallback( (e: GestureStateChangeEvent, _success: boolean) => { "worklet"; - - if (!sizeReady.value || size <= 0) { + // Use resolvedSize.value (SharedValue) instead of size (React state) + // to avoid race condition where sizeReady is true but size is still 0 + const currentSize = resolvedSize.value ?? 0; + if (!sizeReady.value || currentSize <= 0) { panOffset.value = undefined; return; } @@ -406,8 +437,9 @@ const IScrollViewGesture: React.FC> = (props) => { ) { const nextPage = Math.round( - (panOffset.value + maxScrollDistancePerSwipe * Math.sign(totalTranslation)) / size - ) * size; + (panOffset.value + maxScrollDistancePerSwipe * Math.sign(totalTranslation)) / + currentSize + ) * currentSize; translation.value = withSpring(withProcessTranslation(nextPage), onScrollEnd); } else if ( /** @@ -419,8 +451,9 @@ const IScrollViewGesture: React.FC> = (props) => { ) { const nextPage = Math.round( - (panOffset.value + minScrollDistancePerSwipe * Math.sign(totalTranslation)) / size - ) * size; + (panOffset.value + minScrollDistancePerSwipe * Math.sign(totalTranslation)) / + currentSize + ) * currentSize; translation.value = withSpring(withProcessTranslation(nextPage), onScrollEnd); } else { endWithSpring(panTranslation, scrollEndVelocityValue, onScrollEnd); @@ -431,7 +464,6 @@ const IScrollViewGesture: React.FC> = (props) => { panOffset.value = undefined; }, [ - size, loop, touching, panOffset, @@ -442,12 +474,13 @@ const IScrollViewGesture: React.FC> = (props) => { fixedDirection, maxScrollDistancePerSwipeIsSet, maxScrollDistancePerSwipe, - maxScrollDistancePerSwipeIsSet, + minScrollDistancePerSwipeIsSet, minScrollDistancePerSwipe, endWithSpring, withSpring, onScrollEnd, sizeReady, + resolvedSize, ] ); @@ -481,7 +514,7 @@ const IScrollViewGesture: React.FC> = (props) => { height: measuredHeight, }); }, - [updateContainerSize, resolvedSize, sizePhase, vertical] + [updateContainerSize, resolvedSize, sizePhase, vertical, sizeExplicit] ); return ( diff --git a/src/hooks/useVisibleRanges.test.tsx b/src/hooks/useVisibleRanges.test.tsx index 8d2e0932..5d915af0 100644 --- a/src/hooks/useVisibleRanges.test.tsx +++ b/src/hooks/useVisibleRanges.test.tsx @@ -197,4 +197,83 @@ describe("useVisibleRanges", () => { positiveRange: [0, 2], }); }); + + describe("viewSize boundary conditions", () => { + it("should return safe defaults when viewSize is 0", () => { + const hook = renderHook(() => { + const translation = useSharedValue(0); + const range = useVisibleRanges({ + total: 10, + translation, + viewSize: 0, + windowSize: 4, + loop: false, + }); + return range; + }); + + // Should not crash and return sensible defaults + expect(hook.result.current.value).toEqual({ + negativeRange: [0, 0], + positiveRange: [0, 3], // Math.min(10-1, 4-1) = 3 + }); + }); + + it("should return safe defaults when viewSize is negative", () => { + const hook = renderHook(() => { + const translation = useSharedValue(0); + const range = useVisibleRanges({ + total: 5, + translation, + viewSize: -100, + windowSize: 3, + loop: true, + }); + return range; + }); + + expect(hook.result.current.value).toEqual({ + negativeRange: [0, 0], + positiveRange: [0, 2], // Math.min(5-1, 3-1) = 2 + }); + }); + + it("should handle small total with viewSize 0", () => { + const hook = renderHook(() => { + const translation = useSharedValue(0); + const range = useVisibleRanges({ + total: 2, + translation, + viewSize: 0, + windowSize: 5, + loop: false, + }); + return range; + }); + + expect(hook.result.current.value).toEqual({ + negativeRange: [0, 0], + positiveRange: [0, 1], // Math.min(2-1, 5-1) = 1 + }); + }); + + it("should handle loop mode with viewSize 0", () => { + const hook = renderHook(() => { + const translation = useSharedValue(0); + const range = useVisibleRanges({ + total: 6, + translation, + viewSize: 0, + windowSize: 4, + loop: true, + }); + return range; + }); + + expect(hook.result.current.value).toEqual({ + negativeRange: [0, 0], + positiveRange: [0, 3], // Math.min(6-1, 4-1) = 3 + }); + }); + }); }); diff --git a/src/hooks/useVisibleRanges.tsx b/src/hooks/useVisibleRanges.tsx index bd6541de..1da4b231 100644 --- a/src/hooks/useVisibleRanges.tsx +++ b/src/hooks/useVisibleRanges.tsx @@ -24,6 +24,14 @@ export function useVisibleRanges(options: { const cachedRanges = useRef(null); const ranges = useDerivedValue(() => { + // Prevent division by zero when viewSize is not yet measured + if (viewSize <= 0) { + return { + negativeRange: [0, 0] as Range, + positiveRange: [0, Math.min(total - 1, windowSize - 1)] as Range, + }; + } + const positiveCount = Math.round(windowSize / 2); const negativeCount = windowSize - positiveCount;