related-ui-components 2.7.6 → 2.7.8

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.
@@ -1,20 +1,19 @@
1
- import React, { useState, useEffect, useMemo } from "react";
2
- import { View, StyleSheet, Image, Dimensions, Text } from "react-native";
1
+ import { Image } from "expo-image";
2
+ import React, { useEffect, useMemo } from "react";
3
+ import { Dimensions, StyleSheet, Text, View } from "react-native";
3
4
  import { Gesture, GestureDetector } from "react-native-gesture-handler";
4
5
  import Animated, {
5
- useSharedValue,
6
- useAnimatedStyle,
7
- withSpring,
8
- interpolate,
9
- Extrapolate,
10
- useDerivedValue,
11
- runOnJS,
12
- Extrapolation,
6
+ Extrapolation,
7
+ interpolate,
8
+ useAnimatedStyle,
9
+ useDerivedValue,
10
+ useSharedValue,
11
+ withSpring,
13
12
  } from "react-native-reanimated";
14
13
  import { useTheme } from "../../theme";
15
14
 
15
+ // --- Constants remain the same ---
16
16
  const { width: SCREEN_WIDTH } = Dimensions.get("window");
17
-
18
17
  const CARD_WIDTH_FACTOR = 0.5;
19
18
  const CARD_ASPECT_RATIO = 1.35;
20
19
  const SIDE_CARD_SCALE_FACTOR = 0.85;
@@ -25,31 +24,29 @@ const SIDE_CARD_TRANSLATE_X_FACTOR = Math.min(0.32, MAX_X_FACTOR);
25
24
  const SIDE_CARD_TRANSLATE_X = CARD_WIDTH * SIDE_CARD_TRANSLATE_X_FACTOR;
26
25
  const ACTIVE_CARD_SCALE = 1.0;
27
26
  const SPRING_CONFIG = { damping: 18, stiffness: 120, mass: 0.6 };
28
- const FADE_SPRING_CONFIG = { damping: 35, stiffness: 50, mass: 1.2 };
29
27
  const SIDE_CARD_ROTATION_DEGREES = 7;
28
+ const VISIBILITY_WINDOW = 3; // Render active card + 2 on each side
30
29
 
30
+ // --- Interfaces remain the same ---
31
31
  interface CarouselItemOriginal {
32
32
  id: string;
33
33
  image: string;
34
34
  title?: string;
35
35
  }
36
-
37
36
  interface CarouselItemVirtual extends CarouselItemOriginal {
38
37
  uniqueId: string;
39
38
  }
40
-
41
39
  interface CarouselCardStackProps {
42
40
  data: CarouselItemOriginal[];
43
41
  cardHeight?: number;
44
42
  cardWidth?: number;
45
- backgroundColor? : string;
43
+ backgroundColor?: string;
46
44
  }
47
45
 
48
46
  const createVirtualData = (
49
47
  originalData: CarouselItemOriginal[]
50
48
  ): CarouselItemVirtual[] => {
51
49
  if (!originalData || originalData.length === 0) return [];
52
-
53
50
  const prefixItems = (
54
51
  items: CarouselItemOriginal[],
55
52
  segmentPrefix: string
@@ -58,221 +55,209 @@ const createVirtualData = (
58
55
  ...item,
59
56
  uniqueId: `${segmentPrefix}-${item.id}-${idx}`,
60
57
  }));
61
-
62
58
  const prevSegment = prefixItems(originalData, "prev");
63
59
  const currSegment = prefixItems(originalData, "curr");
64
60
  const nextSegment = prefixItems(originalData, "next");
65
-
66
61
  return [...prevSegment, ...currSegment, ...nextSegment];
67
62
  };
68
63
 
