@r0b0t3d/react-native-carousel 3.4.6 → 3.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/lib/commonjs/components/Carousel.js +290 -195
  2. package/lib/commonjs/components/Carousel.js.map +1 -1
  3. package/lib/commonjs/components/CarouselContainer.js +17 -31
  4. package/lib/commonjs/components/CarouselContainer.js.map +1 -1
  5. package/lib/commonjs/components/PageItem.js +52 -45
  6. package/lib/commonjs/components/PageItem.js.map +1 -1
  7. package/lib/commonjs/components/PaginationIndicator.js +23 -34
  8. package/lib/commonjs/components/PaginationIndicator.js.map +1 -1
  9. package/lib/commonjs/components/useCarouselContext.js +3 -8
  10. package/lib/commonjs/components/useCarouselContext.js.map +1 -1
  11. package/lib/commonjs/components/useInternalCarouselContext.js +3 -6
  12. package/lib/commonjs/components/useInternalCarouselContext.js.map +1 -1
  13. package/lib/commonjs/index.js +7 -17
  14. package/lib/commonjs/index.js.map +1 -1
  15. package/lib/commonjs/package.json +1 -0
  16. package/lib/commonjs/types.js +4 -0
  17. package/lib/commonjs/types.js.map +1 -1
  18. package/lib/commonjs/utils.js +24 -18
  19. package/lib/commonjs/utils.js.map +1 -1
  20. package/lib/module/components/Carousel.js +291 -180
  21. package/lib/module/components/Carousel.js.map +1 -1
  22. package/lib/module/components/CarouselContainer.js +18 -23
  23. package/lib/module/components/CarouselContainer.js.map +1 -1
  24. package/lib/module/components/PageItem.js +53 -37
  25. package/lib/module/components/PageItem.js.map +1 -1
  26. package/lib/module/components/PaginationIndicator.js +23 -24
  27. package/lib/module/components/PaginationIndicator.js.map +1 -1
  28. package/lib/module/components/useCarouselContext.js +2 -2
  29. package/lib/module/components/useCarouselContext.js.map +1 -1
  30. package/lib/module/components/useInternalCarouselContext.js +2 -0
  31. package/lib/module/components/useInternalCarouselContext.js.map +1 -1
  32. package/lib/module/index.js +2 -0
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/types.js +2 -0
  35. package/lib/module/types.js.map +1 -1
  36. package/lib/module/utils.js +22 -12
  37. package/lib/module/utils.js.map +1 -1
  38. package/lib/typescript/components/Carousel.d.ts +3 -1
  39. package/lib/typescript/components/Carousel.d.ts.map +1 -0
  40. package/lib/typescript/components/CarouselContainer.d.ts +3 -2
  41. package/lib/typescript/components/CarouselContainer.d.ts.map +1 -0
  42. package/lib/typescript/components/PageItem.d.ts +5 -3
  43. package/lib/typescript/components/PageItem.d.ts.map +1 -0
  44. package/lib/typescript/components/PaginationIndicator.d.ts +3 -1
  45. package/lib/typescript/components/PaginationIndicator.d.ts.map +1 -0
  46. package/lib/typescript/components/useCarouselContext.d.ts +1 -1
  47. package/lib/typescript/components/useCarouselContext.d.ts.map +1 -0
  48. package/lib/typescript/components/useInternalCarouselContext.d.ts +1 -1
  49. package/lib/typescript/components/useInternalCarouselContext.d.ts.map +1 -0
  50. package/lib/typescript/index.d.ts +1 -0
  51. package/lib/typescript/index.d.ts.map +1 -0
  52. package/lib/typescript/types.d.ts +17 -15
  53. package/lib/typescript/types.d.ts.map +1 -0
  54. package/lib/typescript/utils.d.ts +4 -1
  55. package/lib/typescript/utils.d.ts.map +1 -0
  56. package/package.json +26 -29
  57. package/src/components/Carousel.tsx +404 -206
  58. package/src/components/CarouselContainer.tsx +4 -4
  59. package/src/components/PageItem.tsx +55 -33
  60. package/src/components/PaginationIndicator.tsx +2 -2
  61. package/src/components/useCarouselContext.ts +0 -1
  62. package/src/components/useInternalCarouselContext.ts +0 -1
  63. package/src/types.ts +9 -7
  64. package/src/utils.ts +31 -11
