@momo-kits/carousel 0.165.2-beta.1 → 0.165.2-beta.2

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 (40) hide show
  1. package/core/LICENSE +21 -0
  2. package/core/README.md +16 -0
  3. package/core/components/Carousel.tsx +28 -0
  4. package/core/components/CarouselLayout.tsx +231 -0
  5. package/core/components/ItemLayout.tsx +215 -0
  6. package/core/components/ItemRenderer.tsx +126 -0
  7. package/core/components/LazyView.tsx +14 -0
  8. package/core/components/ScrollViewGesture.tsx +443 -0
  9. package/core/constants/index.ts +11 -0
  10. package/core/hooks/useAutoPlay.ts +63 -0
  11. package/core/hooks/useCarouselController.tsx +295 -0
  12. package/core/hooks/useCheckMounted.ts +14 -0
  13. package/core/hooks/useCommonVariables.ts +261 -0
  14. package/core/hooks/useInitProps.ts +177 -0
  15. package/core/hooks/useLayoutConfig.ts +35 -0
  16. package/core/hooks/useOffsetX.ts +81 -0
  17. package/core/hooks/useOnProgressChange.ts +33 -0
  18. package/core/hooks/usePanGestureProxy.ts +144 -0
  19. package/core/hooks/usePropsErrorBoundary.ts +41 -0
  20. package/core/hooks/useUpdateGestureConfig.ts +17 -0
  21. package/core/hooks/useVisibleRanges.tsx +141 -0
  22. package/core/layouts/index.tsx +10 -0
  23. package/core/layouts/normal.ts +32 -0
  24. package/core/layouts/parallax.ts +50 -0
  25. package/core/layouts/stack.ts +266 -0
  26. package/core/public-types.ts +209 -0
  27. package/core/store/index.tsx +63 -0
  28. package/core/types.ts +32 -0
  29. package/core/utils/carousel-direction.ts +39 -0
  30. package/core/utils/carousel-math.ts +156 -0
  31. package/core/utils/compute-gesture-translation.ts +29 -0
  32. package/core/utils/compute-offset-if-size-changed.ts +14 -0
  33. package/core/utils/computed-with-auto-fill-data.ts +92 -0
  34. package/core/utils/deal-with-animation.ts +19 -0
  35. package/core/utils/log.ts +12 -0
  36. package/core/utils/sanitize-animation-style.ts +20 -0
  37. package/index.tsx +390 -455
  38. package/package.json +6 -5
  39. package/types.ts +123 -48
  40. package/animation.ts +0 -62
