@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.
- package/lib/commonjs/components/Carousel.js +290 -195
- package/lib/commonjs/components/Carousel.js.map +1 -1
- package/lib/commonjs/components/CarouselContainer.js +17 -31
- package/lib/commonjs/components/CarouselContainer.js.map +1 -1
- package/lib/commonjs/components/PageItem.js +52 -45
- package/lib/commonjs/components/PageItem.js.map +1 -1
- package/lib/commonjs/components/PaginationIndicator.js +23 -34
- package/lib/commonjs/components/PaginationIndicator.js.map +1 -1
- package/lib/commonjs/components/useCarouselContext.js +3 -8
- package/lib/commonjs/components/useCarouselContext.js.map +1 -1
- package/lib/commonjs/components/useInternalCarouselContext.js +3 -6
- package/lib/commonjs/components/useInternalCarouselContext.js.map +1 -1
- package/lib/commonjs/index.js +7 -17
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/types.js +4 -0
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/utils.js +24 -18
- package/lib/commonjs/utils.js.map +1 -1
- package/lib/module/components/Carousel.js +291 -180
- package/lib/module/components/Carousel.js.map +1 -1
- package/lib/module/components/CarouselContainer.js +18 -23
- package/lib/module/components/CarouselContainer.js.map +1 -1
- package/lib/module/components/PageItem.js +53 -37
- package/lib/module/components/PageItem.js.map +1 -1
- package/lib/module/components/PaginationIndicator.js +23 -24
- package/lib/module/components/PaginationIndicator.js.map +1 -1
- package/lib/module/components/useCarouselContext.js +2 -2
- package/lib/module/components/useCarouselContext.js.map +1 -1
- package/lib/module/components/useInternalCarouselContext.js +2 -0
- package/lib/module/components/useInternalCarouselContext.js.map +1 -1
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -1
- package/lib/module/utils.js +22 -12
- package/lib/module/utils.js.map +1 -1
- package/lib/typescript/components/Carousel.d.ts +3 -1
- package/lib/typescript/components/Carousel.d.ts.map +1 -0
- package/lib/typescript/components/CarouselContainer.d.ts +3 -2
- package/lib/typescript/components/CarouselContainer.d.ts.map +1 -0
- package/lib/typescript/components/PageItem.d.ts +5 -3
- package/lib/typescript/components/PageItem.d.ts.map +1 -0
- package/lib/typescript/components/PaginationIndicator.d.ts +3 -1
- package/lib/typescript/components/PaginationIndicator.d.ts.map +1 -0
- package/lib/typescript/components/useCarouselContext.d.ts +1 -1
- package/lib/typescript/components/useCarouselContext.d.ts.map +1 -0
- package/lib/typescript/components/useInternalCarouselContext.d.ts +1 -1
- package/lib/typescript/components/useInternalCarouselContext.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +1 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +17 -15
- package/lib/typescript/types.d.ts.map +1 -0
- package/lib/typescript/utils.d.ts +4 -1
- package/lib/typescript/utils.d.ts.map +1 -0
- package/package.json +26 -29
- package/src/components/Carousel.tsx +407 -206
- package/src/components/CarouselContainer.tsx +4 -4
- package/src/components/PageItem.tsx +55 -33
- package/src/components/PaginationIndicator.tsx +2 -2
- package/src/components/useCarouselContext.ts +0 -1
- package/src/components/useInternalCarouselContext.ts +0 -1
- package/src/types.ts +9 -7
- 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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
return data;
|
|
136
|
+
}, [data, loop, additionalPagesPerSide]);
|
|
101
137
|
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
[
|
|
211
|
+
[offsets]
|
|
121
212
|
);
|
|
122
213
|
|
|
123
|
-
const
|
|
124
|
-
(page: number,
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
[
|
|
222
|
+
[renderOffset, internalAnimatedRef]
|
|
135
223
|
);
|
|
136
224
|
|
|
225
|
+
// ---- navigation ---------------------------------------------------------
|
|
226
|
+
|
|
137
227
|
const goNext = useCallback(() => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
283
|
+
if (
|
|
284
|
+
carouselState.value === CarouselState.IDLE &&
|
|
285
|
+
appActiveRef.current
|
|
286
|
+
) {
|
|
287
|
+
goNext();
|
|
288
|
+
}
|
|
216
289
|
},
|
|
217
|
-
!autoPlay || !loop ||
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
258
|
-
return '';
|
|
436
|
+
return `carousel-item-${index}`;
|
|
259
437
|
},
|
|
260
438
|
[keyExtractor]
|
|
261
439
|
);
|
|
262
440
|
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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:
|
|
284
|
-
paddingRight:
|
|
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:
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
<
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|