react-native-ultra-carousel 0.1.0
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/package.json +81 -0
- package/src/animations/basic/fade.ts +51 -0
- package/src/animations/basic/overlap.ts +69 -0
- package/src/animations/basic/parallax.ts +65 -0
- package/src/animations/basic/peek.ts +79 -0
- package/src/animations/basic/scale.ts +63 -0
- package/src/animations/basic/scaleFade.ts +73 -0
- package/src/animations/basic/slide.ts +53 -0
- package/src/animations/basic/slideFade.ts +60 -0
- package/src/animations/basic/vertical.ts +50 -0
- package/src/animations/basic/verticalFade.ts +60 -0
- package/src/animations/index.ts +45 -0
- package/src/animations/registry.ts +175 -0
- package/src/animations/types.ts +11 -0
- package/src/animations/utils.ts +75 -0
- package/src/components/AutoPlayController.tsx +38 -0
- package/src/components/Carousel.tsx +371 -0
- package/src/components/CarouselItem.tsx +98 -0
- package/src/components/Pagination/BarPagination.tsx +141 -0
- package/src/components/Pagination/CustomPagination.tsx +48 -0
- package/src/components/Pagination/DotPagination.tsx +137 -0
- package/src/components/Pagination/NumberPagination.tsx +117 -0
- package/src/components/Pagination/Pagination.tsx +82 -0
- package/src/components/Pagination/ProgressPagination.tsx +70 -0
- package/src/components/Pagination/index.ts +11 -0
- package/src/components/ParallaxImage.tsx +89 -0
- package/src/gestures/FlingGestureManager.ts +49 -0
- package/src/gestures/PanGestureManager.ts +202 -0
- package/src/gestures/ScrollViewCompat.ts +28 -0
- package/src/gestures/types.ts +6 -0
- package/src/hooks/useAnimationProgress.ts +33 -0
- package/src/hooks/useAutoPlay.ts +115 -0
- package/src/hooks/useCarousel.ts +118 -0
- package/src/hooks/useCarouselGesture.ts +109 -0
- package/src/hooks/useItemAnimation.ts +44 -0
- package/src/hooks/usePagination.ts +39 -0
- package/src/hooks/useSnapPoints.ts +31 -0
- package/src/hooks/useVirtualization.ts +63 -0
- package/src/index.ts +71 -0
- package/src/plugins/PluginManager.ts +150 -0
- package/src/plugins/types.ts +6 -0
- package/src/types/animation.ts +72 -0
- package/src/types/carousel.ts +188 -0
- package/src/types/gesture.ts +42 -0
- package/src/types/index.ts +41 -0
- package/src/types/pagination.ts +65 -0
- package/src/types/plugin.ts +27 -0
- package/src/utils/accessibility.ts +71 -0
- package/src/utils/constants.ts +45 -0
- package/src/utils/layout.ts +115 -0
- package/src/utils/math.ts +78 -0
- package/src/utils/platform.ts +33 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Carousel component
|
|
3
|
+
* @description Main carousel component with gesture handling, animations, and accessibility
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, {
|
|
7
|
+
forwardRef,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useImperativeHandle,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
} from 'react';
|
|
15
|
+
import { StyleSheet, View } from 'react-native';
|
|
16
|
+
import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
17
|
+
import { useSharedValue, runOnJS } from 'react-native-reanimated';
|
|
18
|
+
import type { CarouselProps, CarouselRef, CustomAnimationFn } from '../types';
|
|
19
|
+
import { CarouselItem } from './CarouselItem';
|
|
20
|
+
import { Pagination } from './Pagination';
|
|
21
|
+
import { AutoPlayController } from './AutoPlayController';
|
|
22
|
+
import { useCarouselGesture } from '../hooks/useCarouselGesture';
|
|
23
|
+
import { useSnapPoints } from '../hooks/useSnapPoints';
|
|
24
|
+
import { usePagination } from '../hooks/usePagination';
|
|
25
|
+
import type { UseAutoPlayReturn } from '../hooks/useAutoPlay';
|
|
26
|
+
import { getEffectiveItemSize } from '../utils/layout';
|
|
27
|
+
import { getCarouselAccessibilityProps } from '../utils/accessibility';
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_WIDTH,
|
|
30
|
+
DEFAULT_HEIGHT,
|
|
31
|
+
DEFAULT_PAGINATION_ACTIVE_COLOR,
|
|
32
|
+
DEFAULT_PAGINATION_INACTIVE_COLOR,
|
|
33
|
+
DEFAULT_PAGINATION_SIZE,
|
|
34
|
+
DEFAULT_PAGINATION_GAP,
|
|
35
|
+
} from '../utils/constants';
|
|
36
|
+
import { clamp } from '../utils/math';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The main Carousel component.
|
|
40
|
+
* Provides gesture-driven navigation, animation presets, pagination, and auto play.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* <Carousel
|
|
45
|
+
* data={items}
|
|
46
|
+
* renderItem={({ item }) => <Card item={item} />}
|
|
47
|
+
* preset="coverflow"
|
|
48
|
+
* pagination
|
|
49
|
+
* autoPlay
|
|
50
|
+
* />
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
const CarouselComponent = <T,>(
|
|
54
|
+
props: CarouselProps<T>,
|
|
55
|
+
ref: React.Ref<CarouselRef>
|
|
56
|
+
) => {
|
|
57
|
+
const {
|
|
58
|
+
data,
|
|
59
|
+
renderItem,
|
|
60
|
+
preset = 'slide',
|
|
61
|
+
animationConfig,
|
|
62
|
+
width: containerWidth = DEFAULT_WIDTH,
|
|
63
|
+
height: containerHeight = DEFAULT_HEIGHT,
|
|
64
|
+
itemWidth: itemWidthProp,
|
|
65
|
+
itemHeight: itemHeightProp,
|
|
66
|
+
direction = 'horizontal',
|
|
67
|
+
gap = 0,
|
|
68
|
+
snapAlignment = 'center',
|
|
69
|
+
loop = false,
|
|
70
|
+
initialIndex = 0,
|
|
71
|
+
enabled = true,
|
|
72
|
+
autoPlay,
|
|
73
|
+
autoPlayInterval,
|
|
74
|
+
pagination,
|
|
75
|
+
gestureConfig,
|
|
76
|
+
onIndexChange,
|
|
77
|
+
onScrollStart,
|
|
78
|
+
onScrollEnd,
|
|
79
|
+
scrollProgress: externalScrollProgress,
|
|
80
|
+
plugins,
|
|
81
|
+
maxRenderItems = 0,
|
|
82
|
+
renderBuffer = 2,
|
|
83
|
+
accessible = true,
|
|
84
|
+
accessibilityLabel,
|
|
85
|
+
style,
|
|
86
|
+
itemStyle,
|
|
87
|
+
} = props;
|
|
88
|
+
|
|
89
|
+
const isHorizontal = direction === 'horizontal';
|
|
90
|
+
const containerSize = isHorizontal ? containerWidth : containerHeight;
|
|
91
|
+
const itemWidth = getEffectiveItemSize(itemWidthProp, containerWidth);
|
|
92
|
+
const itemHeight = getEffectiveItemSize(itemHeightProp, containerHeight);
|
|
93
|
+
const itemSize = isHorizontal ? itemWidth : itemHeight;
|
|
94
|
+
const totalItems = data.length;
|
|
95
|
+
|
|
96
|
+
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
|
97
|
+
const autoPlayRef = useRef<UseAutoPlayReturn | null>(null);
|
|
98
|
+
|
|
99
|
+
const snapPoints = useSnapPoints(
|
|
100
|
+
totalItems,
|
|
101
|
+
itemSize,
|
|
102
|
+
gap,
|
|
103
|
+
containerSize,
|
|
104
|
+
snapAlignment
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const handleIndexChange = useCallback(
|
|
108
|
+
(index: number) => {
|
|
109
|
+
setCurrentIndex(index);
|
|
110
|
+
onIndexChange?.(index);
|
|
111
|
+
|
|
112
|
+
plugins?.forEach((plugin) => {
|
|
113
|
+
plugin.onIndexChange?.(index);
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
[onIndexChange, plugins]
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const {
|
|
120
|
+
gesture,
|
|
121
|
+
offset,
|
|
122
|
+
activeIndex,
|
|
123
|
+
snapToIndex,
|
|
124
|
+
} = useCarouselGesture({
|
|
125
|
+
totalItems,
|
|
126
|
+
itemSize,
|
|
127
|
+
gap,
|
|
128
|
+
snapPoints,
|
|
129
|
+
direction,
|
|
130
|
+
loop,
|
|
131
|
+
enabled,
|
|
132
|
+
gestureConfig,
|
|
133
|
+
onIndexChange: handleIndexChange,
|
|
134
|
+
onScrollStart,
|
|
135
|
+
onScrollEnd,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const { currentPage } = usePagination(offset, itemSize, gap, totalItems);
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (externalScrollProgress) {
|
|
142
|
+
externalScrollProgress.value = offset.value;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (initialIndex > 0 && initialIndex < totalItems) {
|
|
148
|
+
snapToIndex(initialIndex, false);
|
|
149
|
+
}
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
plugins?.forEach((plugin) => {
|
|
154
|
+
plugin.onInit?.(carouselRef);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return () => {
|
|
158
|
+
plugins?.forEach((plugin) => {
|
|
159
|
+
plugin.onDestroy?.();
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
}, [plugins]);
|
|
163
|
+
|
|
164
|
+
const carouselRef: CarouselRef = useMemo(
|
|
165
|
+
() => ({
|
|
166
|
+
scrollTo: (index: number, animated = true) => {
|
|
167
|
+
const clampedIndex = loop ? index : clamp(index, 0, totalItems - 1);
|
|
168
|
+
snapToIndex(clampedIndex, animated);
|
|
169
|
+
},
|
|
170
|
+
next: (animated = true) => {
|
|
171
|
+
const nextIndex = loop
|
|
172
|
+
? (currentIndex + 1) % totalItems
|
|
173
|
+
: Math.min(currentIndex + 1, totalItems - 1);
|
|
174
|
+
snapToIndex(nextIndex, animated);
|
|
175
|
+
},
|
|
176
|
+
prev: (animated = true) => {
|
|
177
|
+
const prevIndex = loop
|
|
178
|
+
? (currentIndex - 1 + totalItems) % totalItems
|
|
179
|
+
: Math.max(currentIndex - 1, 0);
|
|
180
|
+
snapToIndex(prevIndex, animated);
|
|
181
|
+
},
|
|
182
|
+
getCurrentIndex: () => currentIndex,
|
|
183
|
+
startAutoPlay: () => autoPlayRef.current?.start(),
|
|
184
|
+
stopAutoPlay: () => autoPlayRef.current?.stop(),
|
|
185
|
+
pauseAutoPlay: () => autoPlayRef.current?.pause(),
|
|
186
|
+
}),
|
|
187
|
+
[currentIndex, totalItems, loop, snapToIndex]
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
useImperativeHandle(ref, () => carouselRef, [carouselRef]);
|
|
191
|
+
|
|
192
|
+
const handleAutoPlayAdvance = useCallback(
|
|
193
|
+
(advanceDirection: 'forward' | 'backward') => {
|
|
194
|
+
if (advanceDirection === 'forward') {
|
|
195
|
+
carouselRef.next();
|
|
196
|
+
} else {
|
|
197
|
+
carouselRef.prev();
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
[carouselRef]
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const handleAutoPlayControlsReady = useCallback(
|
|
204
|
+
(controls: UseAutoPlayReturn) => {
|
|
205
|
+
autoPlayRef.current = controls;
|
|
206
|
+
},
|
|
207
|
+
[]
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const autoPlayConfig = useMemo(() => {
|
|
211
|
+
if (typeof autoPlay === 'boolean') {
|
|
212
|
+
return {
|
|
213
|
+
enabled: autoPlay,
|
|
214
|
+
interval: autoPlayInterval,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return autoPlay;
|
|
218
|
+
}, [autoPlay, autoPlayInterval]);
|
|
219
|
+
|
|
220
|
+
const a11yProps = accessible
|
|
221
|
+
? getCarouselAccessibilityProps(accessibilityLabel, currentIndex, totalItems)
|
|
222
|
+
: {};
|
|
223
|
+
|
|
224
|
+
const handleAccessibilityAction = useCallback(
|
|
225
|
+
(event: { nativeEvent: { actionName: string } }) => {
|
|
226
|
+
switch (event.nativeEvent.actionName) {
|
|
227
|
+
case 'increment':
|
|
228
|
+
carouselRef.next();
|
|
229
|
+
break;
|
|
230
|
+
case 'decrement':
|
|
231
|
+
carouselRef.prev();
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
[carouselRef]
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const paginationConfig = useMemo(() => {
|
|
239
|
+
if (!pagination) return null;
|
|
240
|
+
if (typeof pagination === 'boolean') {
|
|
241
|
+
return {
|
|
242
|
+
type: 'dot' as const,
|
|
243
|
+
activeColor: DEFAULT_PAGINATION_ACTIVE_COLOR,
|
|
244
|
+
inactiveColor: DEFAULT_PAGINATION_INACTIVE_COLOR,
|
|
245
|
+
size: DEFAULT_PAGINATION_SIZE,
|
|
246
|
+
gap: DEFAULT_PAGINATION_GAP,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
...pagination,
|
|
251
|
+
activeColor: pagination.activeColor ?? DEFAULT_PAGINATION_ACTIVE_COLOR,
|
|
252
|
+
inactiveColor: pagination.inactiveColor ?? DEFAULT_PAGINATION_INACTIVE_COLOR,
|
|
253
|
+
size: pagination.size ?? DEFAULT_PAGINATION_SIZE,
|
|
254
|
+
gap: pagination.gap ?? DEFAULT_PAGINATION_GAP,
|
|
255
|
+
};
|
|
256
|
+
}, [pagination]);
|
|
257
|
+
|
|
258
|
+
const goToIndex = useCallback(
|
|
259
|
+
(index: number) => {
|
|
260
|
+
carouselRef.scrollTo(index);
|
|
261
|
+
},
|
|
262
|
+
[carouselRef]
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const renderItems = useMemo(() => {
|
|
266
|
+
return data.map((item, index) => {
|
|
267
|
+
const isActive = index === currentIndex;
|
|
268
|
+
const progress = useSharedValue(0);
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<CarouselItem
|
|
272
|
+
key={index}
|
|
273
|
+
index={index}
|
|
274
|
+
totalItems={totalItems}
|
|
275
|
+
scrollOffset={offset}
|
|
276
|
+
itemWidth={itemWidth}
|
|
277
|
+
itemHeight={itemHeight}
|
|
278
|
+
gap={gap}
|
|
279
|
+
preset={preset as string | CustomAnimationFn}
|
|
280
|
+
animationConfig={animationConfig}
|
|
281
|
+
isActive={isActive}
|
|
282
|
+
accessible={accessible}
|
|
283
|
+
style={itemStyle}
|
|
284
|
+
>
|
|
285
|
+
{renderItem({
|
|
286
|
+
item,
|
|
287
|
+
index,
|
|
288
|
+
animationProgress: progress,
|
|
289
|
+
isActive,
|
|
290
|
+
})}
|
|
291
|
+
</CarouselItem>
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
}, [
|
|
295
|
+
data,
|
|
296
|
+
currentIndex,
|
|
297
|
+
totalItems,
|
|
298
|
+
offset,
|
|
299
|
+
itemWidth,
|
|
300
|
+
itemHeight,
|
|
301
|
+
gap,
|
|
302
|
+
preset,
|
|
303
|
+
animationConfig,
|
|
304
|
+
accessible,
|
|
305
|
+
itemStyle,
|
|
306
|
+
renderItem,
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<GestureHandlerRootView style={styles.root}>
|
|
311
|
+
<View
|
|
312
|
+
style={[
|
|
313
|
+
styles.container,
|
|
314
|
+
{
|
|
315
|
+
width: containerWidth,
|
|
316
|
+
height: containerHeight,
|
|
317
|
+
flexDirection: isHorizontal ? 'row' : 'column',
|
|
318
|
+
},
|
|
319
|
+
style,
|
|
320
|
+
]}
|
|
321
|
+
{...a11yProps}
|
|
322
|
+
onAccessibilityAction={handleAccessibilityAction}
|
|
323
|
+
>
|
|
324
|
+
<GestureDetector gesture={gesture}>
|
|
325
|
+
<View style={styles.gestureContainer}>
|
|
326
|
+
{renderItems}
|
|
327
|
+
</View>
|
|
328
|
+
</GestureDetector>
|
|
329
|
+
|
|
330
|
+
{paginationConfig && (
|
|
331
|
+
<Pagination
|
|
332
|
+
config={paginationConfig}
|
|
333
|
+
totalItems={totalItems}
|
|
334
|
+
progress={currentPage}
|
|
335
|
+
goToIndex={goToIndex}
|
|
336
|
+
/>
|
|
337
|
+
)}
|
|
338
|
+
|
|
339
|
+
{autoPlayConfig && (
|
|
340
|
+
<AutoPlayController
|
|
341
|
+
config={autoPlayConfig}
|
|
342
|
+
onAdvance={handleAutoPlayAdvance}
|
|
343
|
+
onControlsReady={handleAutoPlayControlsReady}
|
|
344
|
+
/>
|
|
345
|
+
)}
|
|
346
|
+
</View>
|
|
347
|
+
</GestureHandlerRootView>
|
|
348
|
+
);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
CarouselComponent.displayName = 'Carousel';
|
|
352
|
+
|
|
353
|
+
const styles = StyleSheet.create({
|
|
354
|
+
root: {
|
|
355
|
+
flex: 0,
|
|
356
|
+
},
|
|
357
|
+
container: {
|
|
358
|
+
overflow: 'hidden',
|
|
359
|
+
position: 'relative',
|
|
360
|
+
},
|
|
361
|
+
gestureContainer: {
|
|
362
|
+
flex: 1,
|
|
363
|
+
position: 'relative',
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
export const Carousel = forwardRef(CarouselComponent) as <T>(
|
|
368
|
+
props: CarouselProps<T> & { ref?: React.Ref<CarouselRef> }
|
|
369
|
+
) => React.ReactElement;
|
|
370
|
+
|
|
371
|
+
(Carousel as React.FC).displayName = 'Carousel';
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file CarouselItem component
|
|
3
|
+
* @description Wraps each carousel item with animation and accessibility
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { memo } from 'react';
|
|
7
|
+
import { StyleSheet, type ViewStyle, type StyleProp } from 'react-native';
|
|
8
|
+
import Animated, { type SharedValue } from 'react-native-reanimated';
|
|
9
|
+
import type { CustomAnimationFn } from '../types';
|
|
10
|
+
import { useAnimationProgress } from '../hooks/useAnimationProgress';
|
|
11
|
+
import { useItemAnimation } from '../hooks/useItemAnimation';
|
|
12
|
+
import { getItemAccessibilityProps } from '../utils/accessibility';
|
|
13
|
+
|
|
14
|
+
/** Props for the CarouselItem component */
|
|
15
|
+
export interface CarouselItemProps {
|
|
16
|
+
/** Item index in the data array */
|
|
17
|
+
index: number;
|
|
18
|
+
/** Total number of items */
|
|
19
|
+
totalItems: number;
|
|
20
|
+
/** Current scroll offset shared value */
|
|
21
|
+
scrollOffset: SharedValue<number>;
|
|
22
|
+
/** Item width in pixels */
|
|
23
|
+
itemWidth: number;
|
|
24
|
+
/** Item height in pixels */
|
|
25
|
+
itemHeight: number;
|
|
26
|
+
/** Gap between items */
|
|
27
|
+
gap: number;
|
|
28
|
+
/** Animation preset name or custom function */
|
|
29
|
+
preset?: string | CustomAnimationFn;
|
|
30
|
+
/** Animation config overrides */
|
|
31
|
+
animationConfig?: Record<string, number>;
|
|
32
|
+
/** Whether this item is currently active */
|
|
33
|
+
isActive: boolean;
|
|
34
|
+
/** Whether accessibility features are enabled */
|
|
35
|
+
accessible: boolean;
|
|
36
|
+
/** Custom item container style */
|
|
37
|
+
style?: StyleProp<ViewStyle>;
|
|
38
|
+
/** Content to render inside the item */
|
|
39
|
+
children: React.ReactNode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Individual carousel item wrapper component.
|
|
44
|
+
* Applies animation transforms based on scroll progress.
|
|
45
|
+
*/
|
|
46
|
+
const CarouselItemComponent: React.FC<CarouselItemProps> = ({
|
|
47
|
+
index,
|
|
48
|
+
totalItems,
|
|
49
|
+
scrollOffset,
|
|
50
|
+
itemWidth,
|
|
51
|
+
itemHeight,
|
|
52
|
+
gap,
|
|
53
|
+
preset,
|
|
54
|
+
animationConfig,
|
|
55
|
+
isActive,
|
|
56
|
+
accessible,
|
|
57
|
+
style,
|
|
58
|
+
children,
|
|
59
|
+
}) => {
|
|
60
|
+
const progress = useAnimationProgress(index, scrollOffset, itemWidth, gap);
|
|
61
|
+
const animatedStyle = useItemAnimation(
|
|
62
|
+
progress,
|
|
63
|
+
preset,
|
|
64
|
+
animationConfig,
|
|
65
|
+
index,
|
|
66
|
+
totalItems
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const a11yProps = accessible
|
|
70
|
+
? getItemAccessibilityProps(index, totalItems, isActive)
|
|
71
|
+
: {};
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Animated.View
|
|
75
|
+
style={[
|
|
76
|
+
styles.item,
|
|
77
|
+
{ width: itemWidth, height: itemHeight },
|
|
78
|
+
animatedStyle,
|
|
79
|
+
style,
|
|
80
|
+
]}
|
|
81
|
+
{...a11yProps}
|
|
82
|
+
>
|
|
83
|
+
{children}
|
|
84
|
+
</Animated.View>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
CarouselItemComponent.displayName = 'CarouselItem';
|
|
89
|
+
|
|
90
|
+
const styles = StyleSheet.create({
|
|
91
|
+
item: {
|
|
92
|
+
justifyContent: 'center',
|
|
93
|
+
alignItems: 'center',
|
|
94
|
+
overflow: 'hidden',
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
export const CarouselItem = memo(CarouselItemComponent);
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file BarPagination component
|
|
3
|
+
* @description Thin bar indicators with animated active width
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { memo } from 'react';
|
|
7
|
+
import { StyleSheet, Pressable, View } from 'react-native';
|
|
8
|
+
import Animated, {
|
|
9
|
+
useAnimatedStyle,
|
|
10
|
+
interpolate,
|
|
11
|
+
Extrapolation,
|
|
12
|
+
type SharedValue,
|
|
13
|
+
} from 'react-native-reanimated';
|
|
14
|
+
import type { BasePaginationProps } from '../../types';
|
|
15
|
+
|
|
16
|
+
/** Width multiplier for active bar */
|
|
17
|
+
const ACTIVE_WIDTH_MULTIPLIER = 2.5;
|
|
18
|
+
/** Bar height in pixels */
|
|
19
|
+
const BAR_HEIGHT = 3;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bar-based pagination indicator.
|
|
23
|
+
* Active bar stretches wider with smooth animation.
|
|
24
|
+
*/
|
|
25
|
+
const BarPaginationComponent: React.FC<BasePaginationProps> = ({
|
|
26
|
+
totalItems,
|
|
27
|
+
progress,
|
|
28
|
+
activeColor,
|
|
29
|
+
inactiveColor,
|
|
30
|
+
size,
|
|
31
|
+
gap,
|
|
32
|
+
goToIndex,
|
|
33
|
+
style,
|
|
34
|
+
}) => {
|
|
35
|
+
return (
|
|
36
|
+
<View style={[styles.container, style]} accessibilityRole="tablist">
|
|
37
|
+
{Array.from({ length: totalItems }).map((_, index) => (
|
|
38
|
+
<BarIndicator
|
|
39
|
+
key={index}
|
|
40
|
+
index={index}
|
|
41
|
+
progress={progress}
|
|
42
|
+
activeColor={activeColor}
|
|
43
|
+
inactiveColor={inactiveColor}
|
|
44
|
+
size={size}
|
|
45
|
+
gap={gap}
|
|
46
|
+
goToIndex={goToIndex}
|
|
47
|
+
/>
|
|
48
|
+
))}
|
|
49
|
+
</View>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
BarPaginationComponent.displayName = 'BarPagination';
|
|
54
|
+
|
|
55
|
+
/** Props for an individual bar */
|
|
56
|
+
interface BarIndicatorProps {
|
|
57
|
+
index: number;
|
|
58
|
+
progress: SharedValue<number>;
|
|
59
|
+
activeColor: string;
|
|
60
|
+
inactiveColor: string;
|
|
61
|
+
size: number;
|
|
62
|
+
gap: number;
|
|
63
|
+
goToIndex: (index: number) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Individual animated bar indicator */
|
|
67
|
+
const BarIndicator: React.FC<BarIndicatorProps> = memo(({
|
|
68
|
+
index,
|
|
69
|
+
progress,
|
|
70
|
+
activeColor,
|
|
71
|
+
inactiveColor,
|
|
72
|
+
size,
|
|
73
|
+
gap,
|
|
74
|
+
goToIndex,
|
|
75
|
+
}) => {
|
|
76
|
+
const baseWidth = size * 2;
|
|
77
|
+
|
|
78
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
79
|
+
const distance = Math.abs(progress.value - index);
|
|
80
|
+
|
|
81
|
+
const width = interpolate(
|
|
82
|
+
distance,
|
|
83
|
+
[0, 1],
|
|
84
|
+
[baseWidth * ACTIVE_WIDTH_MULTIPLIER, baseWidth],
|
|
85
|
+
Extrapolation.CLAMP
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const opacity = interpolate(
|
|
89
|
+
distance,
|
|
90
|
+
[0, 1],
|
|
91
|
+
[1, 0.4],
|
|
92
|
+
Extrapolation.CLAMP
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
width,
|
|
97
|
+
opacity,
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const isActive = Math.round(progress.value) === index;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Pressable
|
|
105
|
+
onPress={() => goToIndex(index)}
|
|
106
|
+
accessibilityRole="tab"
|
|
107
|
+
accessibilityLabel={`Page ${index + 1}`}
|
|
108
|
+
accessibilityState={{ selected: isActive }}
|
|
109
|
+
hitSlop={8}
|
|
110
|
+
>
|
|
111
|
+
<Animated.View
|
|
112
|
+
style={[
|
|
113
|
+
styles.bar,
|
|
114
|
+
{
|
|
115
|
+
height: BAR_HEIGHT,
|
|
116
|
+
borderRadius: BAR_HEIGHT / 2,
|
|
117
|
+
marginHorizontal: gap / 2,
|
|
118
|
+
backgroundColor: isActive ? activeColor : inactiveColor,
|
|
119
|
+
},
|
|
120
|
+
animatedStyle,
|
|
121
|
+
]}
|
|
122
|
+
/>
|
|
123
|
+
</Pressable>
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
BarIndicator.displayName = 'BarIndicator';
|
|
128
|
+
|
|
129
|
+
const styles = StyleSheet.create({
|
|
130
|
+
container: {
|
|
131
|
+
flexDirection: 'row',
|
|
132
|
+
alignItems: 'center',
|
|
133
|
+
justifyContent: 'center',
|
|
134
|
+
paddingVertical: 12,
|
|
135
|
+
},
|
|
136
|
+
bar: {
|
|
137
|
+
// dynamic styles applied inline
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export const BarPagination = memo(BarPaginationComponent);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file CustomPagination component
|
|
3
|
+
* @description Renders user-provided custom pagination via render function
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { memo } from 'react';
|
|
7
|
+
import type { SharedValue } from 'react-native-reanimated';
|
|
8
|
+
import type { PaginationRenderInfo } from '../../types';
|
|
9
|
+
|
|
10
|
+
/** Props for the CustomPagination component */
|
|
11
|
+
export interface CustomPaginationProps {
|
|
12
|
+
/** Total number of items */
|
|
13
|
+
totalItems: number;
|
|
14
|
+
/** Animated scroll progress */
|
|
15
|
+
progress: SharedValue<number>;
|
|
16
|
+
/** Navigate to specific index */
|
|
17
|
+
goToIndex: (index: number) => void;
|
|
18
|
+
/** User-provided render function */
|
|
19
|
+
renderCustom: (info: PaginationRenderInfo) => React.ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Custom pagination that delegates rendering to a user-provided function.
|
|
24
|
+
* Passes all pagination state to the render function.
|
|
25
|
+
*/
|
|
26
|
+
const CustomPaginationComponent: React.FC<CustomPaginationProps> = ({
|
|
27
|
+
totalItems,
|
|
28
|
+
progress,
|
|
29
|
+
goToIndex,
|
|
30
|
+
renderCustom,
|
|
31
|
+
}) => {
|
|
32
|
+
const currentIndex = Math.round(progress.value);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
{renderCustom({
|
|
37
|
+
currentIndex,
|
|
38
|
+
totalItems,
|
|
39
|
+
progress,
|
|
40
|
+
goToIndex,
|
|
41
|
+
})}
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
CustomPaginationComponent.displayName = 'CustomPagination';
|
|
47
|
+
|
|
48
|
+
export const CustomPagination = memo(CustomPaginationComponent);
|