@@ -1,25 +1,59 @@
1
- /* eslint-disable @typescript-eslint/ban-ts-comment */
2
1
  import React, {
3
2
  useState,
4
- useRef,
5
3
  useEffect,
6
4
  useCallback,
7
5
  useMemo,
6
+ useRef,
8
7
  } from 'react';
9
- import { View, Dimensions, StyleSheet, Platform } from 'react-native';
8
+ import { Dimensions, StyleSheet, Platform, AppState } from 'react-native';
10
9
  import { useInterval } from '@r0b0t3d/react-native-hooks';
11
10
  import Animated, {
12
11
  useSharedValue,
13
12
  useAnimatedScrollHandler,
14
- runOnJS,
15
13
  useAnimatedRef,
14
+ scrollTo,
16
15
  } from 'react-native-reanimated';
16
+ import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets';
17
17
  import type { CarouselProps } from '../types';
18
18
  import PageItem from './PageItem';
19
- import { findNearestPage, generateOffsets } from '../utils';
19
+ import {
20
+ findNearestPage,
21
+ generateOffsets,
22
+ getLogicalPage,
23
+ getLoopBoundaryTarget,
24
+ } from '../utils';
20
25
  import { useCarouselContext } from './useCarouselContext';
21
26
  import { useInternalCarouselContext } from './useInternalCarouselContext';
22
27
 
