Componentes React Native — Cultiva Hábitos
Código TypeScript listo para producción basado en los patrones de la skill diseno-react-native: StyleSheet tipado, animaciones Reanimated 3 en el hilo UI y navegación con tipos seguros.
HabitCard — Componente Animado Reanimated 3 StyleSheet
// components/HabitCard.tsx import React from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, interpolateColor, } from 'react-native-reanimated'; interface HabitCardProps { name: string; duration: string; icon: string; iconBg: string; status: 'done' | 'pending' | 'progress'; progress?: number; // 0-1 para status 'progress' onPress: () => void; } const AnimatedPressable = Animated.createAnimatedComponent(Pressable); export function HabitCard({ name, duration, icon, iconBg, status, progress = 0, onPress }: HabitCardProps) { const scale = useSharedValue(1); const completed = useSharedValue(status === 'done' ? 1 : 0); const cardStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], backgroundColor: interpolateColor( completed.value, [0, 1], ['#ffffff', '#f0fdf4'] ), })); const handleComplete = () => { completed.value = withSpring(1, { damping: 15, stiffness: 150 }); onPress(); }; return ( <AnimatedPressable style={[styles.card, cardStyle]} onPressIn={() => { scale.value = withSpring(0.97); }} onPressOut={() => { scale.value = withSpring(1); }} onPress={handleComplete} > <View style={[styles.iconWrap, { backgroundColor: iconBg }]}> <Text style={styles.iconText}>{icon}</Text> </View> <View style={styles.info}> <Text style={styles.name}>{name}</Text> <Text style={styles.meta}>{duration}</Text> {status === 'progress' && ( <View style={styles.progressBar}> <Animated.View style={[styles.progressFill, { width: `${progress * 100}%` }]} /> </View> )} </View> <CheckCircle status={status} /> </AnimatedPressable> ); } const styles = StyleSheet.create({ card: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 14, borderRadius: 16, borderWidth: 1.5, borderColor: '#e2e8f0', shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2, }, iconWrap: { width: 40, height: 40, borderRadius: 12, alignItems: 'center', justifyContent: 'center' }, iconText: { fontSize: 20 }, info: { flex: 1, gap: 2 }, name: { fontSize: 15, fontWeight: '600', color: '#1e293b' }, meta: { fontSize: 12, color: '#64748b' }, progressBar: { height: 4, backgroundColor: '#e2e8f0', borderRadius: 99, marginTop: 6, overflow: 'hidden' }, progressFill: { height: '100%', backgroundColor: '#8b5cf6', borderRadius: 99 }, });
ProgressRing — Animación SVG con Reanimated 3 worklet
// components/ProgressRing.tsx import Animated, { useSharedValue, useAnimatedProps, withTiming, Easing, } from 'react-native-reanimated'; import Svg, { Circle } from 'react-native-svg'; import { useEffect } from 'react'; const AnimatedCircle = Animated.createAnimatedComponent(Circle); const RADIUS = 28; const CIRCUMFERENCE = 2 * Math.PI * RADIUS; // 175.93 interface ProgressRingProps { progress: number; // 0-1 size?: number; color?: string; trackColor?: string; strokeWidth?: number; } export function ProgressRing({ progress, size = 72, color = '#22c55e', trackColor = '#e2e8f0', strokeWidth = 8 }: ProgressRingProps) { const animProgress = useSharedValue(0); useEffect(() => { animProgress.value = withTiming(progress, { duration: 1200, easing: Easing.out(Easing.cubic), }); }, [progress]); const animatedProps = useAnimatedProps(() => ({ strokeDashoffset: CIRCUMFERENCE * (1 - animProgress.value), })); return ( <Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ transform: [{ rotate: '-90deg' }] }}> <Circle cx={size / 2} cy={size / 2} r={RADIUS} fill="none" stroke={trackColor} strokeWidth={strokeWidth} /> <AnimatedCircle cx={size / 2} cy={size / 2} r={RADIUS} fill="none" stroke={color} strokeWidth={strokeWidth} strokeDasharray={CIRCUMFERENCE} animatedProps={animatedProps} strokeLinecap="round" /> </Svg> ); }
Navegación tipada — Bottom Tabs + Stack React Navigation 6 TypeScript
// navigation/types.ts export type RootTabParamList = { Home: undefined; Explore: undefined; Stats: undefined; Profile: undefined; }; export type HomeStackParamList = { HomeScreen: undefined; HabitDetail: { habitId: string; habitName: string }; AddHabit: undefined; }; // navigation/AppNavigator.tsx import { NavigationContainer } from '@react-navigation/native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; const Tab = createBottomTabNavigator<RootTabParamList>(); const HomeStack = createNativeStackNavigator<HomeStackParamList>(); function HomeStackNavigator() { return ( <HomeStack.Navigator screenOptions={{ headerTintColor: '#22c55e', headerTitleStyle: { fontWeight: '700' }, animation: 'ios_from_right', }}> <HomeStack.Screen name="HomeScreen" component={HomeScreen} /> <HomeStack.Screen name="HabitDetail" component={HabitDetailScreen} options={({ route }) => ({ title: route.params.habitName })} /> <HomeStack.Screen name="AddHabit" component={AddHabitScreen} options={{ presentation: 'modal' }} /> </HomeStack.Navigator> ); } export function AppNavigator() { return ( <NavigationContainer> <Tab.Navigator screenOptions={({ route }) => ({ tabBarIcon: ({ focused, color }) => { const icons: Record<keyof RootTabParamList, string> = { Home: focused ? '🏠' : '🏡', Explore: '🔍', Stats: '📊', Profile: '👤', }; return <Text>{icons[route.name]}</Text>; }, tabBarActiveTintColor: '#22c55e', tabBarInactiveTintColor: '#94a3b8', tabBarStyle: { borderTopColor: '#f1f5f9', paddingTop: 6, paddingBottom: 10, }, headerShown: false, })}>> <Tab.Screen name="Home" component={HomeStackNavigator} /> <Tab.Screen name="Explore" component={ExploreScreen} /> <Tab.Screen name="Stats" component={StatsScreen} /> <Tab.Screen name="Profile" component={ProfileScreen} /> </Tab.Navigator> </NavigationContainer> ); }
Sistema de Tema — Light / Dark Context API
// theme/cultivaTheme.ts export const cultivaLight = { colors: { primary: '#22c55e', primaryLight: '#dcfce7', secondary: '#8b5cf6', background: '#fafafa', surface: '#ffffff', text: '#1e293b', textSecondary: '#64748b', border: '#e2e8f0', success: '#22c55e', warning: '#f59e0b', danger: '#ef4444', }, spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48, }, borderRadius: { sm: 8, md: 12, lg: 16, xl: 24, full: 9999, }, typography: { h1: { fontSize: 32, fontWeight: '800', lineHeight: 40 }, h2: { fontSize: 24, fontWeight: '700', lineHeight: 32 }, h3: { fontSize: 20, fontWeight: '600', lineHeight: 28 }, body: { fontSize: 16, fontWeight: '400', lineHeight: 24 }, small: { fontSize: 13, fontWeight: '400', lineHeight: 18 }, label: { fontSize: 12, fontWeight: '600', lineHeight: 16 }, }, } as const; export const cultivaDark = { ...cultivaLight, colors: { ...cultivaLight.colors, background: '#0f172a', surface: '#1e293b', text: '#f1f5f9', textSecondary: '#94a3b8', border: '#334155', primary: '#4ade80', secondary: '#a78bfa', }, } as const; export type CultivaTheme = typeof cultivaLight;
Buenas Prácticas — Rendimiento y UX
Worklets en hilo UI
Usa useAnimatedStyle y runOnUI para mantener 60fps en animaciones sin bloquear el JS thread.
FlatList siempre
Nunca ScrollView + .map() para listas largas. Usa FlatList con keyExtractor y getItemLayout.
TypeScript estricto
Define ParamList para cada navigator y usa NativeStackScreenProps en cada pantalla para evitar errores en runtime.
Safe Area siempre
Wrap con useSafeAreaInsets() en pantallas con contenido cercano a notch o home indicator.
StyleSheet.create
Nunca inline styles en renders repetidos. StyleSheet.create optimiza la serialización al native layer.
Dark mode nativo
Usa useColorScheme() en el ThemeProvider para cambio automático. Prueba en simulador con Appearance.setColorScheme.