Picker
A iOS inspired component made using React Native Reanimated and React Native Gesture Handler
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated react-native-worklets expo-blur react-native-gesture-handler expo-linear-gradient expo-hapticsCopy and paste the following code into your project.
component/organisms/picker
import { StyleSheet, Text, View, Pressable, ViewStyle, TextStyle, FlatList,} from "react-native";import { memo, useCallback, useEffect, useRef } from "react";import { LinearGradient } from "expo-linear-gradient";import Animated, { interpolate, useAnimatedScrollHandler, useSharedValue, useAnimatedStyle, Extrapolation, interpolateColor, useAnimatedProps,} from "react-native-reanimated";import * as Haptics from "expo-haptics";import { BlurView, type BlurViewProps } from "expo-blur";import { scheduleOnRN } from "react-native-worklets";import type { IPicker } from "./types";const AnimatedBlurView = Animated.createAnimatedComponent<BlurViewProps>(BlurView);const DEFAULT_ITEM_HEIGHT = 44;const DEFAULT_VISIBLE_ITEMS = 7;export const Picker: React.FC<IPicker> & React.FunctionComponent<IPicker> = memo<IPicker>( ({ items, onIndexChange, onItemChange, initialIndex = 0, itemHeight = DEFAULT_ITEM_HEIGHT, fontSize = 20, textColor = "#8E8E93", selectedTextColor = "#fff", backgroundColor = "#F2F2F7", hapticFeedback = true, selectionAreaBackgroundColor = "rgba(255, 255, 255, 0.06)", width, }: IPicker): | (React.ReactNode & React.ReactElement & React.JSX.Element) | null => { const scrollY = useSharedValue<number>(initialIndex * itemHeight); const lastSelectedIndex = useRef<number>(initialIndex); const flatListRef = useRef<FlatList>(null); const pickerHeight = itemHeight * DEFAULT_VISIBLE_ITEMS; useEffect(() => { if (flatListRef.current && initialIndex > 0) { setTimeout<[]>(() => { flatListRef.current?.scrollToOffset({ offset: initialIndex * itemHeight, animated: false, }); }, 100); } }, [initialIndex, itemHeight]); const triggerHaptic = useCallback<() => void>(() => { if (hapticFeedback) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } }, [hapticFeedback]); const handleIndexChange = useCallback( (index: number) => { const roundedIndex = Math.round(index); if (roundedIndex !== lastSelectedIndex.current) { lastSelectedIndex.current = roundedIndex; triggerHaptic(); if (onIndexChange) { onIndexChange(roundedIndex); } if ( onItemChange && roundedIndex >= 0 && roundedIndex < items.length ) { onItemChange(items[roundedIndex], roundedIndex); } } }, [items, onIndexChange, onItemChange, triggerHaptic], ); const AnimatedItem = useCallback( ({ item, index }: { item: string; index: number }) => { const animatedStyle = useAnimatedStyle< Pick<ViewStyle, "opacity" | "transform"> >(() => { const centerOffset = index * itemHeight; const distance = scrollY.value - centerOffset; const normalizedDistance = distance / itemHeight; const absDistance = Math.abs(normalizedDistance); const opacity = interpolate( absDistance, [0, 0.3, 0.6, 1, 1.5, 2], [1, 0.85, 0.6, 0.35, 0.15, 0.05], Extrapolation.CLAMP, ); const scale = interpolate( absDistance, [0, 1, 2], [1, 0.96, 0.94], Extrapolation.CLAMP, ); const translateY = interpolate( normalizedDistance, [-3, -2, -1, 0, 1, 2, 3], [15, 10, 5, 0, -5, -10, -15], Extrapolation.CLAMP, ); const rotateX = interpolate( normalizedDistance, [-3, -2, -1, 0, 1, 2, 3], [85, 60, 30, 0, -30, -60, -85], Extrapolation.CLAMP, ); return { opacity, transform: [ { perspective: 1400 }, { translateY }, { rotateX: `${rotateX}deg` }, { scale }, ], }; }); const textAnimatedStyle = useAnimatedStyle< Pick<TextStyle, "color" | "fontWeight"> >(() => { const centerOffset = index * itemHeight; const distance = scrollY.value - centerOffset; const normalizedDistance = Math.abs(distance / itemHeight); const isSelected = normalizedDistance < 0.5; return { color: interpolateColor( normalizedDistance, [0, 1], [selectedTextColor, textColor], ), fontWeight: isSelected ? "600" : "400", }; }); const blurAnimatedProps = useAnimatedProps<BlurViewProps>(() => { const centerOffset = index * itemHeight; const distance = Math.abs(scrollY.value - centerOffset); const normalizedDistance = distance / itemHeight; const blurOpacity = interpolate( normalizedDistance, [0, 0.6, 1.2, 2], [0, 2.5, 3.5, 6], Extrapolation.CLAMP, ); return { intensity: blurOpacity, }; }); return ( <Animated.View style={[ styles.item, { height: itemHeight, width: width }, animatedStyle, ]} > <Animated.Text style={[ styles.itemText, { fontSize, }, textAnimatedStyle, ]} numberOfLines={1} allowFontScaling={false} > {item} </Animated.Text> <AnimatedBlurView animatedProps={blurAnimatedProps} tint="light" style={[ StyleSheet.absoluteFill, { overflow: "hidden", borderRadius: 99, }, ]} pointerEvents="none" /> </Animated.View> ); }, [scrollY, itemHeight, fontSize, textColor, selectedTextColor], ); const onScroll = useAnimatedScrollHandler<Record<string, unknown>>({ onScroll: (event) => { scrollY.value = event.contentOffset.y; const interpolatedIndex = interpolate( event.contentOffset.y, items.map((_, i) => i * itemHeight), items.map((_, i) => i), Extrapolation.CLAMP, ); scheduleOnRN(handleIndexChange, interpolatedIndex); }, }); const scrollToIndex = useCallback( (index: number) => { flatListRef.current?.scrollToOffset({ offset: index * itemHeight, animated: true, }); }, [itemHeight], ); const renderItem = useCallback( ({ item, index }: { item: string; index: number }) => ( <Pressable key={index} onPress={() => scrollToIndex(index)}> <AnimatedItem item={item} index={index} /> </Pressable> ), [AnimatedItem, scrollToIndex], ); return ( <View style={[styles.container, { height: pickerHeight }]}> <View style={[styles.background, { backgroundColor }]} /> <Animated.FlatList ref={flatListRef} onScroll={onScroll} scrollEventThrottle={16} decelerationRate="fast" snapToAlignment="center" snapToInterval={itemHeight} contentContainerStyle={{ paddingVertical: (pickerHeight - itemHeight) / 2, }} showsVerticalScrollIndicator={false} data={items} renderItem={renderItem} keyExtractor={(item, index) => `${item}-${index}`} disableIntervalMomentum bounces={true} getItemLayout={(data, index) => ({ length: itemHeight, offset: itemHeight * index, index, })} /> <View style={[ styles.selectionArea, { top: (pickerHeight - itemHeight) / 2, height: itemHeight, width: width, backgroundColor: selectionAreaBackgroundColor, }, ]} pointerEvents="none" /> <LinearGradient colors={[ backgroundColor, backgroundColor, `${backgroundColor}F2`, `${backgroundColor}DD`, `${backgroundColor}CC`, `${backgroundColor}AA`, `${backgroundColor}77`, `${backgroundColor}33`, `${backgroundColor}00`, ]} locations={[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.65, 0.8, 1]} start={{ x: 0, y: 0 }} end={{ x: 0, y: 1 }} style={[ styles.gradient, styles.topGradient, { height: (pickerHeight - itemHeight) / 2 + 10 }, ]} pointerEvents="none" /> <LinearGradient colors={[ `${backgroundColor}00`, `${backgroundColor}33`, `${backgroundColor}77`, `${backgroundColor}AA`, `${backgroundColor}CC`, `${backgroundColor}DD`, `${backgroundColor}F2`, backgroundColor, backgroundColor, ]} locations={[0, 0.2, 0.35, 0.5, 0.6, 0.7, 0.8, 0.9, 1]} start={{ x: 0, y: 0 }} end={{ x: 0, y: 1 }} style={[ styles.gradient, styles.bottomGradient, { height: (pickerHeight - itemHeight) / 2 + 10 }, ]} pointerEvents="none" /> <View style={[ styles.selectionIndicator, { top: (pickerHeight - itemHeight) / 2, height: itemHeight, }, ]} pointerEvents="none" > <View style={styles.indicatorLine} /> <View style={[styles.indicatorLine, { bottom: 0, top: undefined }]} /> </View> </View> ); }, );const styles = StyleSheet.create({ container: { position: "relative", overflow: "hidden", borderRadius: 12, }, background: { ...StyleSheet.absoluteFillObject, }, item: { alignItems: "center", justifyContent: "center", }, itemText: { textAlign: "center", letterSpacing: -0.3, }, gradient: { position: "absolute", left: 0, right: 0, zIndex: 10, }, topGradient: { top: -5, }, bottomGradient: { bottom: -5, }, selectionArea: { position: "absolute", left: 0, right: 0, zIndex: 5, borderRadius: 20, }, selectionIndicator: { position: "absolute", left: 0, right: 0, justifyContent: "space-between", zIndex: 15, }, indicatorLine: { position: "absolute", top: 0, left: 0, right: 0, height: StyleSheet.hairlineWidth, backgroundColor: "rgba(60, 60, 67, 0.29)", },});Usage
import { View, StyleSheet, Text } from "react-native";import { useState } from "react";import { GestureHandlerRootView } from "react-native-gesture-handler";import { Picker } from "@/components/organisms/picker";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";export default function App() { const [selected, setSelected] = useState("Day"); const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <Text style={[styles.label, fontLoaded && { fontFamily: "SfProRounded" }]} > Select a time </Text> <View style={styles.pickerWrapper}> <Picker items={["Day", "Afternoon", "Evening", "Night"]} backgroundColor="#000" hapticFeedback={true} onItemChange={(item) => setSelected(item)} /> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#000", alignItems: "center", // justifyContent: "cen1ter", paddingHorizontal: 50, marginTop: 90, }, label: { color: "#666", fontSize: 16, position: "absolute", top: 70, zIndex: 222, }, selected: { color: "#fff", fontSize: 48, marginBottom: 40, }, pickerWrapper: { width: "100%", },});Props
React Native Reanimated
React Native Worklets
Expo Blur
React Native Gesture Handler
Expo Haptics
Expo Linear Gradient