28
+ // ---------------------------------------------------------------------------
29
+ // Carousel state machine
30
+ // ---------------------------------------------------------------------------
31
+ // Transitions:
32
+ //
33
+ // IDLE ── onBeginDrag ──► DRAGGING
34
+ // DRAGGING ── onMomentumEnd ──► IDLE (or LOOP_JUMP at boundary)
35
+ // LOOP_JUMP ── onScroll (arrival) ──► IDLE
36
+ // LOOP_JUMP ── onBeginDrag (user overrides) ──► DRAGGING
37
+ //
38
+ // Page change (onPageChange / animatedPage) only fires at IDLE transitions.
39
+ // Autoplay only advances when state === IDLE.
40
+ // ---------------------------------------------------------------------------
41
+
42
+ enum CarouselState {
43
+ IDLE = 0,
44
+ DRAGGING = 1,
45
+ LOOP_JUMP = 2,
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Terminology
50
+ // ---------------------------------------------------------------------------
51
+ // render index – position in the padded pageItems array (includes loop
52
+ // clones at head/tail). Used for scroll offsets.
53
+ // logical index – position in the original data array (0 .. data.length-1).
54
+ // All consumer-facing callbacks receive logical indices.
55
+ // ---------------------------------------------------------------------------
56
+
23
57
  const getScreenWidth = () => {
24
58
  return Dimensions.get('screen').width;
25
59
  };
@@ -44,27 +78,37 @@ function Carousel<TData>({
44
78
  onPageChange,
45
79
  scrollViewProps = {},
46
80
  keyExtractor,
47
- onItemPress
81
+ onItemPress,
82
+ disableItemPress = false,
83
+ scrollViewRef: externalScrollViewRef,
48
84
  }: CarouselProps<TData>) {
49
- const currentPage = useSharedValue(0);
85
+ // ---- shared values (UI-thread state) ------------------------------------
86
+
87
+ const currentRenderPage = useSharedValue(0);
88
+ const currentLogicalPage = useSharedValue(0);
50
89
  const animatedScroll = useSharedValue(0);
90
+
91
+ const carouselState = useSharedValue(CarouselState.IDLE);
92
+ // Destination render page for a loop jump. Used to detect arrival via
93
+ // onScroll and release the jump lock.
94
+ const jumpDestination = useSharedValue(-1);
95
+ // Suppresses parallax/opacity animations during instant scrolls on
96
+ // Android where the scroll isn't truly synchronous.
51
97
  const freeze = useSharedValue(false);
52
- const [isDragging, setDragging] = useState(false);
53
- const expectedPosition = useSharedValue(-1);
54
- const pageMapper = useRef<Record<number, number>>({});
98
+ // When false, autoplay is suppressed (app backgrounded). Plain ref —
99
+ // only read on JS thread by the autoplay interval.
100
+ const appActiveRef = useRef(true);
101
+
102
+ const internalAnimatedRef = useAnimatedRef<Animated.ScrollView>();
55
103
  const { currentPage: animatedPage, totalPage } = useCarouselContext();
56
104
 
57
- useEffect(() => {
58
- animatedScroll.value = currentPage.value * sliderWidth;
59
- }, []);
105
+ // ---- derived values -----------------------------------------------------
60
106
 
61
107
  const horizontalPadding = useMemo(() => {
62
108
  const padding = (sliderWidth - itemWidth) / 2;
63
109
  return firstItemAlignment === 'center' || loop ? padding : spaceHeadTail;
64
110
  }, [sliderWidth, itemWidth, firstItemAlignment, loop, spaceHeadTail]);
65
111
 
66
- const scrollViewRef = useAnimatedRef<any>();
67
-
68
112
  const offsets = useMemo(() => {
69
113
  return generateOffsets({
70
114
  sliderWidth,
@@ -72,97 +116,148 @@ function Carousel<TData>({
72
116
  itemCount: data.length + (loop ? additionalPagesPerSide * 2 : 0),
73
117
  horizontalPadding,
74
118
  });
75
- }, [sliderWidth, itemWidth, data, horizontalPadding]);
119
+ }, [sliderWidth, itemWidth, data.length, loop, additionalPagesPerSide, horizontalPadding]);
76
120
 
77
121
  const pageItems = useMemo(() => {
78
- if (!data) {
122
+ if (!data || data.length === 0) {
79
123
  return [];
80
124
  }
81
- totalPage.value = data.length;
82
125
  if (loop) {
83
126
  const headItems = data.slice(
84
127
  data.length - additionalPagesPerSide,
85
128
  data.length
86
129
  );
87
130
  const tailItems = data.slice(0, additionalPagesPerSide);
88
- const newItems = [...headItems, ...data, ...tailItems];
89
- for (let i = 0; i < newItems.length; i++) {
90
- pageMapper.current[i] =
91
- (data.length - additionalPagesPerSide + i) % data.length;
92
- }
93
- return newItems;
94
- } else {
95
- for (let i = 0; i < data.length; i++) {
96
- pageMapper.current[i] = i;
97
- }
98
- return data;
131
+ return [...headItems, ...data, ...tailItems];
99
132
  }
100
- }, [data, loop]);
133
+ return data;
134
+ }, [data, loop, additionalPagesPerSide]);
101
135
 
102
- const getActualPage = useCallback((page: number) => {
103
- return pageMapper.current[page];
104
- }, []);
136
+ // ---- page-change callback (JS thread) -----------------------------------
137
+
138
+ const handlePageChange = useCallback(
139
+ (logicalPage: number) => {
140
+ animatedPage.value = logicalPage;
141
+ if (onPageChange) {
142
+ onPageChange(logicalPage);
143
+ }
144
+ },
145
+ [onPageChange, animatedPage]
146
+ );
147
+
148
+ // ---- side effects -------------------------------------------------------
149
+
150
+ useEffect(() => {
151
+ totalPage.value = data.length;
152
+ }, [data.length, totalPage]);
153
+
154
+ // When the carousel layout or data changes (orientation, split-screen,
155
+ // toggling loop, data shrinking), reposition so the current logical
156
+ // page stays visible at its new render index and scroll offset.
157
+ useEffect(() => {
158
+ if (data.length === 0 || offsets.length === 0) return;
159
+
160
+ const prevLogical = currentLogicalPage.value;
161
+ const clampedLogical = Math.min(currentLogicalPage.value, data.length - 1);
162
+ const renderIdx = loop
163
+ ? clampedLogical + additionalPagesPerSide
164
+ : clampedLogical;
165
+ const safeRenderIdx = Math.max(0, Math.min(renderIdx, offsets.length - 1));
166
+ const safeLogicalIdx = getLogicalPage(
167
+ safeRenderIdx,
168
+ data.length,
169
+ additionalPagesPerSide,
170
+ loop
171
+ );
172
+
173
+ currentRenderPage.value = safeRenderIdx;
174
+ currentLogicalPage.value = safeLogicalIdx;
175
+ animatedPage.value = safeLogicalIdx;
176
+
177
+ const scrollOffset = renderOffset(safeRenderIdx);
178
+ animatedScroll.value = scrollOffset;
105
179
 
106
- const getRef = useCallback(() => {
107
- if (!scrollViewRef.current) return;
108
- if (scrollViewRef.current.scrollTo) {
109
- return scrollViewRef.current;
180
+ scheduleOnUI((_idx: number, offsetVal: number) => {
181
+ 'worklet';
182
+ scrollTo(internalAnimatedRef, offsetVal, 0, false);
183
+ }, safeRenderIdx, scrollOffset);
184
+
185
+ if (safeLogicalIdx !== prevLogical) {
186
+ handlePageChange(safeLogicalIdx);
110
187
  }
111
- return scrollViewRef.current.getNode();
188
+ }, [data.length, sliderWidth, itemWidth, loop, additionalPagesPerSide, offsets.length, handlePageChange]);
189
+
190
+ // ---- app-state listener (pause autoplay when backgrounded) --------------
191
+
192
+ useEffect(() => {
193
+ const sub = AppState.addEventListener('change', (state) => {
194
+ appActiveRef.current = state === 'active';
195
+ });
196
+ return () => sub.remove();
112
197
  }, []);
113
198
 
114
- const handleScrollTo = useCallback(
115
- (page: number, animated = true) => {
116
- if (getRef()) {
117
- getRef().scrollTo({ x: offsets[page], y: 0, animated });
118
- }
199
+ // ---- scroll helpers -----------------------------------------------------
200
+
201
+ // Safe accessor: returns offset for a render index, clamping to valid
202
+ // range and falling back to 0 if offsets is empty.
203
+ const renderOffset = useCallback(
204
+ (renderIdx: number): number => {
205
+ if (offsets.length === 0) return 0;
206
+ const clamped = Math.max(0, Math.min(renderIdx, offsets.length - 1));
207
+ return offsets[clamped];
119
208
  },
120
- [getRef, offsets]
209
+ [offsets]
121
210
  );
122
211
 
123
- const jumpTo = useCallback(
124
- (page: number, delay = 200) => {
125
- expectedPosition.value = page;
126
- if (Platform.OS === 'android') {
127
- freeze.value = true;
128
- }
129
- setTimeout(() => {
130
- animatedScroll.value = offsets[page];
131
- handleScrollTo(page, false);
132
- }, delay);
212
+ const handleScrollTo = useCallback(
213
+ (page: number, animated = true) => {
214
+ const offset = renderOffset(page);
215
+ scheduleOnUI((offsetVal: number, isAnimated: boolean) => {
216
+ 'worklet';
217
+ scrollTo(internalAnimatedRef, offsetVal, 0, isAnimated);
218
+ }, offset, animated);
133
219
  },
134
- [handleScrollTo, animatedScroll, freeze]
220
+ [renderOffset, internalAnimatedRef]
135
221
  );
136
222
 
223
+ // ---- navigation ---------------------------------------------------------
224
+
137
225
  const goNext = useCallback(() => {
138
- const next = currentPage.value + 1;
139
- handleScrollTo(next);
140
- }, [handleScrollTo]);
226
+ if (carouselState.value !== CarouselState.IDLE || data.length === 0) return;
227
+ const next = currentRenderPage.value + 1;
228
+ if (next < offsets.length) {
229
+ currentRenderPage.value = next;
230
+ handleScrollTo(next);
231
+ }
232
+ }, [handleScrollTo, offsets.length, data.length]);
141
233
 
142
234
  const goPrev = useCallback(() => {
143
- const prev = currentPage.value - 1;
144
- handleScrollTo(prev);
235
+ if (carouselState.value !== CarouselState.IDLE || data.length === 0) return;
236
+ const prev = currentRenderPage.value - 1;
237
+ if (prev >= 0) {
238
+ currentRenderPage.value = prev;
239
+ handleScrollTo(prev);
240
+ }
145
241
  }, [handleScrollTo]);
146
242
 
147
243
  const snapToItem = useCallback(
148
244
  (index: number, animated = true) => {
245
+ if (data.length === 0) return;
149
246
  if (index < 0 || index >= data.length) {
150
247
  console.error(`Index not valid ${index}`);
151
248
  return;
152
249
  }
153
- let pageIndex = index;
154
- if (loop) {
155
- const indices: number[] = Object.keys(pageMapper.current)
156
- .filter((idx) => pageMapper.current[Number(idx)] === index)
157
- .map((idx) => Number(idx));
158
- const toIndex = findNearestPage(currentPage.value, indices, 10);
159
- pageIndex = indices[toIndex];
160
- }
161
- handleScrollTo(pageIndex, animated);
250
+ const renderIndex = loop ? index + additionalPagesPerSide : index;
251
+ const clamped = Math.max(0, Math.min(renderIndex, offsets.length - 1));
252
+ currentRenderPage.value = clamped;
253
+ currentLogicalPage.value = index;
254
+ handleScrollTo(clamped, animated);
162
255
  },
163
- [handleScrollTo]
256
+ [loop, additionalPagesPerSide, offsets.length, handleScrollTo, data.length]
164
257
  );
165
258
 
259
+ // ---- context wiring -----------------------------------------------------
260
+
166
261
  const { setCarouselHandlers } = useInternalCarouselContext();
167
262
 
168
263
  useEffect(() => {
@@ -175,76 +270,157 @@ function Carousel<TData>({
175
270
  }
176
271
  }, [goNext, goPrev, snapToItem, setCarouselHandlers]);
177
272
 
178
- const handlePageChange = useCallback(
179
- (page: number) => {
180
- const actualPage = getActualPage(page);
181
- animatedPage.value = actualPage;
182
- if (onPageChange) {
183
- onPageChange(actualPage);
184
- }
185
- if (!loop) return;
186
- if (page === pageItems.length - 1) {
187
- jumpTo(additionalPagesPerSide * 2 - 1);
188
- } else if (page === 0) {
189
- jumpTo(pageItems.length - additionalPagesPerSide * 2);
190
- }
191
- },
192
- [onPageChange, loop, getActualPage, jumpTo, pageItems]
193
- );
273
+ // ---- autoplay -----------------------------------------------------------
194
274
 
195
- const refreshPage = useCallback(
196
- (offset) => {
197
- 'worklet';
198
- const pageNum = findNearestPage(offset, offsets, 20);
199
- if (pageNum === -1) {
200
- return;
201
- }
202
- if (pageNum !== currentPage.value) {
203
- if (expectedPosition.value === pageNum) {
204
- freeze.value = false;
205
- }
206
- currentPage.value = pageNum;
207
- runOnJS(handlePageChange)(pageNum);
208
- }
209
- },
210
- [isDragging, offsets, handlePageChange]
211
- );
275
+ // isDragging acts as a debounce — autoplay resumes 200ms after the
276
+ // user lifts their finger, preventing an immediate auto-advance.
277
+ const [autoplayPaused, setAutoplayPaused] = useState(false);
212
278
 
213
279
  useInterval(
214
280
  () => {
215
- goNext();
281
+ if (
282
+ carouselState.value === CarouselState.IDLE &&
283
+ appActiveRef.current
284
+ ) {
285
+ goNext();
286
+ }
216
287
  },
217
- !autoPlay || !loop || isDragging ? -1 : duration
288
+ !autoPlay || !loop || autoplayPaused ? -1 : duration
218
289
  );
219
290
 
291
+ // ---- initial page setup -------------------------------------------------
292
+
293
+ const didInitialMount = useRef(false);
294
+
220
295
  useEffect(() => {
296
+ if (didInitialMount.current) return;
297
+ didInitialMount.current = true;
298
+
299
+ if (data.length === 0) return;
221
300
  if (initialPage < 0 || initialPage >= data.length) {
222
301
  console.error(`Invalid initialPage ${initialPage}`);
223
302
  return;
224
303
  }
225
- let pageIndex = initialPage;
226
- if (loop) {
227
- pageIndex = initialPage + additionalPagesPerSide;
228
- }
229
- if (currentPage.value !== pageIndex) {
230
- setTimeout(() => {
231
- handleScrollTo(pageIndex, false);
232
- freeze.value = false;
233
- });
234
- }
235
- }, []);
304
+ const renderIndex = loop ? initialPage + additionalPagesPerSide : initialPage;
305
+ const safeIdx = Math.max(0, Math.min(renderIndex, offsets.length - 1));
306
+ currentRenderPage.value = safeIdx;
307
+ currentLogicalPage.value = initialPage;
308
+ animatedPage.value = initialPage;
236
309
 
237
- const beginDrag = useCallback(() => {
238
- if (autoPlay) {
239
- setDragging(true);
240
- }
241
- }, [autoPlay]);
310
+ scheduleOnUI((idx: number) => {
311
+ 'worklet';
312
+ scrollTo(internalAnimatedRef, offsets[idx], 0, false);
313
+ }, safeIdx);
314
+ animatedScroll.value = offsets[safeIdx] ?? 0;
315
+ }, [data.length, initialPage, loop, additionalPagesPerSide, offsets]);
242
316
 
243
- const endDrag = useCallback(() => {
244
- if (autoPlay) {
245
- setTimeout(() => setDragging(false), 200);
246
- }
247
- }, [autoPlay]);
317
+ // ---- scroll handler (UI thread worklet) ---------------------------------
318
+
319
+ const scrollHandler = useAnimatedScrollHandler(
320
+ {
321
+ onScroll: (event) => {
322
+ animatedScroll.value = event.contentOffset.x;
323
+
324
+ // During a loop jump, detect when the scroll arrives at the
325
+ // destination and transition back to IDLE.
326
+ if (
327
+ carouselState.value === CarouselState.LOOP_JUMP &&
328
+ jumpDestination.value !== -1
329
+ ) {
330
+ const nearest = findNearestPage(event.contentOffset.x, offsets);
331
+ if (nearest === jumpDestination.value) {
332
+ freeze.value = false;
333
+ carouselState.value = CarouselState.IDLE;
334
+ jumpDestination.value = -1;
335
+ }
336
+ }
337
+ },
338
+
339
+ onBeginDrag: () => {
340
+ // User gesture always wins. Cancel any in-flight loop jump
341
+ // so the carousel doesn't fight the user's drag.
342
+ if (carouselState.value === CarouselState.LOOP_JUMP) {
343
+ jumpDestination.value = -1;
344
+ freeze.value = false;
345
+ }
346
+ carouselState.value = CarouselState.DRAGGING;
347
+ scheduleOnRN(setAutoplayPaused, true);
348
+ },
349
+
350
+ onEndDrag: () => {
351
+ // Autoplay resumes in onMomentumEnd after momentum settles.
352
+ },
353
+
354
+ onMomentumEnd: (event) => {
355
+ // Only process page changes when dragging ended naturally.
356
+ // Loop jumps are handled in onScroll.
357
+ if (carouselState.value !== CarouselState.DRAGGING) {
358
+ return;
359
+ }
360
+
361
+ const offset = event.contentOffset.x;
362
+ if (isNaN(offset) || offsets.length === 0) {
363
+ carouselState.value = CarouselState.IDLE;
364
+ scheduleOnRN(setAutoplayPaused, false);
365
+ return;
366
+ }
367
+
368
+ const renderPage = findNearestPage(offset, offsets);
369
+
370
+ // Loop boundary — user scrolled into the cloned head/tail pages.
371
+ if (loop) {
372
+ const boundaryTarget = getLoopBoundaryTarget(
373
+ renderPage,
374
+ data.length,
375
+ additionalPagesPerSide
376
+ );
377
+ if (boundaryTarget !== -1) {
378
+ // Jump instantly to the matching real page.
379
+ // On Android, freeze animations during the jump to prevent
380
+ // a visual flash from the clones.
381
+ carouselState.value = CarouselState.LOOP_JUMP;
382
+ jumpDestination.value = boundaryTarget;
383
+ freeze.value = Platform.OS === 'android';
384
+ animatedScroll.value = offsets[boundaryTarget];
385
+ scrollTo(internalAnimatedRef, offsets[boundaryTarget], 0, false);
386
+
387
+ currentRenderPage.value = boundaryTarget;
388
+ currentLogicalPage.value = getLogicalPage(
389
+ boundaryTarget,
390
+ data.length,
391
+ additionalPagesPerSide,
392
+ loop
393
+ );
394
+ scheduleOnRN(handlePageChange, currentLogicalPage.value);
395
+ return;
396
+ }
397
+ }
398
+
399
+ // Normal page change — no loop boundary involved.
400
+ carouselState.value = CarouselState.IDLE;
401
+ currentRenderPage.value = renderPage;
402
+ currentLogicalPage.value = getLogicalPage(
403
+ renderPage,
404
+ data.length,
405
+ additionalPagesPerSide,
406
+ loop
407
+ );
408
+ scheduleOnRN(handlePageChange, currentLogicalPage.value);
409
+ // Autoplay only resumes once momentum fully settles.
410
+ scheduleOnRN(setAutoplayPaused, false);
411
+ },
412
+ },
413
+ [
414
+ offsets,
415
+ loop,
416
+ data.length,
417
+ additionalPagesPerSide,
418
+ handlePageChange,
419
+ internalAnimatedRef,
420
+ ]
421
+ );
422
+
423
+ // ---- render helpers -----------------------------------------------------
248
424
 
249
425
  const getItemKey = useCallback(
250
426
  (item: TData, index: number): string => {
@@ -254,99 +430,121 @@ function Carousel<TData>({
254
430
  if ((item as any).id) {
255
431
  return `${(item as any).id}-${index}`;
256
432
  }
257
- console.error('You need implement keyExtractor');
258
- return '';
433
+ return `carousel-item-${index}`;
259
434
  },
260
435
  [keyExtractor]
261
436
  );
262
437
 
263
- const scrollHandler = useAnimatedScrollHandler(
264
- {
265
- onScroll: (event) => {
266
- animatedScroll.value = event.contentOffset.x;
267
- refreshPage(animatedScroll.value);
268
- },
269
- onBeginDrag: (_e) => {
270
- runOnJS(beginDrag)();
271
- },
272
- onEndDrag: (_e) => {
273
- runOnJS(endDrag)();
274
- },
275
- },
276
- [beginDrag, endDrag, refreshPage]
277
- );
278
-
279
- const containerStyle = useCallback(
280
- (index: number) => {
281
- if (firstItemAlignment === 'start') {
438
+ const containerStyles = useMemo(() => {
439
+ if (data.length === 0) return [];
440
+ return pageItems.map((_, i) => {
441
+ if (firstItemAlignment !== 'start') {
282
442
  return {
283
- paddingLeft: index === 0 ? 0 : spaceBetween / 2,
284
- paddingRight: index === data.length - 1 ? 0 : spaceBetween / 2,
285
- };
443
+ paddingLeft: spaceBetween / 2,
444
+ paddingRight: spaceBetween / 2,
445
+ } as const;
286
446
  }
447
+ const logicalIndex = loop
448
+ ? getLogicalPage(i, data.length, additionalPagesPerSide, loop)
449
+ : i;
287
450
  return {
288
- paddingLeft: spaceBetween / 2,
289
- paddingRight: spaceBetween / 2,
451
+ paddingLeft: logicalIndex === 0 ? 0 : spaceBetween / 2,
452
+ paddingRight:
453
+ logicalIndex === data.length - 1 ? 0 : spaceBetween / 2,
454
+ } as const;
455
+ });
456
+ }, [pageItems, firstItemAlignment, spaceBetween, data.length, loop, additionalPagesPerSide]);
457
+
458
+ const itemPressHandlers = useMemo(() => {
459
+ if (disableItemPress) return [];
460
+ return pageItems.map((item, i) => {
461
+ const logicalIndex = loop
462
+ ? getLogicalPage(i, data.length, additionalPagesPerSide, loop)
463
+ : i;
464
+ return () => {
465
+ handleScrollTo(i);
466
+ if (onItemPress) {
467
+ onItemPress(item, logicalIndex);
468
+ }
290
469
  };
470
+ });
471
+ }, [pageItems, disableItemPress, loop, data.length, additionalPagesPerSide, handleScrollTo, onItemPress]);
472
+
473
+ const contentContainerStyle = useMemo(
474
+ () => ({ paddingHorizontal: horizontalPadding }),
475
+ [horizontalPadding]
476
+ );
477
+
478
+ const renderPage = useCallback(
479
+ (item: TData, i: number) => {
480
+ // Compute the logical index so consumer callbacks (renderItem,
481
+ // onItemPress via `index` prop) receive logical, not render.
482
+ const logicalIndex = loop
483
+ ? getLogicalPage(i, data.length, additionalPagesPerSide, loop)
484
+ : i;
485
+ return (
486
+ <PageItem
487
+ key={getItemKey(item, i)}
488
+ containerStyle={containerStyles[i]}
489
+ item={item}
490
+ index={logicalIndex}
491
+ offset={offsets[i]}
492
+ itemWidth={itemWidth}
493
+ animatedValue={animatedScroll}
494
+ animation={animation}
495
+ renderItem={renderItem}
496
+ freeze={freeze}
497
+ inactiveOpacity={inactiveOpacity}
498
+ inactiveScale={inactiveScale}
499
+ onPress={itemPressHandlers[i]}
500
+ />
501
+ );
291
502
  },
292
- [spaceBetween, firstItemAlignment]
503
+ [
504
+ loop,
505
+ data.length,
506
+ additionalPagesPerSide,
507
+ getItemKey,
508
+ containerStyles,
509
+ offsets,
510
+ itemWidth,
511
+ animatedScroll,
512
+ animation,
513
+ renderItem,
514
+ freeze,
515
+ inactiveOpacity,
516
+ inactiveScale,
517
+ itemPressHandlers,
518
+ ]
293
519
  );
294
520
 
295
- const handleItemPress = useCallback((item: TData, index: number) => () => {
296
- handleScrollTo(index);
297
- if (onItemPress) {
298
- onItemPress(item, index);
299
- }
300
- }, [onItemPress, handlePageChange]);
301
-
302
- const contentContainerStyle = useMemo(() => {
303
- return {
304
- paddingHorizontal: horizontalPadding,
305
- };
306
- }, [horizontalPadding]);
307
-
308
- function renderPage(item: TData, i: number) {
309
- return (
310
- <PageItem
311
- key={getItemKey(item, i)}
312
- containerStyle={containerStyle(i)}
313
- item={item}
314
- index={i}
315
- offset={offsets[i]}
316
- itemWidth={itemWidth}
317
- animatedValue={animatedScroll}
318
- animation={animation}
319
- renderItem={renderItem}
320
- freeze={freeze}
321
- inactiveOpacity={inactiveOpacity}
322
- inactiveScale={inactiveScale}
323
- onPress={handleItemPress(item, i)}
324
- />
325
- );
326
- }
521
+ // ---- render -------------------------------------------------------------
327
522
 
328
523
  return (
329
- <View style={style}>
330
- <Animated.ScrollView
331
- {...scrollViewProps}
332
- ref={scrollViewRef}
333
- style={styles.container}
334
- horizontal
335
- disableScrollViewPanResponder
336
- disableIntervalMomentum
337
- showsHorizontalScrollIndicator={false}
338
- snapToOffsets={offsets}
339
- snapToStart
340
- snapToEnd
341
- decelerationRate="fast"
342
- scrollEventThrottle={4}
343
- onScroll={scrollHandler}
344
- bounces={false}
345
- contentContainerStyle={contentContainerStyle}
346
- >
347
- {pageItems.map(renderPage)}
348
- </Animated.ScrollView>
349
- </View>
524
+ <Animated.ScrollView
525
+ {...scrollViewProps}
526
+ ref={(nativeRef: any) => {
527
+ internalAnimatedRef(nativeRef);
528
+ if (externalScrollViewRef) {
529
+ externalScrollViewRef.current = nativeRef;
530
+ }
531
+ }}
532
+ style={[styles.container, style]}
533
+ horizontal
534
+ disableScrollViewPanResponder
535
+ disableIntervalMomentum
536
+ showsHorizontalScrollIndicator={false}
537
+ snapToOffsets={offsets}
538
+ snapToStart
539
+ snapToEnd
540
+ decelerationRate="fast"
541
+ scrollEventThrottle={4}
542
+ onScroll={scrollHandler}
543
+ bounces={false}
544
+ contentContainerStyle={contentContainerStyle}
545
+ >
546
+ {pageItems.map(renderPage)}
547
+ </Animated.ScrollView>
350
548
  );
351
549
  }
352
550