@r0b0t3d/react-native-carousel 3.4.5 → 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.
- 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 +53 -46
- 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 +54 -38
- 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 +404 -206
- package/src/components/CarouselContainer.tsx +4 -4
- package/src/components/PageItem.tsx +55 -34
- 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,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 {
|
|
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
|
+
// 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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
return data;
|
|
134
|
+
}, [data, loop, additionalPagesPerSide]);
|
|
101
135
|
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
[
|
|
209
|
+
[offsets]
|
|
121
210
|
);
|
|
122
211
|
|
|
123
|
-
const
|
|
124
|
-
(page: number,
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
[
|
|
220
|
+
[renderOffset, internalAnimatedRef]
|
|
135
221
|
);
|
|
136
222
|
|
|
223
|
+
// ---- navigation ---------------------------------------------------------
|
|
224
|
+
|
|
137
225
|
const goNext = useCallback(() => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
);
|
|
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
|
-
|
|
281
|
+
if (
|
|
282
|
+
carouselState.value === CarouselState.IDLE &&
|
|
283
|
+
appActiveRef.current
|
|
284
|
+
) {
|
|
285
|
+
goNext();
|
|
286
|
+
}
|
|
216
287
|
},
|
|
217
|
-
!autoPlay || !loop ||
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
258
|
-
return '';
|
|
433
|
+
return `carousel-item-${index}`;
|
|
259
434
|
},
|
|
260
435
|
[keyExtractor]
|
|
261
436
|
);
|
|
262
437
|
|
|
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') {
|
|
438
|
+
const containerStyles = useMemo(() => {
|
|
439
|
+
if (data.length === 0) return [];
|
|
440
|
+
return pageItems.map((_, i) => {
|
|
441
|
+
if (firstItemAlignment !== 'start') {
|
|
282
442
|
return {
|
|
283
|
-
paddingLeft:
|
|
284
|
-
paddingRight:
|
|
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:
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
<
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|