@r0b0t3d/react-native-carousel 3.4.6 → 3.4.8

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