|
| 1 | +import { |
| 2 | + SafeAreaView, |
| 3 | + StyleSheet, |
| 4 | + View, |
| 5 | + Text, |
| 6 | + TouchableOpacity, |
| 7 | + Pressable, |
| 8 | +} from 'react-native'; |
| 9 | +import type { |
| 10 | + FlatList, |
| 11 | + LayoutChangeEvent, |
| 12 | + NativeScrollEvent, |
| 13 | + NativeSyntheticEvent, |
| 14 | + ViewStyle, |
| 15 | +} from 'react-native'; |
| 16 | +import { useCallback, useMemo, useRef, useState } from 'react'; |
| 17 | +import Animated, { |
| 18 | + FadeInRight, |
| 19 | + FadeOutLeft, |
| 20 | + interpolate, |
| 21 | + runOnJS, |
| 22 | + useAnimatedScrollHandler, |
| 23 | + useAnimatedStyle, |
| 24 | + useSharedValue, |
| 25 | + ZoomIn, |
| 26 | + ZoomOut, |
| 27 | +} from 'react-native-reanimated'; |
| 28 | +import type { SharedValue } from 'react-native-reanimated'; |
| 29 | + |
| 30 | +type Option = { |
| 31 | + key: string; |
| 32 | + emoji?: string; |
| 33 | + label: string; |
| 34 | +}; |
| 35 | + |
| 36 | +type OptionProps = { |
| 37 | + onChange: (option: Option) => void; |
| 38 | +}; |
| 39 | + |
| 40 | +const data: Option[] = [ |
| 41 | + { key: '1', emoji: '😭', label: '1' }, |
| 42 | + { key: '2', emoji: '💀', label: '2' }, |
| 43 | + { key: '3', emoji: '🎩', label: '3' }, |
| 44 | + { key: '4', emoji: '🥹', label: '4' }, |
| 45 | +]; |
| 46 | + |
| 47 | +const OptionInput = ({ onChange }: OptionProps) => { |
| 48 | + const [activeOption, setActiveOption] = useState<Option>(data[0]); |
| 49 | + |
| 50 | + const handleSelect = (option: Option) => { |
| 51 | + setActiveOption(option); |
| 52 | + onChange && onChange(option); |
| 53 | + }; |
| 54 | + |
| 55 | + return ( |
| 56 | + <View style={styles.optionContainer}> |
| 57 | + {data.map((option) => ( |
| 58 | + <Box |
| 59 | + key={option.key} |
| 60 | + option={option} |
| 61 | + isActive={option.key === activeOption?.key} |
| 62 | + onSelect={handleSelect} |
| 63 | + /> |
| 64 | + ))} |
| 65 | + </View> |
| 66 | + ); |
| 67 | +}; |
| 68 | + |
| 69 | +type BoxProps = { |
| 70 | + option: Option; |
| 71 | + isActive: boolean; |
| 72 | + onSelect: (option: Option) => void; |
| 73 | +}; |
| 74 | + |
| 75 | +const Box = ({ option, isActive, onSelect }: BoxProps) => { |
| 76 | + return ( |
| 77 | + <TouchableOpacity |
| 78 | + style={[styles.boxContainer, isActive && { borderColor: '#AAFFAA' }]} |
| 79 | + activeOpacity={0.6} |
| 80 | + onPress={() => onSelect(option)}> |
| 81 | + <View style={styles.boxContent}> |
| 82 | + <View style={styles.optionContent}> |
| 83 | + {option.emoji && <Text style={styles.emoji}>{option.emoji}</Text>} |
| 84 | + <Text>{option.label}</Text> |
| 85 | + </View> |
| 86 | + <View |
| 87 | + style={[ |
| 88 | + styles.circle, |
| 89 | + isActive && { |
| 90 | + borderColor: 'black', |
| 91 | + backgroundColor: '#AAFFAA', |
| 92 | + }, |
| 93 | + ]}> |
| 94 | + {isActive && ( |
| 95 | + <Animated.View |
| 96 | + key={`option)-${option.label}`} |
| 97 | + entering={ZoomIn} |
| 98 | + exiting={ZoomOut}> |
| 99 | + <Text>✔️</Text> |
| 100 | + </Animated.View> |
| 101 | + )} |
| 102 | + </View> |
| 103 | + </View> |
| 104 | + </TouchableOpacity> |
| 105 | + ); |
| 106 | +}; |
| 107 | + |
| 108 | +type Props = { |
| 109 | + item: string | number; |
| 110 | + index: number; |
| 111 | + contentOffsetY: SharedValue<number>; |
| 112 | + itemHeight: number; |
| 113 | +}; |
| 114 | + |
| 115 | +const Item = ({ item, index, contentOffsetY, itemHeight }: Props) => { |
| 116 | + const animatedStyle = useAnimatedStyle(() => { |
| 117 | + const inputRange = [ |
| 118 | + itemHeight * (index - 3), |
| 119 | + itemHeight * (index - 2), |
| 120 | + itemHeight * (index - 1), |
| 121 | + itemHeight * index, |
| 122 | + itemHeight * (index + 1), |
| 123 | + itemHeight * (index + 2), |
| 124 | + itemHeight * (index + 3), |
| 125 | + ]; |
| 126 | + |
| 127 | + const opacity = interpolate( |
| 128 | + contentOffsetY.value, |
| 129 | + inputRange, |
| 130 | + [0.1, 0.2, 0.35, 1, 0.35, 0.2, 0.1], |
| 131 | + 'clamp' |
| 132 | + ); |
| 133 | + |
| 134 | + return { |
| 135 | + opacity, |
| 136 | + }; |
| 137 | + }); |
| 138 | + |
| 139 | + return ( |
| 140 | + <Animated.View |
| 141 | + style={[styles.itemContainer, { height: itemHeight }, animatedStyle]}> |
| 142 | + <Text style={styles.itemText}>{item.toString()}</Text> |
| 143 | + </Animated.View> |
| 144 | + ); |
| 145 | +}; |
| 146 | + |
| 147 | +interface WheelPickerProps { |
| 148 | + minValue: number; |
| 149 | + maxValue: number; |
| 150 | + initialValue?: number; |
| 151 | + itemHeight?: number; |
| 152 | + highlightStyle?: ViewStyle; |
| 153 | +} |
| 154 | + |
| 155 | +const WheelPicker: React.FC<WheelPickerProps> = ({ |
| 156 | + minValue, |
| 157 | + maxValue, |
| 158 | + initialValue, |
| 159 | + itemHeight = 60, |
| 160 | + highlightStyle, |
| 161 | +}) => { |
| 162 | + const [containerHeight, setContainerHeight] = useState(0); |
| 163 | + const flatListRef = useRef<FlatList>(null); |
| 164 | + const contentOffsetY = useSharedValue(0); |
| 165 | + const lastSelectedIndex = useRef(-1); |
| 166 | + |
| 167 | + const data = useMemo( |
| 168 | + () => |
| 169 | + Array.from({ length: maxValue - minValue + 1 }, (_, i) => i + minValue), |
| 170 | + [minValue, maxValue] |
| 171 | + ); |
| 172 | + |
| 173 | + const calculateSelectedValue = useCallback( |
| 174 | + (offsetY: number) => { |
| 175 | + const index = Math.round(offsetY / itemHeight); |
| 176 | + if (index !== lastSelectedIndex.current) { |
| 177 | + lastSelectedIndex.current = index; |
| 178 | + } |
| 179 | + }, |
| 180 | + [itemHeight] |
| 181 | + ); |
| 182 | + |
| 183 | + const onScroll = useAnimatedScrollHandler({ |
| 184 | + onScroll: (event) => { |
| 185 | + contentOffsetY.value = event.contentOffset.y; |
| 186 | + runOnJS(calculateSelectedValue)(event.contentOffset.y); |
| 187 | + }, |
| 188 | + }); |
| 189 | + |
| 190 | + const onScrollEnd = (event: NativeSyntheticEvent<NativeScrollEvent>) => { |
| 191 | + calculateSelectedValue(event.nativeEvent.contentOffset.y); |
| 192 | + }; |
| 193 | + |
| 194 | + const onLayout = (event: LayoutChangeEvent) => { |
| 195 | + setContainerHeight(event.nativeEvent.layout.height); |
| 196 | + }; |
| 197 | + |
| 198 | + return ( |
| 199 | + <View onLayout={onLayout}> |
| 200 | + <Animated.FlatList |
| 201 | + ref={flatListRef} |
| 202 | + data={data} |
| 203 | + keyExtractor={(item) => item.toString()} |
| 204 | + showsVerticalScrollIndicator={false} |
| 205 | + scrollEventThrottle={16} |
| 206 | + snapToInterval={itemHeight} |
| 207 | + decelerationRate="fast" |
| 208 | + bounces={false} |
| 209 | + onScroll={onScroll} |
| 210 | + onMomentumScrollEnd={onScrollEnd} |
| 211 | + onScrollEndDrag={onScrollEnd} |
| 212 | + renderItem={({ item, index }) => ( |
| 213 | + <Item |
| 214 | + index={index} |
| 215 | + item={item} |
| 216 | + itemHeight={itemHeight} |
| 217 | + contentOffsetY={contentOffsetY} |
| 218 | + /> |
| 219 | + )} |
| 220 | + contentContainerStyle={{ |
| 221 | + paddingTop: containerHeight / 2 - itemHeight / 2, |
| 222 | + paddingBottom: containerHeight / 2 - itemHeight / 2, |
| 223 | + }} |
| 224 | + initialScrollIndex={initialValue ? data.indexOf(initialValue) : 0} |
| 225 | + getItemLayout={(_, index) => ({ |
| 226 | + length: itemHeight, |
| 227 | + offset: itemHeight * index, |
| 228 | + index, |
| 229 | + })} |
| 230 | + /> |
| 231 | + <View |
| 232 | + style={[ |
| 233 | + styles.activeLine, |
| 234 | + highlightStyle, |
| 235 | + { |
| 236 | + borderWidth: 1, |
| 237 | + width: 300, |
| 238 | + backgroundColor: '#DDDDDD10', |
| 239 | + top: containerHeight / 2 - itemHeight / 2, |
| 240 | + height: itemHeight, |
| 241 | + }, |
| 242 | + ]} |
| 243 | + /> |
| 244 | + </View> |
| 245 | + ); |
| 246 | +}; |
| 247 | + |
| 248 | +export default function Example() { |
| 249 | + const [value, setValue] = useState<boolean>(true); |
| 250 | + |
| 251 | + return ( |
| 252 | + <SafeAreaView style={styles.container}> |
| 253 | + <Pressable |
| 254 | + style={styles.buttonContainer} |
| 255 | + onPress={() => setValue((x) => !x)}> |
| 256 | + <Animated.View style={styles.button}> |
| 257 | + <Animated.Text style={styles.buttonText}>Continue</Animated.Text> |
| 258 | + </Animated.View> |
| 259 | + </Pressable> |
| 260 | + {value && ( |
| 261 | + <Animated.View |
| 262 | + key="OptionInput" |
| 263 | + entering={FadeInRight} |
| 264 | + exiting={FadeOutLeft} |
| 265 | + style={styles.content}> |
| 266 | + <OptionInput onChange={() => {}} /> |
| 267 | + </Animated.View> |
| 268 | + )} |
| 269 | + |
| 270 | + {!value && ( |
| 271 | + <Animated.View |
| 272 | + key="WheelPicker" |
| 273 | + entering={FadeInRight} |
| 274 | + exiting={FadeOutLeft} |
| 275 | + style={styles.content}> |
| 276 | + <WheelPicker minValue={16} maxValue={99} initialValue={30} /> |
| 277 | + </Animated.View> |
| 278 | + )} |
| 279 | + </SafeAreaView> |
| 280 | + ); |
| 281 | +} |
| 282 | + |
| 283 | +const styles = StyleSheet.create({ |
| 284 | + optionContainer: { |
| 285 | + justifyContent: 'center', |
| 286 | + flexDirection: 'row', |
| 287 | + flexWrap: 'wrap', |
| 288 | + gap: 10, |
| 289 | + }, |
| 290 | + boxContainer: { |
| 291 | + width: 150, |
| 292 | + borderRadius: 10, |
| 293 | + borderWidth: 2, |
| 294 | + backgroundColor: '#5555FF77', |
| 295 | + }, |
| 296 | + boxContent: { |
| 297 | + justifyContent: 'center', |
| 298 | + alignItems: 'center', |
| 299 | + paddingHorizontal: 22, |
| 300 | + paddingTop: 20, |
| 301 | + paddingBottom: 26, |
| 302 | + gap: 16, |
| 303 | + }, |
| 304 | + optionContent: { |
| 305 | + gap: 8, |
| 306 | + justifyContent: 'center', |
| 307 | + alignItems: 'center', |
| 308 | + }, |
| 309 | + emoji: { |
| 310 | + fontSize: 48, |
| 311 | + }, |
| 312 | + circle: { |
| 313 | + width: 30, |
| 314 | + height: 30, |
| 315 | + borderRadius: 15, |
| 316 | + borderWidth: 1, |
| 317 | + borderColor: 'transparent', |
| 318 | + alignItems: 'center', |
| 319 | + justifyContent: 'center', |
| 320 | + }, |
| 321 | + itemContainer: { |
| 322 | + justifyContent: 'center', |
| 323 | + alignItems: 'center', |
| 324 | + width: 300, |
| 325 | + }, |
| 326 | + container: { |
| 327 | + flex: 1, |
| 328 | + justifyContent: 'center', |
| 329 | + backgroundColor: '#ecf0f1', |
| 330 | + padding: 8, |
| 331 | + }, |
| 332 | + content: { |
| 333 | + flex: 1, |
| 334 | + alignItems: 'center', |
| 335 | + }, |
| 336 | + activeLine: { |
| 337 | + position: 'absolute', |
| 338 | + borderRadius: 99, |
| 339 | + zIndex: -5, |
| 340 | + }, |
| 341 | + buttonContainer: { |
| 342 | + justifyContent: 'flex-end', |
| 343 | + alignItems: 'center', |
| 344 | + marginVertical: 16, |
| 345 | + }, |
| 346 | + button: { |
| 347 | + backgroundColor: 'black', |
| 348 | + padding: 20, |
| 349 | + borderRadius: 8, |
| 350 | + alignItems: 'center', |
| 351 | + justifyContent: 'center', |
| 352 | + width: '80%', |
| 353 | + }, |
| 354 | + buttonText: { |
| 355 | + fontSize: 25, |
| 356 | + fontWeight: 'bold', |
| 357 | + color: 'white', |
| 358 | + }, |
| 359 | + itemText: { fontWeight: 'bold', fontSize: 30 }, |
| 360 | +}); |
0 commit comments