Animasi adalah nilai yang berubah sedikit demi sedikit dari frame ke frame, 60 kali per detik di kebanyakan HP. Di Laundry Bu Rini, saat status #13 berubah dari "Disetrika" ke "Siap diambil", badge-nya membesar sebentar lalu kembali, dan warnanya bergeser dari biru ke hijau. Bayangin kasir Bu Rini yang sedang menghitung uang (JavaScript thread). Kalau dia juga harus menggeser kartu di papan dengan tangannya sendiri, kartunya berhenti setiap kali dia sibuk menghitung. Reanimated memberi kasir seorang asisten (UI thread) yang menggeser kartu sambil kasir terus menghitung.
🧪 Tebakan cepat: Badge #13 sedang dianimasikan. Tepat di tengahnya, JavaScript sibuk 300 ms membaca daftar order yang baru diambil ulang. Animasi dengan
Animated(useNativeDriver: false) dan dengan ReanimatedwithSpring: mana yang tetap mulus? Jawabannya ada di bagian "Dua thread: kenapa Reanimated tetap mulus".
Animated bawaan dan Reanimated 4
React Native punya Animated bawaan. Dengan useNativeDriver: true, animasinya dikirim ke native dan jalan di UI thread, tapi cuma untuk style yang bukan layout: transform, opacity, dan warna. Untuk width atau height React Native menolak: Style property 'width' is not supported by native animated module. Jadi animasi ukuran harus useNativeDriver: false, dan tiap frame-nya dihitung di JavaScript.
npx expo install react-native-reanimated react-native-worklets # SDK 57: 4.5.1 dan 0.10.1
- Di npm sudah ada Reanimated 4.7.1, tapi
npx expo installmemasang versi yang diuji untuk SDK 57. Reanimated ada di Expo Go. - Reanimated 4 cuma mendukung New Architecture. React Native 0.86 di SDK 57 memang cuma New Architecture, jadi aman.
- Worklet adalah fungsi yang dijalankan di UI thread. Plugin Babel-nya (
react-native-worklets/plugin) sudah dipasang otomatis olehbabel-preset-expo. Tutorial lama yang menyuruh menambahreact-native-reanimated/pluginkebabel.config.jssudah gak berlaku.
Shared value, useAnimatedStyle, withTiming, withSpring
// src/components/StatusBadge.tsx
import { useEffect } from 'react';
import { Text } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withSequence, withSpring, withTiming } from 'react-native-reanimated';
import type { Status } from '@/data/orders';
const WARNA: Record<Status, string> = { Antre: '#64748b', Dicuci: '#0369a1', Disetrika: '#1d4ed8', 'Siap diambil': '#047857' };
export function StatusBadge({ status }: { status: Status }) {
const skala = useSharedValue(1);
useEffect(() => {
if (status === 'Siap diambil') {
skala.value = withSequence(
withTiming(1.15, { duration: 120 }),
withSpring(1, { damping: 12, stiffness: 180 }),
);
}
}, [status, skala]);
const gayaSkala = useAnimatedStyle(() => ({ transform: [{ scale: skala.value }] }));
return (
<Animated.View
style={[
{ paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999, backgroundColor: WARNA[status] },
{ transitionProperty: 'backgroundColor', transitionDuration: 300 }, // transisi CSS Reanimated 4
gayaSkala,
]}>
<Text style={{ color: '#fff', fontWeight: '700' }}>{status}</Text>
</Animated.View>
);
}
useSharedValue(1)membuat nilai yang bisa dibaca kedua thread. Mengubahskala.valuegak membuat komponen render ulang.useAnimatedStyleadalah worklet: dihitung ulang di UI thread setiapskala.valueberubah.withTiming(1.15, { duration: 120 })naik dalam 120 ms. Tanpa opsi,withTimingmemakai 300 ms danEasing.inOut(Easing.quad).withSpring(1, { damping: 12, stiffness: 180 })kembali dengan pantulan.withSequencemenjalankan keduanya berurutan.transitionPropertydantransitionDurationadalah transisi gaya CSS yang baru di Reanimated 4: setiapbackgroundColorberubah, warnanya bergeser selama 300 ms, tanpa shared value.
Angkanya, dihitung dengan rumus spring Reanimated 4.5.1 pada 60 frame per detik (waktu spring dihitung dari saat spring mulai):
| Bagian | Yang terjadi |
|---|---|
withTiming |
1,000 → 1,006 → 1,023 → 1,052 → 1,091 → 1,122 → 1,142 → 1,150 di 120 ms |
withSpring({ damping: 12, stiffness: 180 }) |
turun sampai 0,927 di sekitar 483 ms, naik ke 1,035 di sekitar 967 ms, selisihnya di bawah 0,005 sejak sekitar 2.083 ms, selesai di sekitar 6.400 ms |
withSpring(1) tanpa opsi |
gak pernah di bawah 1, selisih di bawah 0,005 sejak sekitar 350 ms, selesai di sekitar 833 ms |
Spring bawaan Reanimated 4 (stiffness: 900, damping: 120, mass: 4) teredam penuh, jadi gak memantul. Reanimated 3 dulu memantul (damping: 10, stiffness: 100). Mau pantulan, tulis damping dan stiffness sendiri. mass yang gak kamu tulis tetap 4, jadi baris kedua tabel memakai mass 4.
Dua thread: kenapa Reanimated tetap mulus
JavaScript sibuk 300 ms berarti sekitar 18 frame (300 / 16,7) gak bisa dihitung di JavaScript.
| Cara | Jalan di | Saat JavaScript sibuk 300 ms |
|---|---|---|
Animated, useNativeDriver: false |
JS thread, tiap frame | diam sekitar 18 frame: tersendat |
Animated, useNativeDriver: true |
UI thread | mulus, tapi cuma transform, opacity, dan warna |
Reanimated withTiming / withSpring |
UI thread (worklet) | mulus, termasuk width dan height |
Jadi Reanimated tetap mulus, dan Animated tanpa native driver tersendat. Itu jawaban tebakannya. Animasinya cuma dimulai dari JavaScript (lewat useEffect); setelah itu frame-nya dihitung di UI thread.
Daftar yang berubah: entering, exiting, layout
<Animated.FlatList data={orders} itemLayoutAnimation={LinearTransition} renderItem={({ item }) => (
<Animated.View entering={FadeIn} exiting={FadeOut}><OrderRow order={item} /></Animated.View>
)} />
Dengan filter "Disetrika", begitu #13 jadi "Siap diambil", barisnya memudar keluar (FadeOut, 300 ms bawaan) dan baris lain bergeser rapi (LinearTransition). Tarik untuk refresh tetap refreshing dan onRefresh dari pelajaran API: jangan bikin gesture sendiri untuk itu.
Untuk gesture lain, react-native-gesture-handler (2.32.0 di SDK 57) memberi Gesture.Tap(), Gesture.Pan(), dan GestureDetector. Callback-nya juga worklet, jadi umpan balik sentuhan (misalnya baris mengecil ke 0,97 saat ditekan) jalan di UI thread. Bungkus layout akar dengan <GestureHandlerRootView style={{ flex: 1 }}>.
Reduce motion
Sebagian orang pusing melihat gerakan, jadi mereka menyalakan "Kurangi gerakan" di pengaturan HP. Bawaan Reanimated adalah ReduceMotion.System: kalau pengaturan itu menyala, withTiming, withSpring, dan layout animation langsung melompat ke nilai akhir. Badge tetap jadi hijau, cuma tanpa gerakan. useReducedMotion() dari Reanimated memberi nilainya saat aplikasi dimulai. Untuk perubahan saat aplikasi jalan, pakai AccessibilityInfo.isReduceMotionEnabled() dan event 'reduceMotionChanged'.
⚠️ Jebakan yang sering ketemu
useStateuntuk nilai tiap frame. Setiap frame jadi render ulang. PakaiuseSharedValue.useNativeDriver: trueuntukwidthatauheight. Ditolak. Pakai Reanimated, atauscaleXdanscaleY.- Menambah
react-native-reanimated/pluginsendiri ke Babel. Di SDK 57babel-preset-exposudah memasang plugin worklets. - Mengira
withSpringtanpa opsi memantul. Di Reanimated 4 bawaannya teredam penuh. - Gesture tanpa
GestureHandlerRootView. Gesture-nya gak bekerja.
TL;DR: Animated bawaan bisa jalan di UI thread dengan useNativeDriver: true, tapi cuma untuk transform, opacity, dan warna; tanpa itu tiap frame dihitung di JavaScript dan tersendat saat JavaScript sibuk. Reanimated 4 (4.5.1 dan react-native-worklets 0.10.1 di SDK 57, New Architecture saja, plugin Babel otomatis) menjalankan animasi sebagai worklet di UI thread: useSharedValue untuk nilai, useAnimatedStyle untuk style, withTiming (bawaan 300 ms) dan withSpring (bawaan v4 teredam penuh; damping: 12, stiffness: 180 dengan mass bawaan 4 memberi pantulan ke 0,927), plus transisi CSS (transitionProperty, transitionDuration: 300) untuk warna badge. entering, exiting, dan itemLayoutAnimation={LinearTransition} menganimasikan daftar; tarik untuk refresh tetap RefreshControl; gesture lewat GestureDetector di dalam GestureHandlerRootView. Hormati "Kurangi gerakan": Reanimated melompat ke nilai akhir secara bawaan.