@momo-kits/carousel 0.159.1-beta.4 → 0.160.1-beta.10

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 (2) hide show
  1. package/index.tsx +161 -61
  2. package/package.json +1 -1
package/index.tsx CHANGED
@@ -8,7 +8,6 @@ import React, {
8
8
  useState,
9
9
  } from 'react';
10
10
  import {
11
- Animated,
12
11
  Dimensions,
13
12
  FlatList,
14
13
  GestureResponderEvent,
@@ -17,14 +16,79 @@ import {
17
16
  NativeSyntheticEvent,
18
17
  Platform,
19
18
  StyleSheet,
20
- View
19
+ View,
21
20
  } from 'react-native';
21
+ import Animated, {
22
+ Extrapolation,
23
+ interpolate,
24
+ runOnJS,
25
+ useAnimatedScrollHandler,
26
+ useAnimatedStyle,
27
+ useSharedValue,
28
+ type SharedValue,
29
+ } from 'react-native-reanimated';
22
30
  import { CarouselProps, CarouselRef } from './types';
23
31
 
24
32
  const { width: viewportWidth } = Dimensions.get('window');
25
33
 
26
34
  const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
27
35
 
36
+ type CarouselItemProps = {
37
+ index: number;
38
+ itemWidth: number;
39
+ scrollX: SharedValue<number>;
40
+ inactiveSlideOpacity: number;
41
+ inactiveSlideScale: number;
42
+ slideStyle: any;
43
+ children: React.ReactNode;
44
+ };
45
+
46
+ const CarouselItem: React.FC<CarouselItemProps> = ({
47
+ index,
48
+ itemWidth,
49
+ scrollX,
50
+ inactiveSlideOpacity,
51
+ inactiveSlideScale,
52
+ slideStyle,
53
+ children,
54
+ }) => {
55
+ const animatedStyle = useAnimatedStyle(() => {
56
+ const inputRange = [
57
+ (index - 1) * itemWidth,
58
+ index * itemWidth,
59
+ (index + 1) * itemWidth,
60
+ ];
61
+ const opacity =
62
+ inactiveSlideOpacity < 1
63
+ ? interpolate(
64
+ scrollX.value,
65
+ inputRange,
66
+ [inactiveSlideOpacity, 1, inactiveSlideOpacity],
67
+ Extrapolation.CLAMP,
68
+ )
69
+ : 1;
70
+ const scale =
71
+ inactiveSlideScale < 1
72
+ ? interpolate(
73
+ scrollX.value,
74
+ inputRange,
75
+ [inactiveSlideScale, 1, inactiveSlideScale],
76
+ Extrapolation.CLAMP,
77
+ )
78
+ : 1;
79
+ return {
80
+ opacity,
81
+ transform: [{ scale }],
82
+ };
83
+ });
84
+
85
+ return (
86
+ <Animated.View style={[{ width: itemWidth }, slideStyle, animatedStyle]}>
87
+ {children}
88
+ </Animated.View>
89
+ );
90
+ };
91
+
28
92
  const Carousel = forwardRef<CarouselRef, CarouselProps>((props, ref) => {
29
93
  const {
30
94
  data,
@@ -62,7 +126,7 @@ const Carousel = forwardRef<CarouselRef, CarouselProps>((props, ref) => {
62
126
 
63
127
  const flatListRef = useRef<FlatList>(null);
64
128
  const autoplayTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
65
- const scrollXRef = useRef(new Animated.Value(0)).current;
129
+ const scrollX = useSharedValue(0);
66
130
  const containerWidthRef = useRef(viewportWidth);
67
131
  const isAutoplayPausedRef = useRef(false);
68
132
  const currentIndexRef = useRef(firstItem);
@@ -111,6 +175,17 @@ const Carousel = forwardRef<CarouselRef, CarouselProps>((props, ref) => {
111
175
  return result;
112
176
  }, [data, loop, loopClonesPerSide]);
113
177
 
178
+ // Explicit per-item snap offsets for the default snap path. Unlike
179
+ // snapToInterval (a single fixed step that can overshoot the content end and
180
+ // leave the last item unreachable), snapToOffsets combined with RN's
181
+ // snapToEnd (default true) always lets the scroll settle on the last item.
182
+ const snapOffsets = useMemo(() => {
183
+ if (!enableSnap || snapToIntervalProp !== undefined) {
184
+ return undefined;
185
+ }
186
+ return dataWithClones.map((_, i) => i * itemWidth);
187
+ }, [enableSnap, snapToIntervalProp, dataWithClones, itemWidth]);
188
+
114
189
  const getRealIndex = useCallback(
115
190
  (index: number) => {
116
191
  if (!loop) return index;
@@ -245,28 +320,68 @@ const Carousel = forwardRef<CarouselRef, CarouselProps>((props, ref) => {
245
320
  [loop, data.length, loopClonesPerSide, itemWidth, getLoopIndex]
246
321
  );
247
322
 
323
+ const resolveIndex = useCallback(
324
+ (offsetX: number, layoutWidth: number, contentWidth: number) => {
325
+ const count = dataWithClones.length;
326
+ // Derive the per-item width from the *native* scroll geometry rather than
327
+ // the JS-side itemWidth (measured via onLayout / Dimensions). In some hosts
328
+ // (e.g. miniapp runtimes) those two coordinate spaces don't line up, which
329
+ // made Math.round(offsetX / itemWidth) land one item short of the end.
330
+ const measuredItemWidth =
331
+ contentWidth > 0 && count > 0 ? contentWidth / count : itemWidth;
332
+ const unit = measuredItemWidth || itemWidth;
333
+ let index = unit > 0 ? Math.round(offsetX / unit) : 0;
334
+
335
+ // Once the content end is reached, snap to the very last item regardless of
336
+ // sub-pixel rounding drift (only when a single item fills the viewport).
337
+ const singleVisible =
338
+ layoutWidth > 0 && layoutWidth <= measuredItemWidth + 1;
339
+ if (
340
+ singleVisible &&
341
+ contentWidth > 0 &&
342
+ offsetX + layoutWidth >= contentWidth - 1
343
+ ) {
344
+ index = count - 1;
345
+ }
346
+
347
+ if (index < 0) {
348
+ index = 0;
349
+ }
350
+ if (count > 0 && index > count - 1) {
351
+ index = count - 1;
352
+ }
353
+ return index;
354
+ },
355
+ [dataWithClones.length, itemWidth]
356
+ );
357
+
248
358
  const handleScroll = useCallback(
249
- (event: NativeSyntheticEvent<NativeScrollEvent>) => {
250
- const offsetX = event.nativeEvent.contentOffset.x;
251
- const index = Math.round(offsetX / itemWidth);
359
+ (offsetX: number, layoutWidth: number, contentWidth: number) => {
360
+ const index = resolveIndex(offsetX, layoutWidth, contentWidth);
252
361
  const realIndex = getRealIndex(index);
253
362
 
254
363
  if (realIndex !== currentIndexRef.current) {
255
364
  currentIndexRef.current = realIndex;
256
365
  setCurrentIndex(realIndex);
257
-
366
+
258
367
  if (onScrollIndexChanged) {
259
368
  onScrollIndexChanged(realIndex);
260
369
  }
261
370
  }
262
371
  },
263
- [itemWidth, getRealIndex, onScrollIndexChanged]
372
+ [resolveIndex, getRealIndex, onScrollIndexChanged]
264
373
  );
265
374
 
266
375
  const handleMomentumScrollEnd = useCallback(
267
376
  (event: NativeSyntheticEvent<NativeScrollEvent>) => {
268
- const offsetX = event.nativeEvent.contentOffset.x;
269
- const index = Math.round(offsetX / itemWidth);
377
+ const { contentOffset, layoutMeasurement, contentSize } =
378
+ event.nativeEvent;
379
+ const offsetX = contentOffset.x;
380
+ const index = resolveIndex(
381
+ offsetX,
382
+ layoutMeasurement.width,
383
+ contentSize.width
384
+ );
270
385
  const realIndex = getRealIndex(index);
271
386
 
272
387
  currentIndexRef.current = realIndex;
@@ -282,7 +397,7 @@ const Carousel = forwardRef<CarouselRef, CarouselProps>((props, ref) => {
282
397
  onMomentumScrollEndProp(event);
283
398
  }
284
399
  },
285
- [itemWidth, getRealIndex, onSnapToItem, handleLoopReposition, onMomentumScrollEndProp]
400
+ [resolveIndex, getRealIndex, onSnapToItem, handleLoopReposition, onMomentumScrollEndProp]
286
401
  );
287
402
 
288
403
  const handleTouchStart = useCallback(
@@ -351,41 +466,17 @@ const Carousel = forwardRef<CarouselRef, CarouselProps>((props, ref) => {
351
466
  );
352
467
  }
353
468
 
354
- const inputRange = [
355
- (index - 1) * itemWidth,
356
- index * itemWidth,
357
- (index + 1) * itemWidth,
358
- ];
359
-
360
- const opacity = hasOpacityAnimation
361
- ? scrollXRef.interpolate({
362
- inputRange,
363
- outputRange: [inactiveSlideOpacity, 1, inactiveSlideOpacity],
364
- extrapolate: 'clamp',
365
- })
366
- : 1;
367
-
368
- const scale = hasScaleAnimation
369
- ? scrollXRef.interpolate({
370
- inputRange,
371
- outputRange: [inactiveSlideScale, 1, inactiveSlideScale],
372
- extrapolate: 'clamp',
373
- })
374
- : 1;
375
-
376
469
  return (
377
- <Animated.View
378
- style={[
379
- { width: itemWidth },
380
- slideStyle,
381
- {
382
- opacity,
383
- transform: [{ scale }],
384
- },
385
- ]}
470
+ <CarouselItem
471
+ index={index}
472
+ itemWidth={itemWidth}
473
+ scrollX={scrollX}
474
+ inactiveSlideOpacity={inactiveSlideOpacity}
475
+ inactiveSlideScale={inactiveSlideScale}
476
+ slideStyle={slideStyle}
386
477
  >
387
478
  {renderItem({ item, index: realIndex })}
388
- </Animated.View>
479
+ </CarouselItem>
389
480
  );
390
481
  },
391
482
  [
@@ -395,7 +486,7 @@ const Carousel = forwardRef<CarouselRef, CarouselProps>((props, ref) => {
395
486
  itemWidth,
396
487
  slideStyle,
397
488
  renderItem,
398
- scrollXRef,
489
+ scrollX,
399
490
  ]
400
491
  );
401
492
 
@@ -409,24 +500,28 @@ const Carousel = forwardRef<CarouselRef, CarouselProps>((props, ref) => {
409
500
  [keyExtractor]
410
501
  );
411
502
 
412
- const onScrollEvent = useMemo(() => {
413
- const scrollHandler = Animated.event(
414
- [{ nativeEvent: { contentOffset: { x: scrollXRef } } }],
415
- {
416
- useNativeDriver: true,
417
- listener: handleScroll,
503
+ const emitScrollProp = useCallback(
504
+ (offsetX: number) => {
505
+ if (onScrollProp) {
506
+ onScrollProp({
507
+ nativeEvent: { contentOffset: { x: offsetX, y: 0 } },
508
+ } as NativeSyntheticEvent<NativeScrollEvent>);
418
509
  }
419
- );
420
-
421
- if (onScrollProp) {
422
- return (event: NativeSyntheticEvent<NativeScrollEvent>) => {
423
- scrollHandler(event);
424
- onScrollProp(event);
425
- };
426
- }
510
+ },
511
+ [onScrollProp],
512
+ );
427
513
 
428
- return scrollHandler;
429
- }, [scrollXRef, handleScroll, onScrollProp]);
514
+ const onScrollEvent = useAnimatedScrollHandler({
515
+ onScroll: (event) => {
516
+ scrollX.value = event.contentOffset.x;
517
+ runOnJS(handleScroll)(
518
+ event.contentOffset.x,
519
+ event.layoutMeasurement.width,
520
+ event.contentSize.width,
521
+ );
522
+ runOnJS(emitScrollProp)(event.contentOffset.x);
523
+ },
524
+ });
430
525
 
431
526
  useEffect(() => {
432
527
  if (isLayoutReady && firstItem > 0 && firstItem < data.length) {
@@ -488,7 +583,12 @@ const Carousel = forwardRef<CarouselRef, CarouselProps>((props, ref) => {
488
583
  onMomentumScrollEnd={handleMomentumScrollEnd}
489
584
  onTouchStart={handleTouchStart}
490
585
  onTouchEnd={handleTouchEnd}
491
- snapToInterval={enableSnap ? (snapToInterval as number) : undefined}
586
+ snapToOffsets={snapOffsets}
587
+ snapToInterval={
588
+ enableSnap && snapToIntervalProp !== undefined
589
+ ? (snapToInterval as number)
590
+ : undefined
591
+ }
492
592
  snapToAlignment={enableSnap ? 'start' : undefined}
493
593
  decelerationRate={enableSnap ? 'fast' : 'normal'}
494
594
  disableIntervalMomentum={disableIntervalMomentum}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/carousel",
3
- "version": "0.159.1-beta.4",
3
+ "version": "0.160.1-beta.10",
4
4
  "private": false,
5
5
  "main": "index.tsx",
6
6
  "peerDependencies": {