Skip to content

Commit 80a7b15

Browse files
authored
fix: support flex-based sizing without explicit width/height (#877)
* fix: support flex-based sizing without explicit width/height - Handle viewSize=0 in useVisibleRanges to prevent division by zero - Initialize displayedItems with sensible defaults in ItemRenderer - Add tests for viewSize boundary conditions and sizing scenarios - Add "Sizing Your Carousel" documentation section Fixes #668 * refactor: update carousel examples to utilize itemWidth for layout
1 parent 13861ac commit 80a7b15

File tree

9 files changed

+361
-48
lines changed

9 files changed

+361
-48
lines changed

example/app/app/demos/basic-layouts/left-align/index.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,8 @@ function Index() {
3838
<Carousel
3939
{...restSettings}
4040
ref={ref}
41-
style={{ width: window.width, height: 258 }}
42-
contentContainerStyle={{
43-
width: window.width / 2,
44-
height: 258,
45-
overflow: "visible",
46-
}}
41+
style={{ width: window.width, height: 258, overflow: "visible" }}
42+
itemWidth={window.width / 2}
4743
onSnapToItem={(index) => console.log("current index:", index)}
4844
renderItem={renderItem({ rounded: true, style: { marginRight: 8 } })}
4945
/>

example/website/pages/usage.mdx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,91 @@ function App() {
8989

9090
export default App;
9191
```
92+
93+
## Sizing Your Carousel
94+
95+
The Carousel component supports multiple ways to define its size. Understanding these options helps you achieve the layout you need.
96+
97+
### 1. Explicit Dimensions (Recommended)
98+
99+
The most straightforward approach is setting explicit `width` and `height` via the `style` prop:
100+
101+
```tsx
102+
<Carousel
103+
style={{ width: 300, height: 200 }}
104+
data={data}
105+
renderItem={renderItem}
106+
/>
107+
```
108+
109+
This gives you full control over the carousel's dimensions and is the most predictable approach.
110+
111+
### 2. Flex-based Sizing
112+
113+
You can use flex layout to have the carousel fill its parent container:
114+
115+
```tsx
116+
<View style={{ flex: 1 }}>
117+
<Carousel
118+
style={{ flex: 1 }}
119+
data={data}
120+
renderItem={renderItem}
121+
/>
122+
</View>
123+
```
124+
125+
The carousel will measure its container and adapt automatically. This is useful when you want the carousel to respond to its parent's layout.
126+
127+
### 3. Custom Snap Distance with `itemWidth` / `itemHeight`
128+
129+
When you want items smaller than the container with custom snapping behavior, use `itemWidth` (horizontal) or `itemHeight` (vertical):
130+
131+
```tsx
132+
// Container is 400px wide, but snaps every 200px (showing ~2 items)
133+
<Carousel
134+
style={{ width: 400, height: 200 }}
135+
itemWidth={200}
136+
data={data}
137+
renderItem={renderItem}
138+
/>
139+
```
140+
141+
This is particularly useful for:
142+
- Showing multiple items at once
143+
- Implementing "peek" effects where adjacent items are partially visible
144+
- Custom snap intervals independent of container size
145+
146+
### 4. Legacy Props (Deprecated)
147+
148+
The `width` and `height` props are deprecated but still supported for backward compatibility:
149+
150+
```tsx
151+
// Deprecated - use style={{ width, height }} instead
152+
<Carousel
153+
width={300}
154+
height={200}
155+
data={data}
156+
renderItem={renderItem}
157+
/>
158+
```
159+
160+
We recommend migrating to the `style` prop for new code.
161+
162+
### Sizing Summary
163+
164+
| Scenario | Props to Use | Example |
165+
|----------|-------------|---------|
166+
| Fixed size | `style={{ width, height }}` | `style={{ width: 300, height: 200 }}` |
167+
| Fill parent | `style={{ flex: 1 }}` | Parent must have defined size |
168+
| Custom snap | `style` + `itemWidth`/`itemHeight` | `style={{ width: 400 }} itemWidth={200}` |
169+
| Vertical | `style` + `vertical` | `style={{ height: 400 }} vertical` |
170+
171+
### Important Notes
172+
173+
1. **At least one dimension is required**: The carousel needs to know its size either through explicit dimensions, flex layout, or legacy props.
174+
175+
2. **For horizontal carousels**: Width is the primary dimension that controls snapping.
176+
177+
3. **For vertical carousels**: Height is the primary dimension that controls snapping.
178+
179+
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.

src/components/Carousel.test.tsx

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -217,56 +217,81 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp
217217
it("should use itemWidth for snapping size when provided", async () => {
218218
const progress = { current: 0 };
219219
const Wrapper = createCarousel(progress);
220+
const containerWidth = 700;
221+
const itemWidth = 350;
220222
const { getByTestId } = render(
221-
<Wrapper style={{ width: 700, height: 200 }} itemWidth={350} />
223+
<Wrapper style={{ width: containerWidth, height: 200 }} itemWidth={itemWidth} />
222224
);
223225
await verifyInitialRender(getByTestId);
224226

225227
// The carousel should use itemWidth (350) for snapping instead of container width (700)
226-
// This allows showing multiple items (2 items in this case: 700 / 350)
228+
// Verify items render and content container is set up correctly
227229
const contentContainer = getByTestId("carousel-content-container");
228230
expect(contentContainer).toBeTruthy();
231+
expect(getByTestId("carousel-item-0")).toBeTruthy();
232+
expect(getByTestId("carousel-item-1")).toBeTruthy();
229233
});
230234

231235
it("should use itemHeight for snapping size in vertical mode when provided", async () => {
232236
const progress = { current: 0 };
233237
const Wrapper = createCarousel(progress);
238+
const containerHeight = 700;
239+
const itemHeight = 350;
234240
const { getByTestId } = render(
235-
<Wrapper vertical style={{ width: 350, height: 700 }} itemHeight={350} />
241+
<Wrapper vertical style={{ width: 350, height: containerHeight }} itemHeight={itemHeight} />
236242
);
237243
await verifyInitialRender(getByTestId);
238244

239245
// The carousel should use itemHeight (350) for snapping instead of container height (700)
246+
// Verify items render - vertical mode uses the same snap logic
240247
const contentContainer = getByTestId("carousel-content-container");
241248
expect(contentContainer).toBeTruthy();
249+
// Verify first item renders
250+
expect(getByTestId("carousel-item-0")).toBeTruthy();
242251
});
243252

244253
it("should prioritize itemWidth over width prop", async () => {
245254
const progress = { current: 0 };
246255
const Wrapper = createCarousel(progress);
256+
const containerWidth = 700;
257+
const itemWidth = 350;
247258
const { getByTestId } = render(
248-
<Wrapper style={{ width: 700, height: 200 }} width={700} itemWidth={350} />
259+
<Wrapper
260+
style={{ width: containerWidth, height: 200 }}
261+
width={containerWidth}
262+
itemWidth={itemWidth}
263+
/>
249264
);
250265
await verifyInitialRender(getByTestId);
251266

252-
// itemWidth (350) should take precedence
267+
// itemWidth (350) should take precedence over deprecated width prop
268+
// Verify items render correctly with multiple visible
253269
const contentContainer = getByTestId("carousel-content-container");
254270
expect(contentContainer).toBeTruthy();
271+
expect(getByTestId("carousel-item-0")).toBeTruthy();
272+
expect(getByTestId("carousel-item-1")).toBeTruthy();
255273
});
256274

257275
it("should support itemWidth for multiple visible items scenario", async () => {
258276
const progress = { current: 0 };
259277
const Wrapper = createCarousel(progress);
278+
const containerWidth = 900;
279+
const itemWidth = 300;
260280
const { getByTestId } = render(
261-
<Wrapper style={{ width: 900, height: 200 }} itemWidth={300} data={createMockData(6)} />
281+
<Wrapper
282+
style={{ width: containerWidth, height: 200 }}
283+
itemWidth={itemWidth}
284+
data={createMockData(6)}
285+
/>
262286
);
263287
await verifyInitialRender(getByTestId);
264288

265289
// Container is 900px, itemWidth is 300px, so 3 items should be visible
266-
// Verify items are rendered
290+
// Verify multiple items are rendered (visible in the viewport)
267291
expect(getByTestId("carousel-item-0")).toBeTruthy();
268292
expect(getByTestId("carousel-item-1")).toBeTruthy();
269293
expect(getByTestId("carousel-item-2")).toBeTruthy();
294+
expect(getByTestId("carousel-item-3")).toBeTruthy();
270295
});
271296

272297
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
889914
expect(handlerOffset.current).toBe(-slideWidth);
890915
});
891916
});
917+
918+
describe("Carousel sizing and measurement", () => {
919+
it("should render items even before onLayout provides size (flex-based sizing)", async () => {
920+
const progress = { current: 0 };
921+
const Wrapper = createCarousel(progress);
922+
923+
// Render with flex: 1 (no explicit width/height values)
924+
const { queryByTestId } = render(<Wrapper style={{ flex: 1, height: 200 }} />);
925+
926+
// Items should render immediately with initial visible ranges
927+
// even if size measurement hasn't completed yet
928+
await waitFor(
929+
() => {
930+
const item = queryByTestId("carousel-item-0");
931+
expect(item).toBeTruthy();
932+
},
933+
{ timeout: 1000 * 3 }
934+
);
935+
});
936+
937+
it("should render items with explicit style dimensions", async () => {
938+
const progress = { current: 0 };
939+
const Wrapper = createCarousel(progress);
940+
941+
const { queryByTestId } = render(<Wrapper style={{ width: 400, height: 250 }} />);
942+
943+
// Items should render with explicit dimensions
944+
await waitFor(
945+
() => {
946+
const item = queryByTestId("carousel-item-0");
947+
expect(item).toBeTruthy();
948+
},
949+
{ timeout: 1000 * 3 }
950+
);
951+
});
952+
953+
it("should render items with itemWidth for custom snap distance", async () => {
954+
const progress = { current: 0 };
955+
const Wrapper = createCarousel(progress);
956+
const containerWidth = 600;
957+
const itemWidth = 200; // 3 items visible
958+
959+
const { getByTestId } = render(
960+
<Wrapper style={{ width: containerWidth, height: 200 }} itemWidth={itemWidth} />
961+
);
962+
963+
// Items should render with itemWidth configuration
964+
await waitFor(
965+
() => {
966+
const item = getByTestId("carousel-item-0");
967+
expect(item).toBeTruthy();
968+
},
969+
{ timeout: 1000 * 3 }
970+
);
971+
972+
// Verify multiple items are visible due to smaller itemWidth
973+
expect(getByTestId("carousel-item-1")).toBeTruthy();
974+
expect(getByTestId("carousel-item-2")).toBeTruthy();
975+
});
976+
});
892977
});

src/components/CarouselLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ export const CarouselLayout = React.forwardRef<ICarouselInstance>((_props, ref)
168168
height: computedHeight,
169169
opacity: isSizeReady.value ? 1 : 0,
170170
};
171-
}, [flattenedStyle, isSizeReady, vertical, resolvedSize]);
171+
}, [flattenedStyle, isSizeReady, vertical, resolvedSize, sizePhase]);
172172

173173
return (
174174
<GestureHandlerRootView testID={testID} style={[styles.layoutContainer, style]}>

src/components/ItemLayout.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,19 @@ export const ItemLayout: React.FC<{
3030
const { handlerOffset, index, children, visibleRanges, animationStyle } = props;
3131

3232
const {
33-
props: { loop, dataLength, width, height, vertical, customConfig, mode, modeConfig, style },
33+
props: {
34+
loop,
35+
dataLength,
36+
width,
37+
height,
38+
vertical,
39+
customConfig,
40+
mode,
41+
modeConfig,
42+
style,
43+
itemWidth,
44+
itemHeight,
45+
},
3446
common,
3547
layout: { updateItemDimensions },
3648
} = useGlobalState();
@@ -45,13 +57,18 @@ export const ItemLayout: React.FC<{
4557
const { width: styleWidth, height: styleHeight } = StyleSheet.flatten(style) || {};
4658
const styleWidthNumber = typeof styleWidth === "number" ? styleWidth : undefined;
4759
const styleHeightNumber = typeof styleHeight === "number" ? styleHeight : undefined;
60+
61+
// When itemWidth/itemHeight is provided, use it for item dimensions (not container style)
62+
const explicitItemSize = vertical ? itemHeight : itemWidth;
4863
const explicitAxisSize = vertical ? (styleHeightNumber ?? height) : (styleWidthNumber ?? width);
49-
const size = (explicitAxisSize ?? fallbackSize) || 0;
64+
// Use itemWidth/itemHeight if provided, otherwise fall back to container size
65+
const size = (explicitItemSize ?? explicitAxisSize ?? fallbackSize) || 0;
5066
const effectivePageSize = size > 0 ? size : undefined;
5167

5268
const dimensionsStyle = useAnimatedStyle<ViewStyle>(() => {
53-
const widthCandidate = vertical ? width : explicitAxisSize;
54-
const heightCandidate = vertical ? explicitAxisSize : height;
69+
// When itemWidth/itemHeight is provided, use it for item width/height
70+
const widthCandidate = vertical ? width : (explicitItemSize ?? explicitAxisSize);
71+
const heightCandidate = vertical ? (explicitItemSize ?? explicitAxisSize) : height;
5572

5673
const computedWidth =
5774
typeof widthCandidate === "number"
@@ -67,7 +84,7 @@ export const ItemLayout: React.FC<{
6784
width: computedWidth,
6885
height: computedHeight,
6986
};
70-
}, [vertical, width, height, explicitAxisSize, effectivePageSize]);
87+
}, [vertical, width, height, explicitAxisSize, explicitItemSize, effectivePageSize]);
7188

7289
let offsetXConfig: IOpts = {
7390
handlerOffset,

src/components/ItemRenderer.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,23 @@ export const ItemRenderer: FC<Props> = (props) => {
5252
loop,
5353
});
5454

55-
const [displayedItems, setDisplayedItems] = React.useState<VisibleRanges>(null!);
55+
// Initialize with a sensible default to avoid blank render on first frame
56+
const initialRanges: VisibleRanges = React.useMemo(
57+
() => ({
58+
negativeRange: [0, 0],
59+
positiveRange: [0, Math.min(dataLength - 1, (windowSize ?? dataLength) - 1)],
60+
}),
61+
[dataLength, windowSize]
62+
);
63+
64+
const [displayedItems, setDisplayedItems] = React.useState<VisibleRanges>(initialRanges);
5665

5766
useAnimatedReaction(
5867
() => visibleRanges.value,
5968
(ranges) => scheduleOnRN(setDisplayedItems, ranges),
6069
[visibleRanges]
6170
);
6271

63-
if (!displayedItems) return null;
64-
6572
return (
6673
<>
6774
{data.map((item, index) => {

0 commit comments

Comments
 (0)