@momo-kits/carousel 0.159.1-beta.5 → 0.160.1-beta.10-test.1

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 +200 -63
  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,32 +320,82 @@ 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
- currentIndexRef.current = realIndex;
273
- setCurrentIndex(realIndex);
387
+ // Report the final resting index here as well. The reanimated scroll
388
+ // handler isn't guaranteed to deliver the last momentum frame to JS in
389
+ // every runtime (e.g. miniapp hosts), so relying on handleScroll alone
390
+ // can leave onScrollIndexChanged stuck one item short of the end.
391
+ if (realIndex !== currentIndexRef.current) {
392
+ currentIndexRef.current = realIndex;
393
+ setCurrentIndex(realIndex);
394
+
395
+ if (onScrollIndexChanged) {
396
+ onScrollIndexChanged(realIndex);
397
+ }
398
+ }
274
399
 
275
400
  if (onSnapToItem) {
276
401
  onSnapToItem(realIndex);
@@ -282,7 +407,33 @@ const Carousel = forwardRef<CarouselRef, CarouselProps>((props, ref) => {
282
407
  onMomentumScrollEndProp(event);
283
408
  }
284
409
  },
285
- [itemWidth, getRealIndex, onSnapToItem, handleLoopReposition, onMomentumScrollEndProp]
410
+ [resolveIndex, getRealIndex, onScrollIndexChanged, onSnapToItem, handleLoopReposition, onMomentumScrollEndProp]
411
+ );
412
+
413
+ const handleScrollEndDrag = useCallback(
414
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
415
+ // Fallback for a drag released without enough velocity to fire a momentum
416
+ // scroll: report the resting index so onScrollIndexChanged isn't left
417
+ // stuck one item short of the end.
418
+ const { contentOffset, layoutMeasurement, contentSize } =
419
+ event.nativeEvent;
420
+ const index = resolveIndex(
421
+ contentOffset.x,
422
+ layoutMeasurement.width,
423
+ contentSize.width
424
+ );
425
+ const realIndex = getRealIndex(index);
426
+
427
+ if (realIndex !== currentIndexRef.current) {
428
+ currentIndexRef.current = realIndex;
429
+ setCurrentIndex(realIndex);
430
+
431
+ if (onScrollIndexChanged) {
432
+ onScrollIndexChanged(realIndex);
433
+ }
434
+ }
435
+ },
436
+ [resolveIndex, getRealIndex, onScrollIndexChanged]
286
437
  );
287
438
 
288
439
  const handleTouchStart = useCallback(
@@ -351,41 +502,17 @@ const Carousel = forwardRef<CarouselRef, CarouselProps>((props, ref) => {
351
502
  );
352
503
  }
353
504
 
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
505
  return (
377
- <Animated.View
378
- style={[
379
- { width: itemWidth },
380
- slideStyle,
381
- {
382
- opacity,
383
- transform: [{ scale }],
384
- },
385
- ]}
506
+ <CarouselItem
507
+ index={index}
508
+ itemWidth={itemWidth}
509
+ scrollX={scrollX}
510
+ inactiveSlideOpacity={inactiveSlideOpacity}
511
+ inactiveSlideScale={inactiveSlideScale}
512
+ slideStyle={slideStyle}
386
513
  >
387
514
  {renderItem({ item, index: realIndex })}
388
- </Animated.View>
515
+ </CarouselItem>
389
516
  );
390
517
  },
391
518
  [
@@ -395,7 +522,7 @@ const Carousel = forwardRef<CarouselRef, CarouselProps>((props, ref) => {
395
522
  itemWidth,
396
523
  slideStyle,
397
524
  renderItem,
398
- scrollXRef,
525
+ scrollX,
399
526
  ]
400
527
  );
401
528
 
@@ -409,24 +536,28 @@ const Carousel = forwardRef<CarouselRef, CarouselProps>((props, ref) => {
409
536
  [keyExtractor]
410
537
  );
411
538
 
412
- const onScrollEvent = useMemo(() => {
413
- const scrollHandler = Animated.event(
414
- [{ nativeEvent: { contentOffset: { x: scrollXRef } } }],
415
- {
416
- useNativeDriver: true,
417
- listener: handleScroll,
539
+ const emitScrollProp = useCallback(
540
+ (offsetX: number) => {
541
+ if (onScrollProp) {
542
+ onScrollProp({
543
+ nativeEvent: { contentOffset: { x: offsetX, y: 0 } },
544
+ } as NativeSyntheticEvent<NativeScrollEvent>);
418
545
  }
419
- );
420
-
421
- if (onScrollProp) {
422
- return (event: NativeSyntheticEvent<NativeScrollEvent>) => {
423
- scrollHandler(event);
424
- onScrollProp(event);
425
- };
426
- }
546
+ },
547
+ [onScrollProp],
548
+ );
427
549
 
428
- return scrollHandler;
429
- }, [scrollXRef, handleScroll, onScrollProp]);
550
+ const onScrollEvent = useAnimatedScrollHandler({
551
+ onScroll: (event) => {
552
+ scrollX.value = event.contentOffset.x;
553
+ runOnJS(handleScroll)(
554
+ event.contentOffset.x,
555
+ event.layoutMeasurement.width,
556
+ event.contentSize.width,
557
+ );
558
+ runOnJS(emitScrollProp)(event.contentOffset.x);
559
+ },
560
+ });
430
561
 
431
562
  useEffect(() => {
432
563
  if (isLayoutReady && firstItem > 0 && firstItem < data.length) {
@@ -486,9 +617,15 @@ const Carousel = forwardRef<CarouselRef, CarouselProps>((props, ref) => {
486
617
  scrollEventThrottle={16}
487
618
  onScroll={onScrollEvent}
488
619
  onMomentumScrollEnd={handleMomentumScrollEnd}
620
+ onScrollEndDrag={handleScrollEndDrag}
489
621
  onTouchStart={handleTouchStart}
490
622
  onTouchEnd={handleTouchEnd}
491
- snapToInterval={enableSnap ? (snapToInterval as number) : undefined}
623
+ snapToOffsets={snapOffsets}
624
+ snapToInterval={
625
+ enableSnap && snapToIntervalProp !== undefined
626
+ ? (snapToInterval as number)
627
+ : undefined
628
+ }
492
629
  snapToAlignment={enableSnap ? 'start' : undefined}
493
630
  decelerationRate={enableSnap ? 'fast' : 'normal'}
494
631
  disableIntervalMomentum={disableIntervalMomentum}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/carousel",
3
- "version": "0.159.1-beta.5",
3
+ "version": "0.160.1-beta.10-test.1",
4
4
  "private": false,
5
5
  "main": "index.tsx",
6
6
  "peerDependencies": {