react-native-reanimated-carousel 5.0.0-beta.4 → 5.0.0-beta.6

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 (31) hide show
  1. package/README.md +28 -6
  2. package/lib/commonjs/components/CarouselLayout.js +1 -1
  3. package/lib/commonjs/components/CarouselLayout.js.map +1 -1
  4. package/lib/commonjs/components/ItemLayout.js +1 -1
  5. package/lib/commonjs/components/ItemLayout.js.map +1 -1
  6. package/lib/commonjs/hooks/useCarouselController.js +1 -1
  7. package/lib/commonjs/hooks/useCarouselController.js.map +1 -1
  8. package/lib/commonjs/hooks/useVisibleRanges.js +1 -1
  9. package/lib/commonjs/hooks/useVisibleRanges.js.map +1 -1
  10. package/lib/module/components/CarouselLayout.js +1 -1
  11. package/lib/module/components/CarouselLayout.js.map +1 -1
  12. package/lib/module/components/ItemLayout.js +1 -1
  13. package/lib/module/components/ItemLayout.js.map +1 -1
  14. package/lib/module/hooks/useCarouselController.js +1 -1
  15. package/lib/module/hooks/useCarouselController.js.map +1 -1
  16. package/lib/module/hooks/useVisibleRanges.js +1 -1
  17. package/lib/module/hooks/useVisibleRanges.js.map +1 -1
  18. package/lib/typescript/components/CarouselLayout.d.ts +11 -0
  19. package/lib/typescript/components/CarouselLayout.d.ts.map +1 -1
  20. package/lib/typescript/components/ItemLayout.d.ts +5 -0
  21. package/lib/typescript/components/ItemLayout.d.ts.map +1 -1
  22. package/lib/typescript/hooks/useCarouselController.d.ts.map +1 -1
  23. package/lib/typescript/hooks/useVisibleRanges.d.ts.map +1 -1
  24. package/package.json +16 -6
  25. package/src/components/Carousel.test.tsx +152 -0
  26. package/src/components/CarouselLayout.tsx +51 -8
  27. package/src/components/ItemLayout.tsx +31 -7
  28. package/src/hooks/useCarouselController.test.tsx +93 -6
  29. package/src/hooks/useCarouselController.tsx +23 -25
  30. package/src/hooks/useVisibleRanges.test.tsx +29 -0
  31. package/src/hooks/useVisibleRanges.tsx +6 -1
@@ -15,6 +15,34 @@ import { ScrollViewGesture } from "./ScrollViewGesture";
15
15
 
16
16
  export type TAnimationStyle = (value: number) => ViewStyle;
17
17
 
