Εισαγωγή: Γιατί τα Animations αλλάζουν τα πάντα στο Mobile UX
Αν έχετε δουλέψει ποτέ με μια εφαρμογή που "κολλάει" στα transitions ή τα gestures αποκρίνονται με μια ενοχλητική καθυστέρηση, τότε ξέρετε ακριβώς πόσο κρίσιμη είναι η ομαλή κίνηση. Τα animations δεν είναι απλά διακοσμητικά στοιχεία — είναι feedback. Λένε στον χρήστη "κάτι συμβαίνει", "η ενέργειά σου καταγράφηκε". Χωρίς αυτά, μια εφαρμογή νιώθει νεκρή, σαν να μιλάς σε τοίχο.
Στο React Native, τα animations ήταν πάντα μια πρόκληση. Το ενσωματωμένο Animated API έχει σοβαρούς περιορισμούς στην απόδοση — κυρίως λόγω της επικοινωνίας μεταξύ JavaScript thread και UI thread. Κι εδώ μπαίνει στη σκηνή το React Native Reanimated, μια βιβλιοθήκη που εκτελεί τα animations απευθείας στο UI thread, εξασφαλίζοντας 60+ FPS ακόμα κι όταν το JS thread είναι απασχολημένο με κάτι άλλο.
Τον Μάρτιο του 2026, η έκδοση 4.2.2 του Reanimated έφερε μαζί της μια αλλαγή που, ειλικρινά, άλλαξε τον τρόπο που γράφουμε animations: CSS-based animations και transitions, διαχωρισμό των worklets σε ξεχωριστό πακέτο, και αποκλειστική υποστήριξη της New Architecture. Σε αυτόν τον οδηγό θα δούμε τα πάντα — από την εγκατάσταση μέχρι πρακτικά παραδείγματα με gestures και σύνθετα animations.
Τι είναι το Reanimated 4 και τι αλλάζει
Αποκλειστική υποστήριξη New Architecture (Fabric)
Η πιο σημαντική αρχιτεκτονική αλλαγή: το Reanimated 4.x λειτουργεί αποκλειστικά με τη New Architecture (Fabric). Αν το project σας χρησιμοποιεί ακόμα το παλιό Bridge, θα πρέπει πρώτα να κάνετε μετάβαση. Η New Architecture προσφέρει σύγχρονη επικοινωνία μεταξύ JavaScript και native layers — κάτι που είναι κρίσιμο για animations υψηλής απόδοσης.
Αν δεν έχετε ήδη μεταβεί, ρίξτε μια ματιά στον οδηγό μας για τη New Architecture του React Native.
CSS Animations και Transitions API
Αυτή είναι η μεγαλύτερη προσθήκη, και κατά τη γνώμη μου η πιο σημαντική. Η ομάδα του Software Mansion πρόσθεσε ένα πλήρες declarative API βασισμένο στα CSS animations και transitions. Τι σημαίνει αυτό στην πράξη; Μπορείτε πλέον να γράφετε animations χρησιμοποιώντας γνωστή σύνταξη CSS — keyframes, transition properties, timing functions — χωρίς να χρειάζεστε shared values ή worklets για τα πιο συνηθισμένα σενάρια.
Η υλοποίηση τρέχει εξ ολοκλήρου στο UI thread, οπότε η απόδοση παραμένει εξαιρετική.
Τα Worklets μετακόμισαν
Τα worklets πλέον ζουν στο ξεχωριστό πακέτο react-native-worklets. Ο λόγος; Αυτός ο διαχωρισμός επιτρέπει και σε άλλες βιβλιοθήκες (όχι μόνο στο Reanimated) να αξιοποιήσουν τη λειτουργικότητα των worklets. Το Babel plugin αλλάζει αντίστοιχα — τώρα χρησιμοποιείτε 'react-native-worklets/plugin' αντί για 'react-native-reanimated/plugin'.
Ενημερωμένο withSpring API
Το withSpring πέρασε σημαντικό refactoring. Τα παλιά restDisplacementThreshold και restSpeedThreshold αντικαταστάθηκαν από ένα ενιαίο energyThreshold. Αυτό κάνει τη ρύθμιση πιο διαισθητική και μειώνει αρκετά τα edge cases που μας ταλαιπωρούσαν.
Εγκατάσταση με Expo SDK 53
Αν χρησιμοποιείτε Expo SDK 53+, έχω καλά νέα: το Reanimated είναι ήδη προεγκατεστημένο. Δε χρειάζεται να κάνετε τίποτα εκτός από μια γρήγορη επαλήθευση.
# Επαλήθευση ότι το Reanimated είναι εγκατεστημένο
npx expo install --check react-native-reanimated
# Αν για κάποιον λόγο δεν υπάρχει, εγκαταστήστε το μαζί με τα worklets
npx expo install react-native-reanimated react-native-worklets
# Εγκατάσταση του Gesture Handler (επίσης συνήθως προεγκατεστημένο)
npx expo install react-native-gesture-handler
Στο Expo, το Babel plugin ρυθμίζεται αυτόματα μέσω του babel-preset-expo, οπότε δε χρειάζεται χειροκίνητη παρέμβαση. Ας δούμε ένα απλό test component για να βεβαιωθούμε ότι όλα δουλεύουν σωστά:
// App.tsx — Δοκιμή ότι το Reanimated λειτουργεί σωστά
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
export default function ReanimatedTestScreen() {
const offset = useSharedValue<number>(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: offset.value }],
}));
return (
<View style={styles.container}>
<Animated.View
style={[styles.box, animatedStyle]}
onTouchEnd={() => {
// Αν πατήσετε το κουτί, θα κινηθεί με spring animation
offset.value = withSpring(offset.value === 0 ? 150 : 0, {
damping: 12,
stiffness: 90,
energyThreshold: 0.01,
});
}}
>
<Text style={styles.text}>Πατήστε με!</Text>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#F8F9FA' },
box: {
width: 160,
height: 80,
backgroundColor: '#6C63FF',
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
text: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' },
});
Αν το κουτί κινείται ομαλά με spring animation όταν το πατήσετε, είστε έτοιμοι. Τόσο απλό.
Βασικές έννοιες: Shared Values, Animated Styles, Worklets
Shared Values — Η καρδιά του Reanimated
Τα Shared Values είναι ειδικές μεταβλητές που υπάρχουν ταυτόχρονα στο JavaScript thread και στο UI thread. Δημιουργούνται με το hook useSharedValue και ενημερώνονται μέσω της ιδιότητας .value (ή των μεθόδων .get()/.set() αν χρησιμοποιείτε τον React Compiler).
Το κρίσιμο πλεονέκτημα; Η αλλαγή ενός shared value δεν προκαλεί re-render. Η ενημέρωση φτάνει κατευθείαν στο UI thread, και γι' αυτό τα animations είναι τόσο ομαλά.
Animated Styles — Η γέφυρα προς το UI
Το useAnimatedStyle συνδέει τα shared values με τα styles ενός component. Κάθε φορά που αλλάζει ένα shared value μέσα στο callback, το στυλ υπολογίζεται ξανά αυτόματα — όλα στο UI thread, χωρίς να μπλοκάρει τίποτα.
Worklets — Κώδικας στο UI Thread
Τα worklets είναι συναρτήσεις που τρέχουν στο UI thread αντί στο JavaScript thread. Σημειώνονται με τη δήλωση 'worklet' στην αρχή τους. Χάρη στο νέο πακέτο react-native-worklets, μπορείτε πλέον να χρησιμοποιήσετε worklets ανεξάρτητα από το Reanimated — κάτι που ανοίγει πολλές πόρτες. Αν χρειαστεί να καλέσετε μια κανονική JS συνάρτηση μέσα από worklet, χρησιμοποιήστε το runOnJS.
import { runOnJS } from 'react-native-reanimated';
import { useSharedValue, useAnimatedStyle } from 'react-native-reanimated';
// Συνάρτηση worklet — τρέχει στο UI thread
function clampValue(value: number, min: number, max: number): number {
'worklet';
return Math.min(Math.max(value, min), max);
}
// Κανονική JS συνάρτηση
function logToConsole(message: string): void {
console.log('[Animation]:', message);
}
// Παράδειγμα χρήσης σε gesture callback
function onGestureEvent(translationY: number): void {
'worklet';
// Κλείδωμα της τιμής μεταξύ 0 και 300
const clamped = clampValue(translationY, 0, 300);
// Για κλήση JS συνάρτησης μέσα από worklet, χρησιμοποιούμε runOnJS
runOnJS(logToConsole)(`Μετατόπιση: ${clamped}`);
}
Μια σημαντική συμβουλή: αποφύγετε να "αιχμαλωτίζετε" μεγάλα JavaScript objects μέσα σε worklets. Αντί να περνάτε ολόκληρο ένα config object, εξάγετε μόνο τις τιμές που χρειάζεστε σε ξεχωριστές μεταβλητές. Αλλιώς θα πληρώσετε σε performance λόγω serialization μεταξύ threads.
CSS Animations — Το νέο declarative API (v4)
Λοιπόν, ας μιλήσουμε για αυτό που πραγματικά αλλάζει τα δεδομένα στο Reanimated 4. Αν έχετε εμπειρία με CSS animations στο web, θα νιώσετε σαν στο σπίτι σας. Ορίζετε keyframes ως JavaScript objects και τα εφαρμόζετε μέσω inline styles — χωρίς shared values, χωρίς useAnimatedStyle, χωρίς worklets. Ναι, διαβάσατε σωστά.
Κάθε κλειδί στο object αντιπροσωπεύει ένα σημείο στο timeline (π.χ. '0%', '50%', '100%' ή from/to), και η τιμή είναι τα styles εκείνης της στιγμής.
// components/PulsingButton.tsx — Animation κουμπιού με keyframes
import React from 'react';
import { Text, StyleSheet } from 'react-native';
import Animated from 'react-native-reanimated';
// Ορισμός keyframes για ένα "πάλλον" κουμπί
const pulseKeyframes = {
from: {
transform: [{ scale: 1 }],
opacity: 1,
},
'50%': {
transform: [{ scale: 1.08 }],
opacity: 0.85,
},
to: {
transform: [{ scale: 1 }],
opacity: 1,
},
};
// Keyframes για ένα φαινόμενο "αιώρησης"
const floatKeyframes = {
'0%': {
transform: [{ translateY: 0 }, { rotate: '0deg' }],
},
'25%': {
transform: [{ translateY: -10 }, { rotate: '2deg' }],
},
'75%': {
transform: [{ translateY: -10 }, { rotate: '-2deg' }],
},
'100%': {
transform: [{ translateY: 0 }, { rotate: '0deg' }],
},
};
export default function PulsingButton() {
return (
<Animated.View
style={[
styles.button,
{
// CSS animation ιδιότητες — ακριβώς όπως στο CSS
animationName: pulseKeyframes,
animationDuration: 2000,
animationIterationCount: 'infinite',
animationTimingFunction: 'ease-in-out',
},
]}
>
<Text style={styles.buttonText}>Προσθήκη στο καλάθι</Text>
</Animated.View>
);
}
export function FloatingBadge() {
return (
<Animated.View
style={[
styles.badge,
{
animationName: floatKeyframes,
animationDuration: 3000,
animationIterationCount: 'infinite',
animationDirection: 'alternate',
animationTimingFunction: 'ease-in-out',
},
]}
>
<Text style={styles.badgeText}>Νέο!</Text>
</Animated.View>
);
}
const styles = StyleSheet.create({
button: {
backgroundColor: '#6C63FF',
paddingHorizontal: 32,
paddingVertical: 16,
borderRadius: 12,
alignItems: 'center',
},
buttonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '700' },
badge: {
backgroundColor: '#FF6B6B',
width: 64,
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
},
badgeText: { color: '#FFFFFF', fontSize: 13, fontWeight: '800' },
});
Οι ιδιότητες animation που υποστηρίζονται είναι: animationName, animationDuration, animationDelay, animationTimingFunction, animationIterationCount, animationDirection, και animationFillMode. Ουσιαστικά ό,τι ξέρετε από CSS animations στο web, μεταφέρεται εδώ χωρίς εκπλήξεις.
CSS Transitions — Μεταβάσεις μεταξύ καταστάσεων (v4)
Τα CSS transitions είναι ιδανικά για σενάρια που θέλετε να κάνετε animate μια αλλαγή state. Αντί να δημιουργείτε shared values και useAnimatedStyle, απλά δηλώνετε ποιες ιδιότητες θέλετε να κάνουν transition και πώς. Η αλλαγή state αναλαμβάνει τα υπόλοιπα.
Είναι τρελά βολικό, δείτε μόνοι σας:
// components/ExpandableCard.tsx — Κάρτα με CSS transitions
import React, { useState } from 'react';
import { Pressable, Text, StyleSheet, View } from 'react-native';
import Animated from 'react-native-reanimated';
export default function ExpandableCard() {
const [expanded, setExpanded] = useState<boolean>(false);
return (
<View style={styles.container}>
<Pressable onPress={() => setExpanded((prev) => !prev)}>
<Animated.View
style={{
// Δυναμικές ιδιότητες βασισμένες στο state
width: expanded ? 320 : 180,
height: expanded ? 220 : 100,
backgroundColor: expanded ? '#6C63FF' : '#A29BFE',
borderRadius: expanded ? 24 : 12,
padding: expanded ? 20 : 12,
// Δήλωση CSS transitions
transitionProperty: ['width', 'height', 'backgroundColor', 'borderRadius', 'padding'],
transitionDuration: 350,
transitionTimingFunction: 'ease-in-out',
}}
>
<Text style={styles.cardTitle}>
{expanded ? 'Λεπτομέρειες προϊόντος' : 'Πατήστε για περισσότερα'}
</Text>
{expanded && (
<Text style={styles.cardBody}>
Εδώ εμφανίζεται η αναλυτική περιγραφή του προϊόντος
με όλες τις πληροφορίες που χρειάζεται ο χρήστης.
</Text>
)}
</Animated.View>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
cardTitle: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
cardBody: { color: '#E8E8E8', fontSize: 14, marginTop: 12, lineHeight: 22 },
});
Η διαφορά σε σύγκριση με την παραδοσιακή προσέγγιση worklets είναι τεράστια σε όγκο κώδικα. Για animations που πυροδοτούνται από αλλαγή state, τα CSS transitions είναι πλέον ο προτεινόμενος τρόπος.
Τα transition properties που υποστηρίζονται: transitionProperty, transitionDuration, transitionDelay, transitionTimingFunction, και transitionBehavior. Και ένα bonus: μπορείτε να ορίσετε διαφορετικά durations ανά property, περνώντας arrays αντί μεμονωμένων τιμών — ακριβώς όπως στο κανονικό CSS.
Κλασικά Worklet-Based Animations: withSpring, withTiming, withDecay
Παρόλο που τα CSS animations είναι εξαιρετικά για declarative σενάρια, τα worklet-based animations παραμένουν απαραίτητα. Πότε; Για gesture-driven interactions, σύνθετα sequences, και σενάρια όπου χρειάζεστε πλήρη έλεγχο σε κάθε frame. Ας δούμε τα τρία βασικά.
withSpring — Φυσική κίνηση ελατηρίου
Το withSpring προσομοιώνει τη φυσική ενός ελατηρίου. Στο Reanimated 4, η νέα παράμετρος energyThreshold αντικαθιστά τα παλιά thresholds. Μικρότερη τιμή σημαίνει ότι το animation θα τρέχει μέχρι σχεδόν πλήρη ακινησία.
withTiming — Ελεγχόμενη διάρκεια
Για animations με ακριβή διάρκεια και easing functions. Ιδανικό για fade-in/out, slide transitions, και progress bars.
withDecay — Αδρανειακή κίνηση
Το withDecay παίρνει μια αρχική ταχύτητα (συνήθως από κάποιο gesture) και τη "σβήνει" σταδιακά. Τέλειο για scrollable cards, flick-to-dismiss, κι ό,τι βασίζεται σε physics.
// hooks/useAnimationExamples.ts — Πλήρη παραδείγματα animation utilities
import { useSharedValue, useAnimatedStyle, withSpring, withTiming, withDecay, Easing, withSequence, withDelay } from 'react-native-reanimated';
// Παράδειγμα 1: Spring animation με το νέο energyThreshold
export function useSpringAnimation() {
const translateY = useSharedValue<number>(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
const bounce = () => {
translateY.value = withSpring(-150, {
damping: 8, // Χαμηλό damping = περισσότερη αναπήδηση
stiffness: 120, // Πόσο "σφιχτό" είναι το ελατήριο
mass: 1, // Μάζα του αντικειμένου
energyThreshold: 0.01, // Νέο στο v4: πότε σταματάει το animation
});
};
const reset = () => {
translateY.value = withSpring(0, {
damping: 15,
stiffness: 100,
energyThreshold: 0.05,
});
};
return { animatedStyle, bounce, reset };
}
// Παράδειγμα 2: Timing animation με easing
export function useTimingAnimation() {
const opacity = useSharedValue<number>(0);
const scale = useSharedValue<number>(0.5);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ scale: scale.value }],
}));
const fadeIn = () => {
opacity.value = withTiming(1, {
duration: 600,
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
});
scale.value = withTiming(1, {
duration: 500,
easing: Easing.out(Easing.back(1.5)),
});
};
const fadeOut = () => {
opacity.value = withTiming(0, { duration: 300 });
scale.value = withTiming(0.5, { duration: 300 });
};
return { animatedStyle, fadeIn, fadeOut };
}
// Παράδειγμα 3: Sequence animation — σύνθετο animation από πολλά βήματα
export function useSequenceAnimation() {
const translateX = useSharedValue<number>(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
const shake = () => {
// Εφέ "κουνήματος" — χρήσιμο για σφάλματα φόρμας
translateX.value = withSequence(
withTiming(-12, { duration: 50 }),
withTiming(12, { duration: 50 }),
withTiming(-8, { duration: 50 }),
withTiming(8, { duration: 50 }),
withTiming(0, { duration: 50 })
);
};
return { animatedStyle, shake };
}
Ενσωμάτωση React Native Gesture Handler
Εδώ γίνεται η μαγεία. Τα animations γίνονται πραγματικά ζωντανά όταν τα συνδυάσετε με gestures. Η βιβλιοθήκη React Native Gesture Handler παρέχει ένα ισχυρό API βασισμένο στο GestureDetector component και τα Gesture objects. Σε συνδυασμό με το Reanimated, δημιουργείτε gesture-driven interactions που τρέχουν εξ ολοκλήρου στο UI thread.
GestureDetector και Gesture API
Το GestureDetector αντικαθιστά τα παλιά PanGestureHandler, TapGestureHandler κ.λπ. Τυλίγει ένα Animated.View και ανιχνεύει gestures ορισμένα μέσω του Gesture factory. Τα callbacks (onUpdate, onEnd) τρέχουν ως worklets, δηλαδή στο UI thread — κάτι που κάνει τεράστια διαφορά στη ρευστότητα.
// components/DraggableBox.tsx — Σύρσιμο αντικειμένου με gesture + animation
import React from 'react';
import { StyleSheet, View, Text } from 'react-native';
import Animated, { useSharedValue, useAnimatedStyle, withSpring, withDecay } from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
export default function DraggableBox() {
// Shared values για τη θέση
const translateX = useSharedValue<number>(0);
const translateY = useSharedValue<number>(0);
// Αποθήκευση αρχικής θέσης στην αρχή κάθε gesture
const startX = useSharedValue<number>(0);
const startY = useSharedValue<number>(0);
// Κλίμακα για visual feedback
const scale = useSharedValue<number>(1);
const panGesture = Gesture.Pan()
.onStart(() => {
'worklet';
// Αποθήκευση τρέχουσας θέσης στην αρχή του gesture
startX.value = translateX.value;
startY.value = translateY.value;
// Μικρό scale up για να δείξουμε ότι "σηκώθηκε"
scale.value = withSpring(1.1, { damping: 15, stiffness: 150 });
})
.onUpdate((event) => {
'worklet';
// Ενημέρωση θέσης βάσει μετατόπισης gesture
translateX.value = startX.value + event.translationX;
translateY.value = startY.value + event.translationY;
})
.onEnd((event) => {
'worklet';
// Αδρανειακή κίνηση βάσει ταχύτητας αφήνοντας
translateX.value = withDecay({
velocity: event.velocityX,
deceleration: 0.998,
});
translateY.value = withDecay({
velocity: event.velocityY,
deceleration: 0.998,
});
// Επαναφορά κλίμακας
scale.value = withSpring(1, { damping: 15, stiffness: 150 });
});
// Gesture tap — διπλό πάτημα για επαναφορά στο κέντρο
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => {
'worklet';
translateX.value = withSpring(0, { damping: 12, stiffness: 100 });
translateY.value = withSpring(0, { damping: 12, stiffness: 100 });
});
// Συνδυασμός gestures — ταυτόχρονα tap και pan
const composedGesture = Gesture.Simultaneous(doubleTap, panGesture);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ scale: scale.value },
],
}));
return (
<View style={styles.container}>
<Text style={styles.hint}>Σύρετε το κουτί ή κάντε διπλό tap για επαναφορά</Text>
<GestureDetector gesture={composedGesture}>
<Animated.View style={[styles.box, animatedStyle]}>
<Text style={styles.boxText}>Σύρε με</Text>
</Animated.View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#F0F0F5' },
hint: { color: '#888', fontSize: 13, marginBottom: 40 },
box: {
width: 140,
height: 140,
backgroundColor: '#6C63FF',
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
// Σκιά για εφέ elevation
shadowColor: '#6C63FF',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.35,
shadowRadius: 16,
elevation: 10,
},
boxText: { color: '#FFFFFF', fontSize: 16, fontWeight: '700' },
});
Παρατηρήστε τον συνδυασμό: Gesture.Simultaneous επιτρέπει και Pan και double-Tap να λειτουργούν ταυτόχρονα στο ίδιο element. Υπάρχουν κι άλλες μέθοδοι σύνθεσης: Gesture.Race (μόνο ένα gesture κερδίζει), Gesture.Exclusive (προτεραιότητα βάσει σειράς).
Πρακτικό παράδειγμα: Swipeable Card με Gesture και Animation
Ένα πολύ συνηθισμένο UX pattern σε mobile εφαρμογές: η κάρτα που σύρεται αριστερά/δεξιά για διαγραφή ή αρχειοθέτηση. Σκεφτείτε email apps ή Tinder-style interfaces. Ας φτιάξουμε ένα πλήρες παράδειγμα που μπορείτε να χρησιμοποιήσετε κατευθείαν στο project σας.
// components/SwipeableCard.tsx — Κάρτα που σύρεται για dismiss
import React from 'react';
import { Dimensions, StyleSheet, Text, View } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
interpolate,
Extrapolation,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
const SCREEN_WIDTH = Dimensions.get('window').width;
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.3; // 30% της οθόνης για dismiss
interface SwipeableCardProps {
title: string;
subtitle: string;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
}
export default function SwipeableCard({
title,
subtitle,
onSwipeLeft,
onSwipeRight,
}: SwipeableCardProps) {
const translateX = useSharedValue<number>(0);
const cardOpacity = useSharedValue<number>(1);
const panGesture = Gesture.Pan()
.activeOffsetX([-10, 10]) // Ενεργοποίηση μόνο σε οριζόντια κίνηση
.onUpdate((event) => {
'worklet';
translateX.value = event.translationX;
})
.onEnd((event) => {
'worklet';
// Αν το swipe ξεπέρασε το threshold, dismiss η κάρτα
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
const direction = translateX.value > 0 ? 1 : -1;
translateX.value = withTiming(direction * SCREEN_WIDTH, { duration: 250 });
cardOpacity.value = withTiming(0, { duration: 250 }, () => {
// Callback μετά το τέλος του animation
if (direction === 1 && onSwipeRight) {
runOnJS(onSwipeRight)();
} else if (direction === -1 && onSwipeLeft) {
runOnJS(onSwipeLeft)();
}
});
} else {
// Επιστροφή στο κέντρο αν δεν ξεπέρασε το threshold
translateX.value = withSpring(0, { damping: 15, stiffness: 120 });
}
});
const cardStyle = useAnimatedStyle(() => {
// Περιστροφή ανάλογα με τη μετατόπιση
const rotation = interpolate(
translateX.value,
[-SCREEN_WIDTH, 0, SCREEN_WIDTH],
[-15, 0, 15],
Extrapolation.CLAMP
);
// Αδιαφάνεια που μειώνεται στα άκρα
const opacity = interpolate(
Math.abs(translateX.value),
[0, SWIPE_THRESHOLD, SCREEN_WIDTH],
[1, 0.8, 0.2],
Extrapolation.CLAMP
);
return {
transform: [
{ translateX: translateX.value },
{ rotate: `${rotation}deg` },
],
opacity: cardOpacity.value * opacity,
};
});
// Δείκτης αριστερά/δεξιά
const leftIndicatorStyle = useAnimatedStyle(() => ({
opacity: interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolation.CLAMP),
}));
const rightIndicatorStyle = useAnimatedStyle(() => ({
opacity: interpolate(translateX.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolation.CLAMP),
}));
return (
<View style={styles.wrapper}>
{/* Ενδείξεις πίσω από την κάρτα */}
<Animated.View style={[styles.indicator, styles.rightAction, rightIndicatorStyle]}>
<Text style={styles.indicatorText}>Διαγραφή</Text>
</Animated.View>
<Animated.View style={[styles.indicator, styles.leftAction, leftIndicatorStyle]}>
<Text style={styles.indicatorText}>Αρχειοθέτηση</Text>
</Animated.View>
{/* Η κάρτα */}
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.card, cardStyle]}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.subtitle}>{subtitle}</Text>
</Animated.View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
wrapper: { width: '100%', alignItems: 'center', marginVertical: 8 },
card: {
width: SCREEN_WIDTH - 32,
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
},
title: { fontSize: 18, fontWeight: '700', color: '#1A1A2E', marginBottom: 4 },
subtitle: { fontSize: 14, color: '#888' },
indicator: {
position: 'absolute',
top: 0,
bottom: 0,
justifyContent: 'center',
paddingHorizontal: 24,
},
leftAction: { left: 16, backgroundColor: '#4CAF50', borderRadius: 16 },
rightAction: { right: 16, backgroundColor: '#F44336', borderRadius: 16 },
indicatorText: { color: '#FFFFFF', fontWeight: '700', fontSize: 14 },
});
Αυτό το component χρησιμοποιεί interpolate για ομαλές μεταβάσεις — η κάρτα περιστρέφεται ελαφρά καθώς σύρεται, ενώ οι ενδείξεις "Αρχειοθέτηση" και "Διαγραφή" εμφανίζονται σταδιακά πίσω της. Αν ο χρήστης αφήσει πριν φτάσει στο threshold, η κάρτα γυρίζει πίσω με ένα ικανοποιητικό spring animation. Αυτού του είδους τα micro-interactions κάνουν μια εφαρμογή να νιώθει premium.
Πρακτικό παράδειγμα: Animated List Items με Entering/Exiting
Ένα ακόμα πολύ χρήσιμο feature: τα ενσωματωμένα Layout Animations του Reanimated. Κάνουν τα items μιας λίστας να εμφανίζονται και να εξαφανίζονται με animation χωρίς χειροκίνητη ρύθμιση. Ας φτιάξουμε μια todo list (ναι, κλασικό παράδειγμα, αλλά λειτουργεί τέλεια για να δείξει αυτή τη δυνατότητα).
// screens/AnimatedTodoList.tsx — Λίστα με entering/exiting animations
import React, { useState, useCallback } from 'react';
import {
View,
Text,
TextInput,
Pressable,
StyleSheet,
ScrollView,
} from 'react-native';
import Animated, {
FadeInRight,
FadeOutLeft,
LinearTransition,
SlideInDown,
} from 'react-native-reanimated';
interface TodoItem {
id: string;
text: string;
completed: boolean;
}
export default function AnimatedTodoList() {
const [todos, setTodos] = useState<TodoItem[]>([]);
const [inputText, setInputText] = useState<string>('');
const addTodo = useCallback(() => {
if (inputText.trim() === '') return;
const newTodo: TodoItem = {
id: Date.now().toString(),
text: inputText.trim(),
completed: false,
};
setTodos((prev) => [newTodo, ...prev]);
setInputText('');
}, [inputText]);
const toggleTodo = useCallback((id: string) => {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}, []);
const removeTodo = useCallback((id: string) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id));
}, []);
return (
<View style={styles.screen}>
<Text style={styles.header}>Οι εργασίες μου</Text>
{/* Φόρμα εισαγωγής */}
<Animated.View entering={SlideInDown.duration(400)} style={styles.inputRow}>
<TextInput
style={styles.input}
value={inputText}
onChangeText={setInputText}
placeholder="Νέα εργασία..."
placeholderTextColor="#AAA"
onSubmitEditing={addTodo}
/>
<Pressable style={styles.addButton} onPress={addTodo}>
<Text style={styles.addButtonText}>+</Text>
</Pressable>
</Animated.View>
{/* Λίστα εργασιών */}
<ScrollView style={styles.list}>
{todos.map((todo) => (
<Animated.View
key={todo.id}
entering={FadeInRight.duration(350).springify().damping(14)}
exiting={FadeOutLeft.duration(300)}
layout={LinearTransition.springify().damping(16).stiffness(120)}
style={[
styles.todoItem,
todo.completed && styles.todoItemCompleted,
]}
>
<Pressable
style={styles.todoContent}
onPress={() => toggleTodo(todo.id)}
>
<View
style={[
styles.checkbox,
todo.completed && styles.checkboxChecked,
]}
>
{todo.completed && (
<Text style={styles.checkmark}>✓</Text>
)}
</View>
<Text
style={[
styles.todoText,
todo.completed && styles.todoTextCompleted,
]}
>
{todo.text}
</Text>
</Pressable>
<Pressable onPress={() => removeTodo(todo.id)}>
<Text style={styles.deleteText}>Διαγραφή</Text>
</Pressable>
</Animated.View>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: '#F4F5F9', paddingTop: 60, paddingHorizontal: 16 },
header: { fontSize: 28, fontWeight: '800', color: '#1A1A2E', marginBottom: 20 },
inputRow: { flexDirection: 'row', marginBottom: 20 },
input: {
flex: 1,
height: 48,
backgroundColor: '#FFFFFF',
borderRadius: 12,
paddingHorizontal: 16,
fontSize: 16,
color: '#333',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 4,
},
addButton: {
width: 48,
height: 48,
backgroundColor: '#6C63FF',
borderRadius: 12,
marginLeft: 8,
alignItems: 'center',
justifyContent: 'center',
},
addButtonText: { color: '#FFFFFF', fontSize: 24, fontWeight: '600' },
list: { flex: 1 },
todoItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#FFFFFF',
borderRadius: 12,
padding: 16,
marginBottom: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 3,
},
todoItemCompleted: { backgroundColor: '#F0FFF4' },
todoContent: { flexDirection: 'row', alignItems: 'center', flex: 1 },
checkbox: {
width: 24,
height: 24,
borderRadius: 6,
borderWidth: 2,
borderColor: '#D0D0D0',
marginRight: 12,
alignItems: 'center',
justifyContent: 'center',
},
checkboxChecked: { backgroundColor: '#4CAF50', borderColor: '#4CAF50' },
checkmark: { color: '#FFFFFF', fontSize: 14, fontWeight: '700' },
todoText: { fontSize: 16, color: '#333', flex: 1 },
todoTextCompleted: { color: '#AAA', textDecorationLine: 'line-through' },
deleteText: { color: '#F44336', fontSize: 13, fontWeight: '600', marginLeft: 8 },
});
Τα τρία κλειδιά εδώ:
entering— Πώς εμφανίζεται ένα item (εδώ: FadeInRight με spring physics).exiting— Πώς εξαφανίζεται (FadeOutLeft, γλιστράει αριστερά).layout— Πώς αναδιατάσσονται τα υπόλοιπα items όταν αλλάζει η σειρά (LinearTransition με spring).
Με αυτά τα τρία props πετυχαίνετε μια εμπειρία λίστας που νιώθει σαν native iOS/Android app, χωρίς δεκάδες γραμμές animation κώδικα. Ειλικρινά, πριν υπάρξει αυτό το API, η ίδια δουλειά θα χρειαζόταν τουλάχιστον τριπλάσιο κώδικα.
Συμβουλές απόδοσης για Animations στο React Native
Ακόμα κι αν χρησιμοποιείτε Reanimated, υπάρχουν πρακτικές που κάνουν τη διαφορά μεταξύ ομαλού 60 FPS και ενοχλητικού jank. Μερικές από αυτές τις έχω μάθει με τον δύσκολο τρόπο:
- Χρησιμοποιήστε
transformαντί γιαwidth/height/top/left— Τα transforms δεν πυροδοτούν relayout. ΈναtranslateXείναι πάντα πιο γρήγορο από αλλαγήleft. - Αποφύγετε μεγάλα objects σε worklets — Εξάγετε μόνο τις τιμές που χρειάζεστε. Τα μεγάλα objects πρέπει να γίνουν serialize/deserialize μεταξύ threads, και αυτό κοστίζει.
- Χρησιμοποιήστε
cancelAnimationστα cleanup — Αν ένα component κάνει unmount ενώ τρέχει animation, ακυρώστε το για αποφυγή memory leaks. - Προτιμήστε CSS transitions για απλά state animations — Λιγότερος κώδικας, λιγότερα shared values, ίδια απόδοση.
- Περιορίστε τα
useAnimatedStyleστα components που τα χρειάζονται — Μη βάζετε animated styles σε parent components που δεν κινούνται. Φαίνεται αθώο, αλλά τρώει resources. - Χρησιμοποιήστε
withRepeatαντί για manual loops — Για επαναλαμβανόμενα animations, είναι βελτιστοποιημένο εσωτερικά. - Σε λίστες, δοκιμάστε
itemLayoutAnimationστοFlashList— Η ενσωμάτωση Reanimated + FlashList δουλεύει πολύ καλύτερα από χειροκίνητη χρήση σε FlatList. - Ελέγξτε πάντα σε πραγματική συσκευή — Ο simulator δεν αντικατοπτρίζει πιστά την απόδοση. Χρησιμοποιήστε τα React Native DevTools ή τον Perf Monitor για μετρήσεις FPS. Αυτό δεν είναι προαιρετικό.
Συχνές ερωτήσεις (FAQ)
1. Μπορώ να χρησιμοποιήσω το Reanimated 4 χωρίς τη New Architecture;
Δυστυχώς όχι. Το Reanimated 4.x απαιτεί αποκλειστικά τη New Architecture (Fabric). Η σύγχρονη επικοινωνία του Fabric είναι απαραίτητη για τα νέα CSS animations και transitions. Αν δεν μπορείτε να κάνετε μετάβαση ακόμα, παραμείνετε στο Reanimated 3.x που υποστηρίζει και τις δύο αρχιτεκτονικές. Αν όμως χρησιμοποιείτε Expo SDK 53, η New Architecture είναι ενεργοποιημένη by default — οπότε πιθανότατα είστε ήδη έτοιμοι χωρίς να το ξέρετε.
2. CSS transitions ή worklet animations; Πότε χρησιμοποιώ τι;
Απλός κανόνας: τα CSS transitions είναι declarative — δηλώνετε τι θέλετε και η αλλαγή state τα πυροδοτεί αυτόματα. Ιδανικά για toggle switches, expandable sections, αλλαγές χρώματος. Τα worklet animations είναι imperative — ελέγχετε κάθε frame χειροκίνητα. Απαραίτητα για gesture-driven interactions και physics-based κινήσεις. Η σύσταση: ξεκινήστε με CSS transitions και πηγαίνετε σε worklets μόνο όταν χρειαστεί.
3. Πώς αντικαθιστώ τα παλιά restDisplacementThreshold/restSpeedThreshold;
Στο Reanimated 4 ενοποιήθηκαν στο energyThreshold. Υπολογίζει τη συνολική "ενέργεια" του spring και σταματάει το animation όταν πέσει κάτω από το κατώφλι. Μια τιμή 0.01 δίνει ομαλή ολοκλήρωση. Για ταχύτερη ολοκλήρωση (π.χ. σε λίστες με πολλά items), δοκιμάστε 0.05 ή 0.1.
4. Χρειάζεται ειδική ρύθμιση Babel plugin με Expo;
Όχι, αν είστε σε Expo SDK 53+. Το babel-preset-expo ρυθμίζει αυτόματα το react-native-worklets/plugin. Αν χρησιμοποιείτε bare React Native χωρίς Expo, τότε πρέπει να προσθέσετε χειροκίνητα το 'react-native-worklets/plugin' στο babel.config.js. Σημαντικό: πρέπει να είναι τελευταίο στον πίνακα plugins, αλλιώς τα worklets δε θα δουλέψουν.
5. Πώς συνδυάζω πολλαπλά gestures στο ίδιο component;
Τρεις μέθοδοι σύνθεσης: Gesture.Simultaneous() — τρέχουν ταυτόχρονα (π.χ. pan + pinch). Gesture.Race() — μόνο ένα κερδίζει, όποιο αναγνωριστεί πρώτο. Gesture.Exclusive() — προτεραιότητα στο πρώτο στη λίστα. Για παράδειγμα, αν θέλετε tap και long press μαζί, χρησιμοποιήστε Gesture.Exclusive(longPressGesture, tapGesture) — το long press έχει προτεραιότητα και το tap ενεργοποιείται μόνο αν δεν αναγνωριστεί long press.