Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions example/app/app/demos/basic-layouts/left-align/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,8 @@ function Index() {
<Carousel
{...restSettings}
ref={ref}
style={{ width: window.width, height: 258 }}
contentContainerStyle={{
width: window.width / 2,
height: 258,
overflow: "visible",
}}
style={{ width: window.width, height: 258, overflow: "visible" }}
itemWidth={window.width / 2}
onSnapToItem={(index) => console.log("current index:", index)}
renderItem={renderItem({ rounded: true, style: { marginRight: 8 } })}
/>
Expand Down
88 changes: 88 additions & 0 deletions example/website/pages/usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<Carousel
style={{ width: 300, height: 200 }}
data={data}
renderItem={renderItem}
/>
```

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
<View style={{ flex: 1 }}>
<Carousel
style={{ flex: 1 }}
data={data}
renderItem={renderItem}
/>
</View>
```

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)
<Carousel
style={{ width: 400, height: 200 }}
itemWidth={200}
data={data}
renderItem={renderItem}
/>
```

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
<Carousel
width={300}
height={200}
data={data}
renderItem={renderItem}
/>
```

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.
99 changes: 92 additions & 7 deletions src/components/Carousel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Wrapper style={{ width: 700, height: 200 }} itemWidth={350} />
<Wrapper style={{ width: containerWidth, height: 200 }} itemWidth={itemWidth} />
);
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(
<Wrapper vertical style={{ width: 350, height: 700 }} itemHeight={350} />
<Wrapper vertical style={{ width: 350, height: containerHeight }} itemHeight={itemHeight} />
);
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(
<Wrapper style={{ width: 700, height: 200 }} width={700} itemWidth={350} />
<Wrapper
style={{ width: containerWidth, height: 200 }}
width={containerWidth}
itemWidth={itemWidth}
/>
);
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(
<Wrapper style={{ width: 900, height: 200 }} itemWidth={300} data={createMockData(6)} />
<Wrapper
style={{ width: containerWidth, height: 200 }}
itemWidth={itemWidth}
data={createMockData(6)}
/>
);
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 () => {
Expand Down Expand Up @@ -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(<Wrapper style={{ flex: 1, height: 200 }} />);

// 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(<Wrapper style={{ width: 400, height: 250 }} />);

// 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(
<Wrapper style={{ width: containerWidth, height: 200 }} itemWidth={itemWidth} />
);

// 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();
});
});
});
2 changes: 1 addition & 1 deletion src/components/CarouselLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export const CarouselLayout = React.forwardRef<ICarouselInstance>((_props, ref)
height: computedHeight,
opacity: isSizeReady.value ? 1 : 0,
};
}, [flattenedStyle, isSizeReady, vertical, resolvedSize]);
}, [flattenedStyle, isSizeReady, vertical, resolvedSize, sizePhase]);

return (
<GestureHandlerRootView testID={testID} style={[styles.layoutContainer, style]}>
Expand Down
27 changes: 22 additions & 5 deletions src/components/ItemLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<ViewStyle>(() => {
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"
Expand All @@ -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,
Expand Down
13 changes: 10 additions & 3 deletions src/components/ItemRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,23 @@ export const ItemRenderer: FC<Props> = (props) => {
loop,
});

const [displayedItems, setDisplayedItems] = React.useState<VisibleRanges>(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<VisibleRanges>(initialRanges);

useAnimatedReaction(
() => visibleRanges.value,
(ranges) => scheduleOnRN(setDisplayedItems, ranges),
[visibleRanges]
);

if (!displayedItems) return null;

return (
<>
{data.map((item, index) => {
Expand Down
Loading