18
+ export function resolveCarouselLayoutStyle(params: {
19
+ flattenedStyle: Partial<ViewStyle>;
20
+ vertical: boolean;
21
+ measuredSize: number;
22
+ sizeExplicit: boolean;
23
+ legacyWidth?: number;
24
+ legacyHeight?: number;
25
+ }) {
26
+ "worklet";
27
+
28
+ const { flattenedStyle, vertical, measuredSize, sizeExplicit, legacyWidth, legacyHeight } =
29
+ params;
30
+ const { width, height } = flattenedStyle;
31
+ const hasLegacyMainAxisSize = vertical
32
+ ? typeof legacyHeight === "number" && legacyHeight > 0
33
+ : typeof legacyWidth === "number" && legacyWidth > 0;
34
+ const shouldUseMeasuredMainAxisSize = sizeExplicit || hasLegacyMainAxisSize;
35
+ const resolvedMainAxisSize = measuredSize || "100%";
36
+
37
+ return {
38
+ width:
39
+ width ?? (vertical ? "100%" : shouldUseMeasuredMainAxisSize ? resolvedMainAxisSize : "100%"),
40
+ height:
41
+ height ??
42
+ (vertical ? (shouldUseMeasuredMainAxisSize ? resolvedMainAxisSize : "100%") : "100%"),
43
+ };
44
+ }
45
+
18
46
  export const CarouselLayout = React.forwardRef<ICarouselInstance>((_props, ref) => {
19
47
  const { props, common } = useGlobalState();
20
48
 
@@ -46,9 +74,11 @@ export const CarouselLayout = React.forwardRef<ICarouselInstance>((_props, ref)
46
74
  onProgressChange,
47
75
  customAnimation,
48
76
  defaultIndex,
77
+ width: legacyWidth,
78
+ height: legacyHeight,
49
79
  } = props;
50
80
 
51
- const { size, handlerOffset, resolvedSize, sizePhase } = common;
81
+ const { size, handlerOffset, resolvedSize, sizePhase, sizeExplicit } = common;
52
82
  const layoutConfig = useLayoutConfig({ ...props, size });
53
83
 
54
84
  const isSizeReady = useDerivedValue(() => {
@@ -157,18 +187,31 @@ export const CarouselLayout = React.forwardRef<ICarouselInstance>((_props, ref)
157
187
  const flattenedStyle = StyleSheet.flatten(style) || {};
158
188
 
159
189
  const layoutStyle = useAnimatedStyle(() => {
160
- const { width, height } = flattenedStyle;
161
190
  const measuredSize = resolvedSize.value ?? 0;
162
-
163
- const computedWidth = width ?? (vertical ? "100%" : measuredSize || "100%");
164
- const computedHeight = height ?? (vertical ? measuredSize || "100%" : "100%");
191
+ const { width, height } = resolveCarouselLayoutStyle({
192
+ flattenedStyle,
193
+ vertical: !!vertical,
194
+ measuredSize,
195
+ sizeExplicit,
196
+ legacyWidth,
197
+ legacyHeight,
198
+ });
165
199
 
166
200
  return {
167
- width: computedWidth,
168
- height: computedHeight,
201
+ width,
202
+ height,
169
203
  opacity: isSizeReady.value ? 1 : 0,
170
204
  };
171
- }, [flattenedStyle, isSizeReady, vertical, resolvedSize, sizePhase]);
205
+ }, [
206
+ flattenedStyle,
207
+ isSizeReady,
208
+ vertical,
209
+ resolvedSize,
210
+ sizePhase,
211
+ sizeExplicit,
212
+ legacyWidth,
213
+ legacyHeight,
214
+ ]);
172
215
 
173
216
  return (
174
217
  <GestureHandlerRootView testID={testID} style={[styles.layoutContainer, style]}>
@@ -19,6 +19,20 @@ import { sanitizeAnimationStyle } from "../utils/sanitize-animation-style";
19
19
 
20
20
  export type TAnimationStyle = NonNullable<TCarouselProps["customAnimation"]>;
21
21
 
22
+ export function resolveItemMainAxisSize(params: {
23
+ explicitSize?: number;
24
+ effectivePageSize?: number;
25
+ measuredSize: number | null;
26
+ }) {
27
+ "worklet";
28
+
29
+ const { explicitSize, effectivePageSize, measuredSize } = params;
30
+
31
+ return typeof explicitSize === "number"
32
+ ? explicitSize
33
+ : (effectivePageSize ?? measuredSize ?? "100%");
34
+ }
35
+
22
36
  export const ItemLayout: React.FC<{
23
37
  index: number;
24
38
  handlerOffset: SharedValue<number>;
@@ -71,15 +85,25 @@ export const ItemLayout: React.FC<{
71
85
  const widthCandidate = vertical ? width : (explicitItemSize ?? explicitAxisSize);
72
86
  const heightCandidate = vertical ? (explicitItemSize ?? explicitAxisSize) : height;
73
87
 
74
- const computedWidth =
75
- typeof widthCandidate === "number"
88
+ const computedWidth = vertical
89
+ ? typeof widthCandidate === "number"
76
90
  ? widthCandidate
77
- : (measuredSize.value.width ?? (vertical ? "100%" : (effectivePageSize ?? "100%")));
78
-
79
- const computedHeight =
80
- typeof heightCandidate === "number"
91
+ : (measuredSize.value.width ?? "100%")
92
+ : resolveItemMainAxisSize({
93
+ explicitSize: widthCandidate,
94
+ effectivePageSize,
95
+ measuredSize: measuredSize.value.width,
96
+ });
97
+
98
+ const computedHeight = vertical
99
+ ? resolveItemMainAxisSize({
100
+ explicitSize: heightCandidate,
101
+ effectivePageSize,
102
+ measuredSize: measuredSize.value.height,
103
+ })
104
+ : typeof heightCandidate === "number"
81
105
  ? heightCandidate
82
- : (measuredSize.value.height ?? (vertical ? (effectivePageSize ?? "100%") : "100%"));
106
+ : (measuredSize.value.height ?? "100%");
83
107
 
84
108
  return {
85
109
  width: computedWidth,
@@ -228,6 +228,76 @@ describe("useCarouselController", () => {
228
228
  expect(mockHandlerOffset.value).toBe(-900); // size * 3
229
229
  });
230
230
 
231
+ it("should keep negative offsets when scrollTo() moves backward in non-loop mode", () => {
232
+ const { result } = renderHook(
233
+ () =>
234
+ useCarouselController({
235
+ ...defaultProps,
236
+ loop: false,
237
+ }),
238
+ { wrapper }
239
+ );
240
+
241
+ act(() => {
242
+ result.current.scrollTo({ index: 3, animated: false });
243
+ });
244
+ expect(mockHandlerOffset.value).toBe(-900);
245
+
246
+ act(() => {
247
+ result.current.scrollTo({ index: 2, animated: false });
248
+ });
249
+ expect(mockHandlerOffset.value).toBe(-600);
250
+
251
+ act(() => {
252
+ result.current.scrollTo({ index: 1, animated: false });
253
+ });
254
+ expect(mockHandlerOffset.value).toBe(-300);
255
+ });
256
+
257
+ it("should keep negative offsets when animated backward scrollTo() runs in non-loop mode", () => {
258
+ const onFinished = jest.fn();
259
+ const { result } = renderHook(
260
+ () =>
261
+ useCarouselController({
262
+ ...defaultProps,
263
+ loop: false,
264
+ }),
265
+ { wrapper }
266
+ );
267
+
268
+ act(() => {
269
+ result.current.scrollTo({ index: 3, animated: true });
270
+ });
271
+ expect(mockHandlerOffset.value).toBe(-900);
272
+
273
+ act(() => {
274
+ result.current.scrollTo({ index: 2, animated: true, onFinished });
275
+ });
276
+ expect(mockHandlerOffset.value).toBe(-600);
277
+ expect(onFinished).toHaveBeenCalled();
278
+ });
279
+
280
+ it("should map scrollTo({ index: 0 }) to zero offset from end in non-loop mode", () => {
281
+ const { result } = renderHook(
282
+ () =>
283
+ useCarouselController({
284
+ ...defaultProps,
285
+ loop: false,
286
+ }),
287
+ { wrapper }
288
+ );
289
+
290
+ act(() => {
291
+ result.current.scrollTo({ index: 4, animated: false });
292
+ });
293
+ expect(mockHandlerOffset.value).toBe(-1200);
294
+
295
+ act(() => {
296
+ result.current.scrollTo({ index: 0, animated: false });
297
+ });
298
+ expect(Math.abs(mockHandlerOffset.value)).toBe(0);
299
+ });
300
+
231
301
  it("should handle animation callbacks", () => {
232
302
  const onFinished = jest.fn();
233
303
  const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper });
@@ -1004,13 +1074,31 @@ describe("useCarouselController edge cases and uncovered lines", () => {
1004
1074
  expect(typeof currentIndex).toBe("number");
1005
1075
  });
1006
1076
 
1007
- it("should handle fixed direction in scrollTo", () => {
1077
+ it("should handle positive fixed direction in scrollTo", () => {
1078
+ const { result } = renderHook(
1079
+ () =>
1080
+ useCarouselController({
1081
+ ...defaultProps,
1082
+ loop: true,
1083
+ fixedDirection: "positive",
1084
+ }),
1085
+ { wrapper }
1086
+ );
1087
+
1088
+ act(() => {
1089
+ result.current.scrollTo({ index: 3, animated: false });
1090
+ });
1091
+
1092
+ expect(mockHandlerOffset.value).toBe(900); // size * 3
1093
+ });
1094
+
1095
+ it("should handle negative fixed direction in scrollTo", () => {
1008
1096
  const { result } = renderHook(
1009
1097
  () =>
1010
1098
  useCarouselController({
1011
1099
  ...defaultProps,
1012
1100
  loop: true,
1013
- fixedDirection: 1,
1101
+ fixedDirection: "negative",
1014
1102
  }),
1015
1103
  { wrapper }
1016
1104
  );
@@ -1022,7 +1110,7 @@ describe("useCarouselController edge cases and uncovered lines", () => {
1022
1110
  expect(mockHandlerOffset.value).toBe(-900); // size * 3
1023
1111
  });
1024
1112
 
1025
- it("should handle complex loop calculations in scrollTo", () => {
1113
+ it("should handle scrollTo when loop position is close to next cycle", () => {
1026
1114
  const { result } = renderHook(
1027
1115
  () =>
1028
1116
  useCarouselController({
@@ -1032,14 +1120,13 @@ describe("useCarouselController edge cases and uncovered lines", () => {
1032
1120
  { wrapper }
1033
1121
  );
1034
1122
 
1035
- // Set to a high offset to test loop boundary calculations
1036
- mockHandlerOffset.value = -1800; // 6 * size, beyond data length
1123
+ mockHandlerOffset.value = -900; // 3 * size, more than half-way through current cycle
1037
1124
 
1038
1125
  act(() => {
1039
1126
  result.current.scrollTo({ index: 1, animated: false });
1040
1127
  });
1041
1128
 
1042
- expect(typeof mockHandlerOffset.value).toBe("number");
1129
+ expect(mockHandlerOffset.value).toBe(-1800);
1043
1130
  });
1044
1131
 
1045
1132
  it("should get shared index correctly", () => {
@@ -352,34 +352,32 @@ export function useCarouselController(options: IOpts): ICarouselController {
352
352
  if (!canSliding()) return;
353
353
 
354
354
  onScrollStart?.();
355
- // direction -> 1 | -1
356
- let direction: -1 | 1;
357
- if (fixedDirection === "positive") direction = 1;
358
- else if (fixedDirection === "negative") direction = -1;
359
- else if (!loop) {
360
- const currentPage = currentFixedPage();
361
- direction = i >= currentPage ? -1 : 1;
362
- } else {
363
- direction = handlerOffsetDirection(handlerOffset);
364
- }
365
-
366
- // target offset
367
- const offset = i * size * direction;
368
- // page width size * page count
369
- const totalSize = dataInfo.length * size;
370
-
371
- let isCloseToNextLoop = false;
355
+ let finalOffset: number;
372
356
 
373
- if (loop) {
374
- isCloseToNextLoop = Math.abs(handlerOffset.value % totalSize) / totalSize >= 0.5;
357
+ if (!loop) {
358
+ // Non-loop mode always uses direct page-to-offset mapping.
359
+ // This avoids sign flips when moving to lower indices.
360
+ finalOffset = -i * size;
361
+ } else {
362
+ // direction -> 1 | -1
363
+ let direction: -1 | 1;
364
+ if (fixedDirection === "positive") direction = 1;
365
+ else if (fixedDirection === "negative") direction = -1;
366
+ else direction = handlerOffsetDirection(handlerOffset);
367
+
368
+ // target offset
369
+ const offset = i * size * direction;
370
+ // page width size * page count
371
+ const totalSize = dataInfo.length * size;
372
+ const isCloseToNextLoop = Math.abs(handlerOffset.value % totalSize) / totalSize >= 0.5;
373
+
374
+ finalOffset =
375
+ (Math.floor(Math.abs(handlerOffset.value / totalSize)) + (isCloseToNextLoop ? 1 : 0)) *
376
+ totalSize *
377
+ direction +
378
+ offset;
375
379
  }
376
380
 
377
- const finalOffset =
378
- (Math.floor(Math.abs(handlerOffset.value / totalSize)) + (isCloseToNextLoop ? 1 : 0)) *
379
- totalSize *
380
- direction +
381
- offset;
382
-
383
381
  if (animated) {
384
382
  index.value = i;
385
383
  handlerOffset.value = scrollWithTiming(finalOffset, onFinished);
@@ -67,6 +67,35 @@ describe("useVisibleRanges", () => {
67
67
  `);
68
68
  });
69
69
 
70
+ it.each([
71
+ { total: 1, windowSize: 1, currentIndex: 0 },
72
+ { total: 2, windowSize: 5, currentIndex: 1 },
73
+ { total: 3, windowSize: 3, currentIndex: 2 },
74
+ { total: 4, windowSize: 5, currentIndex: 1 },
75
+ ])(
76
+ "should display every item when windowSize=$windowSize is at least total=$total in loop mode",
77
+ ({ total, windowSize, currentIndex }) => {
78
+ const ranges = renderHook(() => {
79
+ const translation = useSharedValue(-currentIndex * viewSize);
80
+ return useVisibleRanges({
81
+ total,
82
+ translation,
83
+ viewSize,
84
+ windowSize,
85
+ loop: true,
86
+ });
87
+ }).result.current.value;
88
+
89
+ const visibleIndexes = Array.from({ length: total }, (_, index) => index).filter(
90
+ (index) =>
91
+ (index >= ranges.negativeRange[0] && index <= ranges.negativeRange[1]) ||
92
+ (index >= ranges.positiveRange[0] && index <= ranges.positiveRange[1])
93
+ );
94
+
95
+ expect(visibleIndexes).toEqual(Array.from({ length: total }, (_, index) => index));
96
+ }
97
+ );
98
+
70
99
  it("should shows the increased range of the list when the loop is false and swiped the carousel.", async () => {
71
100
  const slide0hook = renderHook(() => {
72
101
  const translation = useSharedValue(-0 * viewSize);
@@ -50,7 +50,12 @@ export function useVisibleRanges(options: {
50
50
 
51
51
  let newRanges: VisibleRanges;
52
52
 
53
- if (!loop) {
53
+ if (loop && windowSize >= total) {
54
+ newRanges = {
55
+ negativeRange: [0, 0],
56
+ positiveRange: [0, total - 1],
57
+ };
58
+ } else if (!loop) {
54
59
  // Clamp currentIndex to valid range [0, total-1] for non-loop mode
55
60
  // When overdragging right, translation.value becomes positive, making currentIndex negative
56
61
  currentIndex = Math.max(0, Math.min(total - 1, currentIndex));