69
- const CarouselCardStack: React.FC<CarouselCardStackProps> = ({
64
+ const CarouselCard = React.memo(
65
+ ({
66
+ item,
67
+ index,
68
+ activeIndex,
69
+ gestureTranslateX,
70
+ cardWidth,
71
+ cardHeight,
72
+ virtualDataLength,
73
+ }: {
74
+ item: CarouselItemVirtual;
75
+ index: number;
76
+ activeIndex: Animated.SharedValue<number>;
77
+ gestureTranslateX: Animated.SharedValue<number>;
78
+ cardWidth: number;
79
+ cardHeight: number;
80
+ virtualDataLength: number;
81
+ }) => {
82
+ const animatedStyle = useAnimatedStyle(() => {
83
+ const currentCardDragOffset = gestureTranslateX.value / cardWidth;
84
+ const displayOffset =
85
+ index - (activeIndex.value - currentCardDragOffset);
86
+
87
+ // --- THE CORE CHANGE IS HERE ---
88
+ // Check if the card is within our visibility window on the UI thread.
89
+ const isVisible = Math.abs(displayOffset) < VISIBILITY_WINDOW;
90
+
91
+ // If not visible, we don't need to calculate transforms.
92
+ // `display: 'none'` is highly performant as it removes the item from the layout.
93
+ if (!isVisible) {
94
+ return { display: "none" };
95
+ }
96
+
97
+ // Calculations now only run for visible cards
98
+ const scale = interpolate(
99
+ displayOffset,
100
+ [-1, 0, 1],
101
+ [SIDE_CARD_SCALE_FACTOR, ACTIVE_CARD_SCALE, SIDE_CARD_SCALE_FACTOR],
102
+ Extrapolation.CLAMP
103
+ );
104
+ const translateX = interpolate(
105
+ displayOffset,
106
+ [-1, 0, 1],
107
+ [-SIDE_CARD_TRANSLATE_X, 0, SIDE_CARD_TRANSLATE_X],
108
+ Extrapolation.CLAMP
109
+ );
110
+ const rotation = interpolate(
111
+ displayOffset,
112
+ [-1, 0, 1],
113
+ [-SIDE_CARD_ROTATION_DEGREES, 0, SIDE_CARD_ROTATION_DEGREES],
114
+ Extrapolation.CLAMP
115
+ );
116
+ const opacity = interpolate(
117
+ Math.abs(displayOffset),
118
+ [0, 1, 2],
119
+ [1, 1, 0], // Fade out the outermost cards
120
+ Extrapolation.CLAMP
121
+ );
122
+ const snappedDisplayOffset = index - Math.round(activeIndex.value);
123
+ const zIndex = virtualDataLength - Math.abs(snappedDisplayOffset);
124
+
125
+ return {
126
+ display: "flex", // Ensure it's visible
127
+ transform: [{ translateX }, { scale }, { rotateZ: `${rotation}deg` }],
128
+ opacity,
129
+ zIndex,
130
+ };
131
+ });
132
+
133
+ return (
134
+ <Animated.View
135
+ style={[
136
+ styles.card,
137
+ { width: cardWidth, height: cardHeight },
138
+ animatedStyle,
139
+ ]}
140
+ >
141
+ <Image
142
+ source={{ uri: item.image }}
143
+ style={styles.cardImage}
144
+ cachePolicy="memory-disk"
145
+ transition={250}
146
+ />
147
+ {item.title && (
148
+ <View style={styles.titleContainer}>
149
+ <Text style={styles.cardTitle}>{item.title}</Text>
150
+ </View>
151
+ )}
152
+ </Animated.View>
153
+ );
154
+ }
155
+ );
156
+
157
+ CarouselCard.displayName = "CarouselCard"
158
+
159
+ export const CarouselCardStack: React.FC<CarouselCardStackProps> = ({
70
160
  data: originalData,
71
161
  cardHeight = CARD_HEIGHT,
72
162
  cardWidth = CARD_WIDTH,
73
- backgroundColor = "transparent"
163
+ backgroundColor = "transparent",
74
164
  }) => {
75
165
  const { theme } = useTheme();
76
-
77
166
  const N_original = originalData.length;
78
167
 
79
- const virtualData = useMemo(
80
- () => createVirtualData(originalData),
81
- [originalData]
82
- );
168
+ const virtualData = useMemo(() => createVirtualData(originalData), [originalData]);
83
169
 
84
170
  const activeIndex = useSharedValue(N_original > 0 ? N_original : 0);
85
171
  const gestureTranslateX = useSharedValue(0);
86
- const contextX = useSharedValue(0);
87
172
 
88
173
  useEffect(() => {
89
174
  activeIndex.value = N_original > 0 ? N_original : 0;
90
175
  gestureTranslateX.value = 0;
91
176
  }, [N_original, activeIndex, gestureTranslateX]);
92
177
 
93
- // Worklet to handle the loop reset
94
178
  const handleLoopReset = () => {
95
179
  "worklet";
96
180
  if (N_original === 0) return;
97
-
98
181
  const currentValue = Math.round(activeIndex.value);
99
-
100
- // If in the "next" segment (indices >= 2*N_original)
101
182
  if (currentValue >= N_original * 2) {
102
183
  activeIndex.value = currentValue - N_original;
103
- }
104
- // If in the "prev" segment (indices < N_original)
105
- else if (currentValue < N_original) {
184
+ } else if (currentValue < N_original) {
106
185
  activeIndex.value = currentValue + N_original;
107
186
  }
108
187
  };
109
-
110
- useDerivedValue(() => {
111
- const currentValue = Math.round(activeIndex.value);
112
188
 
113
- // Only reset if we're at the boundaries and not currently animating (gestureTranslateX is 0)
114
- if (gestureTranslateX.value === 0) { // i.e., spring animation for gesture has finished
115
- if (currentValue >= N_original * 2) {
116
- activeIndex.value = currentValue - N_original;
117
- } else if (currentValue < N_original) {
118
- activeIndex.value = currentValue + N_original;
189
+ useDerivedValue(() => {
190
+ if (gestureTranslateX.value === 0) {
191
+ handleLoopReset();
119
192
  }
120
- }
121
- });
193
+ });
122
194
 
123
195
  const panGesture = Gesture.Pan()
124
196
  .activeOffsetX([-10, 10])
125
- .onBegin(() => {
126
- contextX.value = gestureTranslateX.value;
127
- })
128
197
  .onUpdate((event) => {
129
198
  gestureTranslateX.value = event.translationX;
130
199
  })
131
200
  .onEnd((event) => {
132
201
  if (virtualData.length === 0) return;
133
-
134
202
  const threshold = cardWidth / 3;
135
- let newTargetVirtualIndex = activeIndex.value;
136
-
203
+ let newTargetVirtualIndex = Math.round(activeIndex.value);
137
204
  if (event.translationX < -threshold) {
138
- newTargetVirtualIndex = activeIndex.value + 1;
205
+ newTargetVirtualIndex++;
139
206
  } else if (event.translationX > threshold) {
140
- newTargetVirtualIndex = activeIndex.value - 1;
207
+ newTargetVirtualIndex--;
141
208
  }
142
-
143
- // Clamp to virtual data bounds
144
- newTargetVirtualIndex = Math.max(
145
- 0,
146
- Math.min(newTargetVirtualIndex, virtualData.length - 1)
147
- );
148
-
149
- activeIndex.value = withSpring(
150
- newTargetVirtualIndex,
151
- SPRING_CONFIG,
152
- (finished) => {
153
- "worklet";
154
- if (finished) {
155
- handleLoopReset();
156
- }
157
- }
158
- );
209
+ activeIndex.value = withSpring(newTargetVirtualIndex, SPRING_CONFIG);
159
210
  gestureTranslateX.value = withSpring(0, SPRING_CONFIG);
160
- contextX.value = 0;
161
211
  });
162
212
 
163
213
  const activeDotIndex = useDerivedValue(() => {
164
214
  if (N_original === 0) return 0;
165
- const currentVal = Math.round(activeIndex.value);
166
- return ((currentVal % N_original) + N_original) % N_original;
215
+ return (Math.round(activeIndex.value) % N_original + N_original) % N_original;
167
216
  });
168
217
 
169
- if (virtualData.length === 0) {
170
- return (
171
- <View style={[styles.container, { height: cardHeight + 60 }]}>
172
- <Text style={styles.emptyText}>No items to display</Text>
173
- </View>
174
- );
175
- }
218
+ if (virtualData.length === 0) { /* ... */ }
176
219
 
177
220
  return (
178
- <View style={[styles.container, { height: cardHeight + 60, backgroundColor: backgroundColor}]}>
221
+ <View
222
+ style={[
223
+ styles.container,
224
+ { height: cardHeight + 60, backgroundColor: backgroundColor },
225
+ ]}
226
+ >
179
227
  <GestureDetector gesture={panGesture}>
180
228
  <Animated.View style={[styles.cardContainer, { height: cardHeight }]}>
181
- {virtualData.map((item, index) => {
182
- const animatedStyle = useAnimatedStyle(() => {
183
- const currentCardDragOffset = gestureTranslateX.value / cardWidth;
184
- const displayOffset =
185
- index - (activeIndex.value - currentCardDragOffset);
186
-
187
- const scale = interpolate(
188
- displayOffset,
189
- [-1, 0, 1],
190
- [
191
- SIDE_CARD_SCALE_FACTOR,
192
- ACTIVE_CARD_SCALE,
193
- SIDE_CARD_SCALE_FACTOR,
194
- ],
195
- Extrapolation.CLAMP
196
- );
197
-
198
- const translateX = interpolate(
199
- displayOffset,
200
- [-1, 0, 1],
201
- [-SIDE_CARD_TRANSLATE_X, 0, SIDE_CARD_TRANSLATE_X],
202
- Extrapolation.CLAMP
203
- );
204
-
205
- const rotation = interpolate(
206
- displayOffset,
207
- [-1, 0, 1],
208
- [
209
- -SIDE_CARD_ROTATION_DEGREES,
210
- 0,
211
- SIDE_CARD_ROTATION_DEGREES,
212
- ],
213
- Extrapolation.CLAMP,
214
- );
215
-
216
- const opacity = interpolate(
217
- Math.abs(displayOffset),
218
- [0, 1, 2, 2.75],
219
- [1, 1, 0.6, 0],
220
- Extrapolation.CLAMP
221
- );
222
-
223
- const snappedDisplayOffset =
224
- index - Math.round(activeIndex.value);
225
- const zIndex =
226
- virtualData.length - Math.abs(snappedDisplayOffset);
227
-
228
-
229
- return {
230
- transform: [{ translateX }, { scale }, { rotateZ: `${rotation}deg` }],
231
- opacity,
232
- zIndex,
233
- };
234
- });
235
-
236
- return (
237
- <Animated.View
238
- key={item.uniqueId}
239
- style={[
240
- styles.card,
241
- { width: cardWidth, height: cardHeight },
242
- animatedStyle,
243
- ]}
244
- >
245
- <Image source={{ uri: item.image }} style={styles.cardImage} />
246
- {item.title && (
247
- <View style={styles.titleContainer}>
248
- <Text style={styles.cardTitle}>{item.title}</Text>
249
- </View>
250
- )}
251
- </Animated.View>
252
- );
253
- })}
229
+ {/* We map over all data, but the logic inside CarouselCard handles visibility */}
230
+ {virtualData.map((item, index) => (
231
+ <CarouselCard
232
+ key={item.uniqueId}
233
+ item={item}
234
+ index={index}
235
+ activeIndex={activeIndex}
236
+ gestureTranslateX={gestureTranslateX}
237
+ cardWidth={cardWidth}
238
+ cardHeight={cardHeight}
239
+ virtualDataLength={virtualData.length}
240
+ />
241
+ ))}
254
242
  </Animated.View>
255
243
  </GestureDetector>
256
244
 
245
+ {/* Pagination dots remain the same */}
257
246
  {N_original > 0 && (
258
- <View style={styles.paginationContainer}>
259
- {originalData.map((_, i) => {
260
- const isActiveDot = useDerivedValue(() => {
261
- return activeDotIndex.value === i;
262
- });
263
- const dotStyle = useAnimatedStyle(() => {
264
- return {
265
- width: withSpring(isActiveDot.value ? 24 : 8, SPRING_CONFIG),
266
- height: 8,
267
- borderRadius: 4,
268
- backgroundColor: isActiveDot.value ? theme.primary : "#FFFFFF",
269
- marginHorizontal: 4,
270
- };
271
- });
272
- return <Animated.View key={`dot-${i}`} style={dotStyle} />;
273
- })}
274
- </View>
275
- )}
247
+ <View style={styles.paginationContainer}>
248
+ {originalData.map((_, i) => {
249
+ const isActiveDot = useDerivedValue(() => activeDotIndex.value === i);
250
+ const dotStyle = useAnimatedStyle(() => ({
251
+ width: withSpring(isActiveDot.value ? 24 : 8, SPRING_CONFIG),
252
+ height: 8,
253
+ borderRadius: 4,
254
+ backgroundColor: isActiveDot.value ? theme.primary : "#FFFFFF",
255
+ marginHorizontal: 4,
256
+ }));
257
+ return <Animated.View key={`dot-${i}`} style={dotStyle} />;
258
+ })}
259
+ </View>
260
+ )}
276
261
  </View>
277
262
  );
278
263
  };
@@ -296,7 +281,6 @@ const styles = StyleSheet.create({
296
281
  cardImage: {
297
282
  width: "100%",
298
283
  height: "100%",
299
- resizeMode: "cover",
300
284
  },
301
285
  titleContainer: {
302
286
  position: "absolute",
@@ -325,4 +309,4 @@ const styles = StyleSheet.create({
325
309
  },
326
310
  });
327
311
 
328
- export default CarouselCardStack;
312
+ export default CarouselCardStack;