@@ -0,0 +1,295 @@
1
+ import React, { useRef } from "react";
2
+ import type { SharedValue } from "react-native-reanimated";
3
+ import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
4
+ import { scheduleOnRN } from "react-native-worklets";
5
+
6
+ import { useGlobalState } from "../store";
7
+ import type {
8
+ CarouselAnimation,
9
+ CarouselRef,
10
+ InternalCarouselScrollToOptions,
11
+ InternalCarouselStepOptions,
12
+ } from "../types";
13
+ import {
14
+ getLogicalProgress,
15
+ getNearestLogicalPage,
16
+ getSettledRawIndex,
17
+ getShortestLoopTargetPage,
18
+ positiveModulo,
19
+ } from "../utils/carousel-math";
20
+ import { dealWithAnimation } from "../utils/deal-with-animation";
21
+
22
+ interface CarouselControllerOptions {
23
+ ref: React.ForwardedRef<CarouselRef>;
24
+ loop: boolean;
25
+ size: number;
26
+ dataLength: number;
27
+ rawDataLength: number;
28
+ handlerOffset: SharedValue<number>;
29
+ animation: CarouselAnimation;
30
+ defaultIndex?: number | undefined;
31
+ onScrollStart?: (() => void) | undefined;
32
+ onMovementEnd?: ((index: number) => void) | undefined;
33
+ }
34
+
35
+ export interface CarouselController {
36
+ getSharedIndex: () => number;
37
+ prev: (options?: InternalCarouselStepOptions) => void;
38
+ next: (options?: InternalCarouselStepOptions) => void;
39
+ getCurrentIndex: () => number;
40
+ scrollTo: (options: InternalCarouselScrollToOptions) => void;
41
+ index: SharedValue<number>;
42
+ startMovement: () => void;
43
+ cancelMovement: () => void;
44
+ settle: () => number;
45
+ }
46
+
47
+ export function useCarouselController(options: CarouselControllerOptions): CarouselController {
48
+ const {
49
+ animation,
50
+ dataLength,
51
+ defaultIndex = 0,
52
+ handlerOffset,
53
+ loop,
54
+ rawDataLength,
55
+ ref,
56
+ size,
57
+ } = options;
58
+ const {
59
+ common: {
60
+ cancelMovement: cancelCommonMovement,
61
+ dataRevision,
62
+ isMoving,
63
+ resolvedSize,
64
+ settleMovement,
65
+ sizePhase,
66
+ startMovement: startCommonMovement,
67
+ },
68
+ } = useGlobalState();
69
+
70
+ const index = useSharedValue(defaultIndex);
71
+ const liveRawIndex = useRef(rawDataLength > 0 ? defaultIndex : 0);
72
+ const settledIndex = useRef(rawDataLength > 0 ? defaultIndex : 0);
73
+ const warnedCommands = useRef(new Set<string>());
74
+ const canMove = rawDataLength > 1;
75
+
76
+ const warnOnce = React.useCallback((key: string, message: string) => {
77
+ if (!__DEV__ || warnedCommands.current.has(key)) return;
78
+ warnedCommands.current.add(key);
79
+ console.warn(`[react-native-reanimated-carousel] ${message}`);
80
+ }, []);
81
+
82
+ const currentVisualPage = React.useCallback(() => {
83
+ if (size <= 0 || rawDataLength <= 0) return 0;
84
+
85
+ const page = getNearestLogicalPage(handlerOffset.value, size);
86
+ return loop ? page : Math.max(0, Math.min(rawDataLength - 1, page));
87
+ }, [handlerOffset, loop, rawDataLength, size]);
88
+
89
+ function setLiveRawIndex(nextIndex: number) {
90
+ liveRawIndex.current = nextIndex;
91
+ }
92
+
93
+ useAnimatedReaction(
94
+ () => {
95
+ if (size <= 0 || dataLength <= 0 || rawDataLength <= 0) {
96
+ return { physicalIndex: 0, rawIndex: 0 };
97
+ }
98
+
99
+ const page = getNearestLogicalPage(handlerOffset.value, size);
100
+ return {
101
+ physicalIndex: positiveModulo(page, dataLength),
102
+ rawIndex: positiveModulo(page, rawDataLength),
103
+ };
104
+ },
105
+ ({ physicalIndex, rawIndex }) => {
106
+ index.value = physicalIndex;
107
+ scheduleOnRN(setLiveRawIndex, rawIndex);
108
+ },
109
+ [dataLength, handlerOffset, index, rawDataLength, size]
110
+ );
111
+
112
+ const settle = React.useCallback(
113
+ (targetIndex?: number) => {
114
+ settleMovement();
115
+
116
+ const nextSettledIndex =
117
+ typeof targetIndex === "number" && rawDataLength > 0
118
+ ? positiveModulo(Math.round(targetIndex), rawDataLength)
119
+ : getSettledRawIndex(
120
+ loop
121
+ ? getLogicalProgress(handlerOffset.value, size)
122
+ : Math.max(
123
+ 0,
124
+ Math.min(rawDataLength - 1, getLogicalProgress(handlerOffset.value, size))
125
+ ),
126
+ rawDataLength
127
+ );
128
+
129
+ settledIndex.current = nextSettledIndex;
130
+ return nextSettledIndex;
131
+ },
132
+ [handlerOffset, loop, rawDataLength, settleMovement, size]
133
+ );
134
+
135
+ const getCurrentIndex = React.useCallback(() => settledIndex.current, []);
136
+ const getSharedIndex = React.useCallback(() => liveRawIndex.current, []);
137
+ const startMovement = React.useCallback(() => startCommonMovement(), [startCommonMovement]);
138
+ const cancelMovement = React.useCallback(() => cancelCommonMovement(), [cancelCommonMovement]);
139
+
140
+ React.useEffect(() => {
141
+ if (!isMoving.value) settle();
142
+ }, [dataRevision, isMoving, rawDataLength, settle]);
143
+
144
+ const isReady = React.useCallback(() => {
145
+ const currentSize = resolvedSize.value ?? size;
146
+ return sizePhase.value === "ready" && currentSize > 0;
147
+ }, [resolvedSize, size, sizePhase]);
148
+
149
+ const emitStart = React.useCallback(() => {
150
+ startMovement();
151
+ options.onScrollStart?.();
152
+ }, [options, startMovement]);
153
+
154
+ const emitSettle = React.useCallback(
155
+ (targetIndex?: number) => {
156
+ const nextSettledIndex = settle(targetIndex);
157
+ options.onMovementEnd?.(nextSettledIndex);
158
+ },
159
+ [options, settle]
160
+ );
161
+
162
+ const animateTo = React.useCallback(
163
+ (targetOffset: number, targetIndex: number, onFinished?: () => void) => {
164
+ "worklet";
165
+ return dealWithAnimation(animation)(targetOffset, (finished) => {
166
+ "worklet";
167
+ if (finished) {
168
+ scheduleOnRN(emitSettle, targetIndex);
169
+ if (onFinished) scheduleOnRN(onFinished);
170
+ } else {
171
+ scheduleOnRN(cancelMovement);
172
+ }
173
+ });
174
+ },
175
+ [animation, cancelMovement, emitSettle]
176
+ );
177
+
178
+ const moveToPage = React.useCallback(
179
+ (targetPage: number, animated: boolean, onFinished?: () => void) => {
180
+ "worklet";
181
+ const targetRawIndex = positiveModulo(targetPage, rawDataLength);
182
+ emitStart();
183
+ index.value = positiveModulo(targetPage, Math.max(dataLength, 1));
184
+
185
+ const targetOffset = -targetPage * size;
186
+ if (animated) {
187
+ handlerOffset.value = animateTo(targetOffset, targetRawIndex, onFinished);
188
+ } else {
189
+ handlerOffset.value = targetOffset;
190
+ emitSettle(targetRawIndex);
191
+ onFinished?.();
192
+ }
193
+ },
194
+ [animateTo, dataLength, emitSettle, emitStart, handlerOffset, index, rawDataLength, size]
195
+ );
196
+
197
+ const validateCount = React.useCallback(
198
+ (command: "next" | "prev", count: number) => {
199
+ if (count === 0) return false;
200
+ if (Number.isInteger(count) && count > 0) return true;
201
+
202
+ warnOnce(
203
+ `${command}:count`,
204
+ `${command} count must be a positive integer; received ${String(count)}.`
205
+ );
206
+ return false;
207
+ },
208
+ [warnOnce]
209
+ );
210
+
211
+ const next = React.useCallback(
212
+ (stepOptions: InternalCarouselStepOptions = {}) => {
213
+ "worklet";
214
+ const { animated = true, count = 1, onFinished } = stepOptions;
215
+ if (!canMove || !isReady() || !validateCount("next", count)) return;
216
+
217
+ const currentPage = currentVisualPage();
218
+ const targetPage = loop
219
+ ? currentPage + count
220
+ : Math.min(rawDataLength - 1, currentPage + count);
221
+ if (targetPage === currentPage) return;
222
+
223
+ moveToPage(targetPage, animated, onFinished);
224
+ },
225
+ [canMove, currentVisualPage, isReady, loop, moveToPage, rawDataLength, validateCount]
226
+ );
227
+
228
+ const prev = React.useCallback(
229
+ (stepOptions: InternalCarouselStepOptions = {}) => {
230
+ "worklet";
231
+ const { animated = true, count = 1, onFinished } = stepOptions;
232
+ if (!canMove || !isReady() || !validateCount("prev", count)) return;
233
+
234
+ const currentPage = currentVisualPage();
235
+ const targetPage = loop ? currentPage - count : Math.max(0, currentPage - count);
236
+ if (targetPage === currentPage) return;
237
+
238
+ moveToPage(targetPage, animated, onFinished);
239
+ },
240
+ [canMove, currentVisualPage, isReady, loop, moveToPage, validateCount]
241
+ );
242
+
243
+ const scrollTo = React.useCallback(
244
+ (scrollOptions: InternalCarouselScrollToOptions) => {
245
+ "worklet";
246
+ const { animated = true, index: targetIndex, onFinished } = scrollOptions;
247
+ if (!canMove || !isReady()) return;
248
+
249
+ if (!Number.isInteger(targetIndex) || targetIndex < 0 || targetIndex >= rawDataLength) {
250
+ warnOnce(
251
+ "scrollTo:index",
252
+ `scrollTo index must be an integer between 0 and ${rawDataLength - 1}; received ${String(targetIndex)}.`
253
+ );
254
+ return;
255
+ }
256
+
257
+ const currentPage = currentVisualPage();
258
+ const currentRawIndex = positiveModulo(currentPage, rawDataLength);
259
+ if (currentRawIndex === targetIndex) return;
260
+
261
+ const targetPage = loop
262
+ ? getShortestLoopTargetPage({
263
+ currentPage,
264
+ targetIndex,
265
+ count: rawDataLength,
266
+ })
267
+ : targetIndex;
268
+ moveToPage(targetPage, animated, onFinished);
269
+ },
270
+ [canMove, currentVisualPage, isReady, loop, moveToPage, rawDataLength, warnOnce]
271
+ );
272
+
273
+ React.useImperativeHandle(
274
+ ref,
275
+ () => ({
276
+ next,
277
+ prev,
278
+ getCurrentIndex,
279
+ scrollTo,
280
+ }),
281
+ [getCurrentIndex, next, prev, scrollTo]
282
+ );
283
+
284
+ return {
285
+ next,
286
+ prev,
287
+ scrollTo,
288
+ getCurrentIndex,
289
+ getSharedIndex,
290
+ index,
291
+ startMovement,
292
+ cancelMovement,
293
+ settle,
294
+ };
295
+ }
@@ -0,0 +1,14 @@
1
+ import React from "react";
2
+
3
+ export function useCheckMounted() {
4
+ const mounted = React.useRef(false);
5
+
6
+ React.useEffect(() => {
7
+ mounted.current = true;
8
+ return () => {
9
+ mounted.current = false;
10
+ };
11
+ }, []);
12
+
13
+ return mounted;
14
+ }
@@ -0,0 +1,261 @@
1
+ import React from "react";
2
+ import { StyleSheet } from "react-native";
3
+ import type { SharedValue } from "react-native-reanimated";
4
+ import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
5
+ import { scheduleOnRN } from "react-native-worklets";
6
+
7
+ import type { InitializedCarouselProps } from "./useInitProps";
8
+
9
+ import {
10
+ getNearestLogicalPage,
11
+ positiveModulo,
12
+ reconcileOffsetAfterDataChange,
13
+ } from "../utils/carousel-math";
14
+ import { computeOffsetIfSizeChanged } from "../utils/compute-offset-if-size-changed";
15
+
16
+ export type CarouselSizePhase = "pending" | "ready" | "updating";
17
+
18
+ export interface CarouselCommonVariables {
19
+ size: number;
20
+ validLength: number;
21
+ handlerOffset: SharedValue<number>;
22
+ resolvedSize: SharedValue<number | null>;
23
+ sizePhase: SharedValue<CarouselSizePhase>;
24
+ sizeExplicit: boolean;
25
+ isMoving: SharedValue<boolean>;
26
+ dataRevision: number;
27
+ startMovement: () => void;
28
+ cancelMovement: () => void;
29
+ settleMovement: () => void;
30
+ }
31
+
32
+ export function useCommonVariables(
33
+ props: InitializedCarouselProps<unknown>
34
+ ): CarouselCommonVariables {
35
+ const {
36
+ orientation,
37
+ style,
38
+ dataLength,
39
+ defaultIndex,
40
+ keyExtractor,
41
+ loop,
42
+ itemSize,
43
+ rawData,
44
+ rawDataLength,
45
+ } = props;
46
+ const isVertical = orientation === "vertical";
47
+
48
+ const manualSize = React.useMemo(() => {
49
+ if (typeof itemSize === "number" && itemSize > 0) {
50
+ return itemSize;
51
+ }
52
+
53
+ const { width, height } = StyleSheet.flatten(style) || {};
54
+ const candidate = isVertical ? height : width;
55
+ if (typeof candidate === "number" && candidate > 0) {
56
+ return candidate;
57
+ }
58
+
59
+ return null;
60
+ }, [isVertical, itemSize, style]);
61
+
62
+ const resolvedSize = useSharedValue<number | null>(manualSize);
63
+ const sizePhase = useSharedValue<CarouselSizePhase>(manualSize ? "ready" : "pending");
64
+
65
+ const defaultHandlerOffsetValue =
66
+ manualSize && dataLength > 0 ? -Math.abs(defaultIndex * manualSize) : 0;
67
+ const _handlerOffset = useSharedValue<number>(defaultHandlerOffsetValue);
68
+ const handlerOffset = props.scrollOffsetValue ?? _handlerOffset;
69
+ const prevDataLength = useSharedValue(rawDataLength);
70
+ const previousRawData = React.useRef(rawData);
71
+ const previousKeyExtractor = React.useRef(keyExtractor);
72
+ const isMoving = useSharedValue(false);
73
+ const pendingDataChange = useSharedValue<{
74
+ previousLength: number;
75
+ currentLength: number;
76
+ retainedIndex: number | null;
77
+ } | null>(null);
78
+ // Start at zero even when a synchronous item size is available. The first
79
+ // size reaction owns initialization and must write the default-index offset
80
+ // into consumer-provided SharedValues as well as the internal value.
81
+ const prevSize = useSharedValue(0);
82
+ const hasInitializedOffset = useSharedValue(false);
83
+ const sizeExplicit = typeof itemSize === "number" && itemSize > 0;
84
+
85
+ const [size, setSize] = React.useState<number>(manualSize ?? 0);
86
+ const [dataRevision, setDataRevision] = React.useState(0);
87
+
88
+ const syncSize = React.useCallback((next: number) => {
89
+ setSize((current) => (current === next ? current : next));
90
+ }, []);
91
+
92
+ useAnimatedReaction(
93
+ () => resolvedSize.value,
94
+ (current, previous) => {
95
+ if (current == null || current <= 0) return;
96
+ if (current !== previous) {
97
+ scheduleOnRN(syncSize, current);
98
+ }
99
+ },
100
+ [syncSize]
101
+ );
102
+
103
+ React.useEffect(() => {
104
+ if (manualSize && manualSize > 0) {
105
+ resolvedSize.value = manualSize;
106
+ sizePhase.value = "ready";
107
+ }
108
+ }, [manualSize, resolvedSize, sizePhase]);
109
+
110
+ React.useEffect(() => {
111
+ const currentSize = resolvedSize.value ?? 0;
112
+ if (currentSize <= 0 || dataLength <= 0 || hasInitializedOffset.value) return;
113
+
114
+ handlerOffset.value = -(defaultIndex * currentSize);
115
+ prevSize.value = currentSize;
116
+ hasInitializedOffset.value = true;
117
+ }, [dataLength, defaultIndex, handlerOffset, hasInitializedOffset, prevSize, resolvedSize]);
118
+
119
+ const reconcileDataChange = React.useCallback(
120
+ (previousLength: number, currentLength: number, retainedIndex?: number) => {
121
+ const currentSize = resolvedSize.value ?? 0;
122
+ if (currentSize <= 0) return;
123
+
124
+ handlerOffset.value = reconcileOffsetAfterDataChange({
125
+ offset: handlerOffset.value,
126
+ itemSize: currentSize,
127
+ previousCount: previousLength,
128
+ nextCount: currentLength,
129
+ defaultIndex,
130
+ loop,
131
+ retainedIndex,
132
+ });
133
+ setDataRevision((revision) => revision + 1);
134
+ },
135
+ [defaultIndex, handlerOffset, loop, resolvedSize]
136
+ );
137
+
138
+ const startMovement = React.useCallback(() => {
139
+ isMoving.value = true;
140
+ }, [isMoving]);
141
+
142
+ const finishMovement = React.useCallback(() => {
143
+ isMoving.value = false;
144
+ const pending = pendingDataChange.value;
145
+ if (!pending) return;
146
+
147
+ pendingDataChange.value = null;
148
+ reconcileDataChange(
149
+ pending.previousLength,
150
+ pending.currentLength,
151
+ pending.retainedIndex ?? undefined
152
+ );
153
+ }, [isMoving, pendingDataChange, reconcileDataChange]);
154
+
155
+ /**
156
+ * When data changes, we need to compute new index for handlerOffset
157
+ */
158
+ React.useEffect(() => {
159
+ const previousLength = prevDataLength.value;
160
+ const previousItems = previousRawData.current;
161
+ const dataChanged =
162
+ previousLength !== rawDataLength ||
163
+ previousItems.length !== rawData.length ||
164
+ previousItems.some((item, index) => !Object.is(item, rawData[index]));
165
+
166
+ previousRawData.current = rawData;
167
+ const extractorForPreviousData = previousKeyExtractor.current ?? keyExtractor;
168
+ previousKeyExtractor.current = keyExtractor;
169
+
170
+ if (!dataChanged) return;
171
+
172
+ let retainedIndex: number | undefined;
173
+ if (keyExtractor && extractorForPreviousData && previousLength > 0 && rawDataLength > 0) {
174
+ const currentSize = resolvedSize.value ?? 0;
175
+ const pendingIndex = pendingDataChange.value?.retainedIndex;
176
+ const previousIndex =
177
+ typeof pendingIndex === "number"
178
+ ? pendingIndex
179
+ : positiveModulo(getNearestLogicalPage(handlerOffset.value, currentSize), previousLength);
180
+ if (previousIndex < previousItems.length) {
181
+ const selectedKey = extractorForPreviousData(previousItems[previousIndex], previousIndex);
182
+ const matchingIndex = rawData.findIndex(
183
+ (item, index) => keyExtractor(item, index) === selectedKey
184
+ );
185
+ if (matchingIndex >= 0) retainedIndex = matchingIndex;
186
+ }
187
+ }
188
+
189
+ prevDataLength.value = rawDataLength;
190
+ if (isMoving.value) {
191
+ pendingDataChange.value = {
192
+ previousLength: pendingDataChange.value?.previousLength ?? previousLength,
193
+ currentLength: rawDataLength,
194
+ retainedIndex: retainedIndex ?? null,
195
+ };
196
+ return;
197
+ }
198
+
199
+ reconcileDataChange(previousLength, rawDataLength, retainedIndex);
200
+ }, [
201
+ handlerOffset,
202
+ isMoving,
203
+ keyExtractor,
204
+ pendingDataChange,
205
+ prevDataLength,
206
+ rawData,
207
+ rawDataLength,
208
+ reconcileDataChange,
209
+ resolvedSize,
210
+ ]);
211
+
212
+ /**
213
+ * When size changes, we need to compute new index for handlerOffset
214
+ */
215
+ useAnimatedReaction(
216
+ () => resolvedSize.value,
217
+ (currentSize) => {
218
+ if (currentSize == null || currentSize <= 0) return;
219
+ const previousSize = prevSize.value;
220
+
221
+ if (previousSize === currentSize) return;
222
+
223
+ sizePhase.value = previousSize > 0 ? "updating" : sizePhase.value;
224
+
225
+ if (dataLength <= 0) {
226
+ prevSize.value = currentSize;
227
+ sizePhase.value = "ready";
228
+ return;
229
+ }
230
+
231
+ if (previousSize <= 0 || !hasInitializedOffset.value) {
232
+ handlerOffset.value = -(defaultIndex * currentSize);
233
+ hasInitializedOffset.value = true;
234
+ } else {
235
+ handlerOffset.value = computeOffsetIfSizeChanged({
236
+ handlerOffset: handlerOffset.value,
237
+ prevSize: previousSize,
238
+ size: currentSize,
239
+ });
240
+ }
241
+
242
+ prevSize.value = currentSize;
243
+ sizePhase.value = "ready";
244
+ },
245
+ [dataLength, defaultIndex, hasInitializedOffset, resolvedSize, sizePhase]
246
+ );
247
+
248
+ return {
249
+ size,
250
+ validLength: dataLength - 1,
251
+ handlerOffset,
252
+ resolvedSize,
253
+ sizePhase,
254
+ sizeExplicit,
255
+ isMoving,
256
+ dataRevision,
257
+ startMovement,
258
+ cancelMovement: finishMovement,
259
+ settleMovement: finishMovement,
260
+ };
261
+ }