Module 9: Animations and Gestures
Common Animation Patterns
Build reusable animation components for fades, slides, bounces, and more
π― Learning Objectives
- Implement fade in/out animations for content transitions
- Create slide animations from different directions
- Build bounce and scale effects for interactive feedback
- Design staggered list animations for dynamic content
- Create skeleton loading animations
- Build reusable animated wrapper components
Fade Animations
Fades are the most common animation patternβthey provide smooth transitions for appearing and disappearing content without jarring visual changes.
Basic Fade In Component
import React, { useEffect } from 'react';
import { ViewProps } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withDelay,
} from 'react-native-reanimated';
interface FadeInProps extends ViewProps {
delay?: number;
duration?: number;
children: React.ReactNode;
}
export function FadeIn({
delay = 0,
duration = 400,
style,
children,
...props
}: FadeInProps) {
const opacity = useSharedValue(0);
useEffect(() => {
opacity.value = withDelay(
delay,
withTiming(1, { duration })
);
}, [delay, duration]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
return (
<Animated.View style={[animatedStyle, style]} {...props}>
{children}
</Animated.View>
);
}
// Usage
function MyScreen() {
return (
<View>
<FadeIn>
<Text>This fades in immediately</Text>
</FadeIn>
<FadeIn delay={200}>
<Text>This fades in after 200ms</Text>
</FadeIn>
<FadeIn delay={400} duration={800}>
<Text>This fades in slowly after 400ms</Text>
</FadeIn>
</View>
);
}
Controllable Fade Component
import React, { useEffect } from 'react';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';
interface FadeProps {
visible: boolean;
duration?: number;
children: React.ReactNode;
style?: any;
}
export function Fade({
visible,
duration = 300,
children,
style
}: FadeProps) {
const opacity = useSharedValue(visible ? 1 : 0);
useEffect(() => {
opacity.value = withTiming(visible ? 1 : 0, { duration });
}, [visible, duration]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
return (
<Animated.View style={[animatedStyle, style]}>
{children}
</Animated.View>
);
}
// Usage
function ToggleContent() {
const [isVisible, setIsVisible] = useState(true);
return (
<View>
<Pressable onPress={() => setIsVisible(!isVisible)}>
<Text>Toggle Content</Text>
</Pressable>
<Fade visible={isVisible}>
<View style={styles.content}>
<Text>This content fades in and out</Text>
</View>
</Fade>
</View>
);
}
Fade with Unmount
import React, { useState, useEffect } from 'react';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
runOnJS,
} from 'react-native-reanimated';
interface FadeUnmountProps {
visible: boolean;
duration?: number;
children: React.ReactNode;
style?: any;
}
export function FadeUnmount({
visible,
duration = 300,
children,
style
}: FadeUnmountProps) {
const [shouldRender, setShouldRender] = useState(visible);
const opacity = useSharedValue(visible ? 1 : 0);
useEffect(() => {
if (visible) {
setShouldRender(true);
opacity.value = withTiming(1, { duration });
} else {
opacity.value = withTiming(0, { duration }, (finished) => {
if (finished) {
runOnJS(setShouldRender)(false);
}
});
}
}, [visible, duration]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
if (!shouldRender) return null;
return (
<Animated.View style={[animatedStyle, style]}>
{children}
</Animated.View>
);
}
// Usage - component unmounts after fade out
function Modal({ isOpen, onClose, children }) {
return (
<FadeUnmount visible={isOpen}>
<View style={styles.modalBackdrop}>
<View style={styles.modalContent}>
{children}
<Pressable onPress={onClose}>
<Text>Close</Text>
</Pressable>
</View>
</View>
</FadeUnmount>
);
}
Cross-Fade Transition
import React, { useState, useEffect } from 'react';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';
interface CrossFadeProps {
activeIndex: number;
children: React.ReactNode[];
duration?: number;
}
export function CrossFade({
activeIndex,
children,
duration = 300
}: CrossFadeProps) {
return (
<View style={{ position: 'relative' }}>
{React.Children.map(children, (child, index) => (
<CrossFadeItem
key={index}
isActive={index === activeIndex}
duration={duration}
isFirst={index === 0}
>
{child}
</CrossFadeItem>
))}
</View>
);
}
function CrossFadeItem({
isActive,
duration,
isFirst,
children
}: {
isActive: boolean;
duration: number;
isFirst: boolean;
children: React.ReactNode;
}) {
const opacity = useSharedValue(isActive ? 1 : 0);
useEffect(() => {
opacity.value = withTiming(isActive ? 1 : 0, { duration });
}, [isActive, duration]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
position: isFirst ? 'relative' : 'absolute',
top: 0,
left: 0,
right: 0,
}));
return (
<Animated.View style={animatedStyle}>
{children}
</Animated.View>
);
}
// Usage - smooth transition between views
function ImageCarousel() {
const [currentIndex, setCurrentIndex] = useState(0);
const images = ['image1.jpg', 'image2.jpg', 'image3.jpg'];
return (
<View>
<CrossFade activeIndex={currentIndex}>
{images.map((img, index) => (
<Image key={index} source={{ uri: img }} style={styles.image} />
))}
</CrossFade>
<View style={styles.dots}>
{images.map((_, index) => (
<Pressable
key={index}
onPress={() => setCurrentIndex(index)}
>
<View style={[
styles.dot,
index === currentIndex && styles.activeDot
]} />
</Pressable>
))}
</View>
</View>
);
}
Slide Animations
Slide animations create a sense of spatial movement and are perfect for screen transitions, drawers, and content reveals.
Slide In Component
import React, { useEffect } from 'react';
import { useWindowDimensions } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withDelay,
} from 'react-native-reanimated';
type SlideDirection = 'left' | 'right' | 'up' | 'down';
interface SlideInProps {
direction?: SlideDirection;
delay?: number;
distance?: number;
children: React.ReactNode;
style?: any;
}
export function SlideIn({
direction = 'up',
delay = 0,
distance,
children,
style,
}: SlideInProps) {
const { width, height } = useWindowDimensions();
const translate = useSharedValue(0);
// Calculate initial offset based on direction
const getInitialOffset = () => {
const defaultDistance = distance ?? (
direction === 'left' || direction === 'right' ? width : height * 0.3
);
switch (direction) {
case 'left': return -defaultDistance;
case 'right': return defaultDistance;
case 'up': return defaultDistance;
case 'down': return -defaultDistance;
}
};
useEffect(() => {
translate.value = getInitialOffset();
translate.value = withDelay(
delay,
withSpring(0, { damping: 15, stiffness: 100 })
);
}, [direction, delay, distance]);
const animatedStyle = useAnimatedStyle(() => {
const isHorizontal = direction === 'left' || direction === 'right';
return {
transform: [
isHorizontal
? { translateX: translate.value }
: { translateY: translate.value }
],
};
});
return (
<Animated.View style={[animatedStyle, style]}>
{children}
</Animated.View>
);
}
// Usage
function WelcomeScreen() {
return (
<View style={styles.container}>
<SlideIn direction="down" delay={0}>
<Text style={styles.title}>Welcome</Text>
</SlideIn>
<SlideIn direction="left" delay={200}>
<Text style={styles.subtitle}>Let's get started</Text>
</SlideIn>
<SlideIn direction="up" delay={400}>
<Pressable style={styles.button}>
<Text>Continue</Text>
</Pressable>
</SlideIn>
</View>
);
}
Slide and Fade Combo
import React, { useEffect } from 'react';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withDelay,
Easing,
} from 'react-native-reanimated';
interface SlideAndFadeProps {
visible: boolean;
direction?: 'up' | 'down' | 'left' | 'right';
distance?: number;
duration?: number;
children: React.ReactNode;
style?: any;
}
export function SlideAndFade({
visible,
direction = 'up',
distance = 20,
duration = 300,
children,
style,
}: SlideAndFadeProps) {
const opacity = useSharedValue(visible ? 1 : 0);
const translate = useSharedValue(visible ? 0 : distance);
useEffect(() => {
const config = {
duration,
easing: Easing.out(Easing.ease)
};
if (visible) {
opacity.value = withTiming(1, config);
translate.value = withTiming(0, config);
} else {
opacity.value = withTiming(0, config);
translate.value = withTiming(distance, config);
}
}, [visible, distance, duration]);
const animatedStyle = useAnimatedStyle(() => {
const translateProp =
direction === 'left' || direction === 'right'
? 'translateX'
: 'translateY';
const sign = direction === 'down' || direction === 'right' ? -1 : 1;
return {
opacity: opacity.value,
transform: [{ [translateProp]: translate.value * sign }],
};
});
return (
<Animated.View style={[animatedStyle, style]}>
{children}
</Animated.View>
);
}
// Usage
function NotificationBanner({ message, visible }) {
return (
<SlideAndFade visible={visible} direction="down">
<View style={styles.banner}>
<Text style={styles.bannerText}>{message}</Text>
</View>
</SlideAndFade>
);
}
Drawer Animation
import React from 'react';
import { Dimensions, Pressable, StyleSheet, View } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
interpolate,
} from 'react-native-reanimated';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const DRAWER_WIDTH = SCREEN_WIDTH * 0.8;
interface DrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
drawerContent: React.ReactNode;
}
export function Drawer({
isOpen,
onClose,
children,
drawerContent
}: DrawerProps) {
const progress = useSharedValue(0);
React.useEffect(() => {
progress.value = withSpring(isOpen ? 1 : 0, {
damping: 20,
stiffness: 100,
});
}, [isOpen]);
const drawerStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: interpolate(
progress.value,
[0, 1],
[-DRAWER_WIDTH, 0]
)},
],
}));
const contentStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: interpolate(
progress.value,
[0, 1],
[0, DRAWER_WIDTH * 0.3]
)},
{ scale: interpolate(
progress.value,
[0, 1],
[1, 0.95]
)},
],
borderRadius: interpolate(progress.value, [0, 1], [0, 20]),
overflow: 'hidden',
}));
const backdropStyle = useAnimatedStyle(() => ({
opacity: progress.value * 0.5,
pointerEvents: isOpen ? 'auto' : 'none',
}));
return (
<View style={styles.container}>
{/* Main Content */}
<Animated.View style={[styles.content, contentStyle]}>
{children}
</Animated.View>
{/* Backdrop */}
<Animated.View style={[styles.backdrop, backdropStyle]}>
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
</Animated.View>
{/* Drawer */}
<Animated.View style={[styles.drawer, drawerStyle]}>
{drawerContent}
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
flex: 1,
backgroundColor: '#fff',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#000',
},
drawer: {
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: DRAWER_WIDTH,
backgroundColor: '#fff',
shadowColor: '#000',
shadowOffset: { width: 2, height: 0 },
shadowOpacity: 0.25,
shadowRadius: 10,
elevation: 5,
},
});
Scale and Bounce Effects
Scale animations provide immediate tactile feedback and help users understand what's interactive. Bounce effects add personality and delight to your UI.
Press Scale Effect
import React from 'react';
import { Pressable, PressableProps, StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated';
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
interface ScalePressableProps extends PressableProps {
scaleValue?: number;
children: React.ReactNode;
}
export function ScalePressable({
scaleValue = 0.95,
style,
children,
...props
}: ScalePressableProps) {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<AnimatedPressable
onPressIn={() => {
scale.value = withSpring(scaleValue, {
damping: 15,
stiffness: 400,
});
}}
onPressOut={() => {
scale.value = withSpring(1, {
damping: 15,
stiffness: 400,
});
}}
style={[animatedStyle, style]}
{...props}
>
{children}
</AnimatedPressable>
);
}
// Usage
function ButtonExample() {
return (
<ScalePressable
style={styles.button}
onPress={() => console.log('Pressed!')}
>
<Text style={styles.buttonText}>Press Me</Text>
</ScalePressable>
);
}
Bounce Animation
import React, { useEffect } from 'react';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withSequence,
withDelay,
} from 'react-native-reanimated';
interface BounceInProps {
delay?: number;
children: React.ReactNode;
style?: any;
}
export function BounceIn({ delay = 0, children, style }: BounceInProps) {
const scale = useSharedValue(0);
useEffect(() => {
scale.value = withDelay(
delay,
withSpring(1, {
damping: 8,
stiffness: 100,
})
);
}, [delay]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<Animated.View style={[animatedStyle, style]}>
{children}
</Animated.View>
);
}
// Pop animation (scale up then settle)
export function PopIn({ delay = 0, children, style }) {
const scale = useSharedValue(0);
useEffect(() => {
scale.value = withDelay(
delay,
withSequence(
withSpring(1.2, { damping: 10, stiffness: 200 }),
withSpring(1, { damping: 15, stiffness: 150 })
)
);
}, [delay]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<Animated.View style={[animatedStyle, style]}>
{children}
</Animated.View>
);
}
// Usage
function BadgeNotification() {
const [count, setCount] = useState(0);
return (
<View>
<Pressable onPress={() => setCount(c => c + 1)}>
<View style={styles.icon}>
<Text>π</Text>
</View>
{count > 0 && (
<PopIn key={count}>
<View style={styles.badge}>
<Text style={styles.badgeText}>{count}</Text>
</View>
</PopIn>
)}
</Pressable>
</View>
);
}
Shake Animation
import React, { useImperativeHandle, forwardRef } from 'react';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSequence,
withTiming,
} from 'react-native-reanimated';
export interface ShakeRef {
shake: () => void;
}
interface ShakeProps {
children: React.ReactNode;
style?: any;
}
export const Shake = forwardRef<ShakeRef, ShakeProps>(
({ children, style }, ref) => {
const translateX = useSharedValue(0);
useImperativeHandle(ref, () => ({
shake: () => {
translateX.value = withSequence(
withTiming(-10, { duration: 50 }),
withTiming(10, { duration: 50 }),
withTiming(-10, { duration: 50 }),
withTiming(10, { duration: 50 }),
withTiming(-5, { duration: 50 }),
withTiming(5, { duration: 50 }),
withTiming(0, { duration: 50 })
);
},
}));
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
return (
<Animated.View style={[animatedStyle, style]}>
{children}
</Animated.View>
);
}
);
// Usage - shake on error
function LoginForm() {
const shakeRef = useRef<ShakeRef>(null);
const [error, setError] = useState(false);
const handleSubmit = () => {
if (/* validation fails */) {
setError(true);
shakeRef.current?.shake();
}
};
return (
<View>
<Shake ref={shakeRef}>
<TextInput
style={[styles.input, error && styles.inputError]}
placeholder="Password"
/>
</Shake>
<Pressable onPress={handleSubmit}>
<Text>Login</Text>
</Pressable>
</View>
);
}
Pulse Animation
import React, { useEffect } from 'react';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withSequence,
withTiming,
Easing,
} from 'react-native-reanimated';
interface PulseProps {
duration?: number;
minScale?: number;
maxScale?: number;
children: React.ReactNode;
style?: any;
}
export function Pulse({
duration = 1000,
minScale = 1,
maxScale = 1.1,
children,
style,
}: PulseProps) {
const scale = useSharedValue(minScale);
useEffect(() => {
scale.value = withRepeat(
withSequence(
withTiming(maxScale, {
duration: duration / 2,
easing: Easing.inOut(Easing.ease),
}),
withTiming(minScale, {
duration: duration / 2,
easing: Easing.inOut(Easing.ease),
})
),
-1, // Infinite repeat
false // Don't reverse
);
}, [duration, minScale, maxScale]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<Animated.View style={[animatedStyle, style]}>
{children}
</Animated.View>
);
}
// Usage - attention-grabbing button
function RecordButton() {
const [isRecording, setIsRecording] = useState(false);
return (
<Pressable onPress={() => setIsRecording(!isRecording)}>
{isRecording ? (
<Pulse minScale={0.9} maxScale={1.1} duration={800}>
<View style={[styles.recordButton, styles.recording]}>
<View style={styles.recordDot} />
</View>
</Pulse>
) : (
<View style={styles.recordButton}>
<View style={styles.recordDot} />
</View>
)}
</Pressable>
);
}
Staggered List Animations
Staggered animations bring lists to life by animating items in sequence. This creates a cascading effect that guides the user's attention through content.
Basic Staggered List
import React, { useEffect } from 'react';
import { FlatList, View, Text, StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withDelay,
withSpring,
FadeIn,
FadeOut,
Layout,
} from 'react-native-reanimated';
interface StaggeredItemProps {
index: number;
children: React.ReactNode;
}
function StaggeredItem({ index, children }: StaggeredItemProps) {
const opacity = useSharedValue(0);
const translateY = useSharedValue(20);
useEffect(() => {
const delay = index * 100;
opacity.value = withDelay(delay, withSpring(1));
translateY.value = withDelay(
delay,
withSpring(0, { damping: 15, stiffness: 100 })
);
}, [index]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ translateY: translateY.value }],
}));
return (
<Animated.View style={animatedStyle}>
{children}
</Animated.View>
);
}
// Usage with FlatList
function StaggeredFlatList({ data }) {
const renderItem = ({ item, index }) => (
<StaggeredItem index={index}>
<View style={styles.listItem}>
<Text style={styles.itemTitle}>{item.title}</Text>
<Text style={styles.itemSubtitle}>{item.subtitle}</Text>
</View>
</StaggeredItem>
);
return (
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
/>
);
}
Reanimated Layout Animations
Reanimated provides built-in entering and exiting animations that work seamlessly with lists.
import React from 'react';
import { FlatList, Pressable, Text, View } from 'react-native';
import Animated, {
FadeIn,
FadeOut,
SlideInRight,
SlideOutLeft,
Layout,
ZoomIn,
ZoomOut,
} from 'react-native-reanimated';
interface Item {
id: string;
title: string;
}
function AnimatedList() {
const [items, setItems] = useState<Item[]>([
{ id: '1', title: 'First Item' },
{ id: '2', title: 'Second Item' },
{ id: '3', title: 'Third Item' },
]);
const addItem = () => {
const newItem = {
id: Date.now().toString(),
title: `Item ${items.length + 1}`,
};
setItems([newItem, ...items]);
};
const removeItem = (id: string) => {
setItems(items.filter(item => item.id !== id));
};
const renderItem = ({ item, index }: { item: Item; index: number }) => (
<Animated.View
entering={SlideInRight.delay(index * 100).springify()}
exiting={SlideOutLeft.duration(300)}
layout={Layout.springify()}
style={styles.item}
>
<Text style={styles.itemText}>{item.title}</Text>
<Pressable onPress={() => removeItem(item.id)}>
<Text style={styles.deleteText}>Delete</Text>
</Pressable>
</Animated.View>
);
return (
<View style={styles.container}>
<Pressable style={styles.addButton} onPress={addItem}>
<Text style={styles.addButtonText}>Add Item</Text>
</Pressable>
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
/>
</View>
);
}
// Built-in entering animations:
// - FadeIn, FadeInUp, FadeInDown, FadeInLeft, FadeInRight
// - SlideInUp, SlideInDown, SlideInLeft, SlideInRight
// - ZoomIn, ZoomInUp, ZoomInDown, ZoomInLeft, ZoomInRight
// - BounceIn, BounceInUp, BounceInDown, BounceInLeft, BounceInRight
// - FlipInXUp, FlipInXDown, FlipInYLeft, FlipInYRight
// - StretchInX, StretchInY
// - LightSpeedInLeft, LightSpeedInRight
// Built-in exiting animations:
// - FadeOut, FadeOutUp, FadeOutDown, FadeOutLeft, FadeOutRight
// - SlideOutUp, SlideOutDown, SlideOutLeft, SlideOutRight
// - ZoomOut, ZoomOutUp, ZoomOutDown, ZoomOutLeft, ZoomOutRight
// - BounceOut, BounceOutUp, BounceOutDown, BounceOutLeft, BounceOutRight
Custom Entering/Exiting Animations
import Animated, {
withTiming,
withSpring,
Easing,
} from 'react-native-reanimated';
// Custom entering animation
const CustomEntering = (targetValues) => {
'worklet';
const animations = {
opacity: withTiming(1, { duration: 300 }),
transform: [
{ translateY: withSpring(0, { damping: 15 }) },
{ scale: withSpring(1, { damping: 12 }) },
{ rotate: withTiming('0deg', { duration: 300 }) },
],
};
const initialValues = {
opacity: 0,
transform: [
{ translateY: 50 },
{ scale: 0.8 },
{ rotate: '-10deg' },
],
};
return {
initialValues,
animations,
};
};
// Custom exiting animation
const CustomExiting = (values) => {
'worklet';
const animations = {
opacity: withTiming(0, { duration: 200 }),
transform: [
{ translateX: withTiming(-100, { duration: 200 }) },
{ scale: withTiming(0.5, { duration: 200 }) },
],
};
const initialValues = {
opacity: 1,
transform: [
{ translateX: 0 },
{ scale: 1 },
],
};
return {
initialValues,
animations,
};
};
// Usage
function CustomAnimatedItem({ item }) {
return (
<Animated.View
entering={CustomEntering}
exiting={CustomExiting}
style={styles.item}
>
<Text>{item.title}</Text>
</Animated.View>
);
}
Staggered Grid Animation
import React, { useEffect, useState } from 'react';
import { View, StyleSheet, Dimensions } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withDelay,
withSpring,
} from 'react-native-reanimated';
const { width } = Dimensions.get('window');
const COLUMNS = 3;
const ITEM_SIZE = (width - 40 - (COLUMNS - 1) * 10) / COLUMNS;
interface GridItem {
id: string;
color: string;
}
function StaggeredGridItem({
item,
index
}: {
item: GridItem;
index: number;
}) {
const scale = useSharedValue(0);
const opacity = useSharedValue(0);
// Calculate row and column for stagger delay
const row = Math.floor(index / COLUMNS);
const col = index % COLUMNS;
const delay = (row + col) * 50; // Diagonal stagger
useEffect(() => {
scale.value = withDelay(delay, withSpring(1, { damping: 12 }));
opacity.value = withDelay(delay, withSpring(1));
}, []);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ scale: scale.value }],
}));
return (
<Animated.View
style={[
styles.gridItem,
{ backgroundColor: item.color },
animatedStyle,
]}
/>
);
}
function StaggeredGrid() {
const items: GridItem[] = Array.from({ length: 12 }, (_, i) => ({
id: `item-${i}`,
color: `hsl(${i * 30}, 70%, 60%)`,
}));
return (
<View style={styles.grid}>
{items.map((item, index) => (
<StaggeredGridItem key={item.id} item={item} index={index} />
))}
</View>
);
}
const styles = StyleSheet.create({
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
padding: 20,
gap: 10,
},
gridItem: {
width: ITEM_SIZE,
height: ITEM_SIZE,
borderRadius: 12,
},
});
Skeleton Loading
Skeleton screens provide perceived performance improvements by showing a preview of the content structure while data loads.
Shimmer Effect
import React, { useEffect } from 'react';
import { View, StyleSheet, Dimensions } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
interpolate,
Easing,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
const { width: SCREEN_WIDTH } = Dimensions.get('window');
interface SkeletonProps {
width?: number | string;
height?: number;
borderRadius?: number;
style?: any;
}
export function Skeleton({
width = '100%',
height = 20,
borderRadius = 4,
style,
}: SkeletonProps) {
const translateX = useSharedValue(-SCREEN_WIDTH);
useEffect(() => {
translateX.value = withRepeat(
withTiming(SCREEN_WIDTH, {
duration: 1500,
easing: Easing.inOut(Easing.ease),
}),
-1, // Infinite
false // Don't reverse
);
}, []);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
return (
<View
style={[
{
width,
height,
borderRadius,
backgroundColor: '#E1E9EE',
overflow: 'hidden',
},
style,
]}
>
<Animated.View style={[StyleSheet.absoluteFill, animatedStyle]}>
<LinearGradient
colors={['#E1E9EE', '#F2F8FC', '#E1E9EE']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[StyleSheet.absoluteFill, { width: SCREEN_WIDTH }]}
/>
</Animated.View>
</View>
);
}
// Card skeleton composition
export function CardSkeleton() {
return (
<View style={styles.card}>
<Skeleton width={60} height={60} borderRadius={30} />
<View style={styles.cardContent}>
<Skeleton width="70%" height={16} />
<Skeleton width="50%" height={14} style={{ marginTop: 8 }} />
</View>
</View>
);
}
// List skeleton
export function ListSkeleton({ count = 5 }: { count?: number }) {
return (
<View>
{Array.from({ length: count }).map((_, index) => (
<CardSkeleton key={index} />
))}
</View>
);
}
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
padding: 16,
backgroundColor: 'white',
marginBottom: 8,
borderRadius: 12,
},
cardContent: {
flex: 1,
marginLeft: 12,
justifyContent: 'center',
},
});
Pulse Skeleton (Alternative)
import React, { useEffect } from 'react';
import { View } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
Easing,
} from 'react-native-reanimated';
interface PulseSkeletonProps {
width?: number | string;
height?: number;
borderRadius?: number;
style?: any;
}
export function PulseSkeleton({
width = '100%',
height = 20,
borderRadius = 4,
style,
}: PulseSkeletonProps) {
const opacity = useSharedValue(0.3);
useEffect(() => {
opacity.value = withRepeat(
withTiming(1, {
duration: 800,
easing: Easing.inOut(Easing.ease),
}),
-1,
true // Reverse each time
);
}, []);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
return (
<Animated.View
style={[
{
width,
height,
borderRadius,
backgroundColor: '#E1E9EE',
},
animatedStyle,
style,
]}
/>
);
}
// Usage with conditional rendering
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) {
return (
<View style={styles.profile}>
<PulseSkeleton width={80} height={80} borderRadius={40} />
<PulseSkeleton width={150} height={24} style={{ marginTop: 16 }} />
<PulseSkeleton width={100} height={16} style={{ marginTop: 8 }} />
</View>
);
}
return (
<View style={styles.profile}>
<Image source={{ uri: user.avatar }} style={styles.avatar} />
<Text style={styles.name}>{user.name}</Text>
<Text style={styles.email}>{user.email}</Text>
</View>
);
}
Page Transitions
Custom page transitions can significantly enhance the user experience by providing visual continuity between screens.
Shared Element Concept
flowchart LR
subgraph Screen1["Screen A"]
A[πΌοΈ Image]
B[Title]
C[Subtitle]
end
subgraph Transition["Transition"]
D[πΌοΈ Image animates position/size]
E[Content fades]
end
subgraph Screen2["Screen B"]
F[πΌοΈ Image - larger]
G[Full content]
end
Screen1 --> Transition --> Screen2
style Transition fill:#fff3e0
Hero Transition Pattern
import React, { useState } from 'react';
import { View, Text, Image, Pressable, StyleSheet, Dimensions } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
interpolate,
} from 'react-native-reanimated';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
interface Item {
id: string;
title: string;
image: string;
description: string;
}
interface HeroTransitionProps {
items: Item[];
}
export function HeroTransition({ items }: HeroTransitionProps) {
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
const [origin, setOrigin] = useState({ x: 0, y: 0, width: 0, height: 0 });
const progress = useSharedValue(0);
const openDetail = (item: Item, layout: any) => {
setOrigin(layout);
setSelectedItem(item);
progress.value = withSpring(1, { damping: 15 });
};
const closeDetail = () => {
progress.value = withSpring(0, { damping: 15 }, (finished) => {
if (finished) {
runOnJS(setSelectedItem)(null);
}
});
};
const backdropStyle = useAnimatedStyle(() => ({
opacity: progress.value,
pointerEvents: progress.value > 0.5 ? 'auto' : 'none',
}));
const detailContainerStyle = useAnimatedStyle(() => {
const x = interpolate(progress.value, [0, 1], [origin.x, 0]);
const y = interpolate(progress.value, [0, 1], [origin.y, 100]);
const width = interpolate(progress.value, [0, 1], [origin.width, SCREEN_WIDTH]);
const height = interpolate(progress.value, [0, 1], [origin.height, 300]);
return {
position: 'absolute',
left: x,
top: y,
width,
height,
};
});
const contentStyle = useAnimatedStyle(() => ({
opacity: interpolate(progress.value, [0.5, 1], [0, 1]),
transform: [
{ translateY: interpolate(progress.value, [0.5, 1], [20, 0]) },
],
}));
const renderItem = (item: Item, index: number) => (
<Pressable
key={item.id}
onPress={(event) => {
event.target.measure((x, y, width, height, pageX, pageY) => {
openDetail(item, { x: pageX, y: pageY, width, height });
});
}}
style={styles.listItem}
>
<Image source={{ uri: item.image }} style={styles.thumbnail} />
<Text style={styles.itemTitle}>{item.title}</Text>
</Pressable>
);
return (
<View style={styles.container}>
{/* List */}
<View style={styles.list}>
{items.map(renderItem)}
</View>
{/* Detail overlay */}
{selectedItem && (
<>
<Animated.View style={[styles.backdrop, backdropStyle]}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={closeDetail}
/>
</Animated.View>
<Animated.View style={detailContainerStyle}>
<Image
source={{ uri: selectedItem.image }}
style={styles.detailImage}
/>
</Animated.View>
<Animated.View style={[styles.detailContent, contentStyle]}>
<Text style={styles.detailTitle}>{selectedItem.title}</Text>
<Text style={styles.detailDescription}>
{selectedItem.description}
</Text>
</Animated.View>
</>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
list: { padding: 16 },
listItem: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
backgroundColor: 'white',
borderRadius: 12,
overflow: 'hidden',
},
thumbnail: {
width: 80,
height: 80,
},
itemTitle: {
fontSize: 16,
fontWeight: '600',
marginLeft: 12,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
detailImage: {
width: '100%',
height: '100%',
borderRadius: 12,
},
detailContent: {
position: 'absolute',
top: 420,
left: 0,
right: 0,
padding: 20,
},
detailTitle: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 12,
},
detailDescription: {
fontSize: 16,
lineHeight: 24,
color: '#666',
},
});
Page Flip Transition
import React, { useState } from 'react';
import { View, Pressable, Text, StyleSheet, Dimensions } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
interpolate,
Extrapolation,
} from 'react-native-reanimated';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
interface FlipPageProps {
frontContent: React.ReactNode;
backContent: React.ReactNode;
}
export function FlipPage({ frontContent, backContent }: FlipPageProps) {
const [isFlipped, setIsFlipped] = useState(false);
const rotation = useSharedValue(0);
const flip = () => {
const newFlipped = !isFlipped;
setIsFlipped(newFlipped);
rotation.value = withSpring(newFlipped ? 180 : 0, {
damping: 15,
stiffness: 100,
});
};
const frontStyle = useAnimatedStyle(() => {
const rotateY = interpolate(
rotation.value,
[0, 180],
[0, 180]
);
return {
transform: [
{ perspective: 1000 },
{ rotateY: `${rotateY}deg` },
],
backfaceVisibility: 'hidden',
};
});
const backStyle = useAnimatedStyle(() => {
const rotateY = interpolate(
rotation.value,
[0, 180],
[180, 360]
);
return {
transform: [
{ perspective: 1000 },
{ rotateY: `${rotateY}deg` },
],
backfaceVisibility: 'hidden',
};
});
return (
<Pressable onPress={flip} style={styles.container}>
<Animated.View style={[styles.page, frontStyle]}>
{frontContent}
</Animated.View>
<Animated.View style={[styles.page, styles.backPage, backStyle]}>
{backContent}
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
width: SCREEN_WIDTH - 40,
height: 200,
},
page: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#007AFF',
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
backPage: {
backgroundColor: '#34C759',
},
});
// Usage
function FlipCard() {
return (
<FlipPage
frontContent={
<View>
<Text style={styles.cardTitle}>Front Side</Text>
<Text style={styles.cardHint}>Tap to flip</Text>
</View>
}
backContent={
<View>
<Text style={styles.cardTitle}>Back Side</Text>
<Text style={styles.cardHint}>Hidden content revealed!</Text>
</View>
}
/>
);
}
Hands-On Exercises
Exercise 1: Animated Toast Notification
Create a toast notification system with animated entrance and exit.
Requirements:
- Slides in from the top with bounce
- Auto-dismisses after 3 seconds
- Supports different types (success, error, info)
- Can be manually dismissed by swiping up
Show Solution
import React, { useEffect, useState, createContext, useContext } from 'react';
import { View, Text, StyleSheet, Dimensions, Pressable } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
useAnimatedGestureHandler,
} from 'react-native-reanimated';
import { PanGestureHandler } from 'react-native-gesture-handler';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: string;
message: string;
type: ToastType;
}
// Toast Context
const ToastContext = createContext<{
showToast: (message: string, type?: ToastType) => void;
}>({ showToast: () => {} });
export function useToast() {
return useContext(ToastContext);
}
// Individual Toast Component
function ToastItem({
toast,
onDismiss
}: {
toast: Toast;
onDismiss: (id: string) => void;
}) {
const translateY = useSharedValue(-100);
const opacity = useSharedValue(0);
useEffect(() => {
// Animate in
translateY.value = withSpring(0, { damping: 12 });
opacity.value = withTiming(1, { duration: 200 });
// Auto dismiss after 3 seconds
const timeout = setTimeout(() => {
dismiss();
}, 3000);
return () => clearTimeout(timeout);
}, []);
const dismiss = () => {
translateY.value = withTiming(-100, { duration: 200 });
opacity.value = withTiming(0, { duration: 200 }, (finished) => {
if (finished) {
runOnJS(onDismiss)(toast.id);
}
});
};
const gestureHandler = useAnimatedGestureHandler({
onActive: (event) => {
if (event.translationY < 0) {
translateY.value = event.translationY;
}
},
onEnd: (event) => {
if (event.translationY < -50 || event.velocityY < -500) {
// Swipe up to dismiss
translateY.value = withTiming(-100, { duration: 150 });
opacity.value = withTiming(0, { duration: 150 }, (finished) => {
if (finished) {
runOnJS(onDismiss)(toast.id);
}
});
} else {
// Snap back
translateY.value = withSpring(0, { damping: 12 });
}
},
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
opacity: opacity.value,
}));
const getColors = () => {
switch (toast.type) {
case 'success': return { bg: '#34C759', icon: 'β' };
case 'error': return { bg: '#FF3B30', icon: 'β' };
case 'info': return { bg: '#007AFF', icon: 'βΉ' };
}
};
const colors = getColors();
return (
<PanGestureHandler onGestureEvent={gestureHandler}>
<Animated.View style={[styles.toast, animatedStyle]}>
<View style={[styles.toastContent, { backgroundColor: colors.bg }]}>
<Text style={styles.toastIcon}>{colors.icon}</Text>
<Text style={styles.toastMessage}>{toast.message}</Text>
</View>
</Animated.View>
</PanGestureHandler>
);
}
// Toast Provider
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = (message: string, type: ToastType = 'info') => {
const id = Date.now().toString();
setToasts(prev => [...prev, { id, message, type }]);
};
const dismissToast = (id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
};
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<View style={styles.toastContainer} pointerEvents="box-none">
{toasts.map(toast => (
<ToastItem
key={toast.id}
toast={toast}
onDismiss={dismissToast}
/>
))}
</View>
</ToastContext.Provider>
);
}
const styles = StyleSheet.create({
toastContainer: {
position: 'absolute',
top: 50,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 1000,
},
toast: {
marginBottom: 8,
},
toastContent: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 12,
minWidth: SCREEN_WIDTH - 40,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 4,
},
toastIcon: {
fontSize: 18,
color: 'white',
marginRight: 12,
fontWeight: 'bold',
},
toastMessage: {
color: 'white',
fontSize: 16,
flex: 1,
},
});
// Usage
function App() {
return (
<ToastProvider>
<MyScreen />
</ToastProvider>
);
}
function MyScreen() {
const { showToast } = useToast();
return (
<View>
<Pressable onPress={() => showToast('Success!', 'success')}>
<Text>Show Success</Text>
</Pressable>
<Pressable onPress={() => showToast('Error occurred', 'error')}>
<Text>Show Error</Text>
</Pressable>
</View>
);
}
Exercise 2: Animated Accordion
Build an accordion component with smooth expand/collapse animations.
Requirements:
- Click header to expand/collapse content
- Smooth height animation
- Rotating chevron indicator
- Support for multiple sections (only one open at a time optional)
Show Solution
import React, { useState } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
measure,
useAnimatedRef,
runOnUI,
} from 'react-native-reanimated';
interface AccordionItemProps {
title: string;
children: React.ReactNode;
isExpanded: boolean;
onToggle: () => void;
}
function AccordionItem({
title,
children,
isExpanded,
onToggle
}: AccordionItemProps) {
const contentRef = useAnimatedRef<Animated.View>();
const height = useSharedValue(0);
const rotation = useSharedValue(0);
React.useEffect(() => {
if (isExpanded) {
// Measure content and animate to full height
runOnUI(() => {
'worklet';
const measured = measure(contentRef);
if (measured) {
height.value = withTiming(measured.height, { duration: 300 });
}
})();
rotation.value = withTiming(180, { duration: 300 });
} else {
height.value = withTiming(0, { duration: 300 });
rotation.value = withTiming(0, { duration: 300 });
}
}, [isExpanded]);
const containerStyle = useAnimatedStyle(() => ({
height: height.value,
overflow: 'hidden',
}));
const chevronStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<View style={styles.accordionItem}>
<Pressable onPress={onToggle} style={styles.accordionHeader}>
<Text style={styles.accordionTitle}>{title}</Text>
<Animated.Text style={[styles.chevron, chevronStyle]}>
βΌ
</Animated.Text>
</Pressable>
<Animated.View style={containerStyle}>
<Animated.View
ref={contentRef}
style={styles.accordionContent}
collapsable={false}
>
{children}
</Animated.View>
</Animated.View>
</View>
);
}
interface AccordionSection {
id: string;
title: string;
content: React.ReactNode;
}
interface AccordionProps {
sections: AccordionSection[];
allowMultiple?: boolean;
}
export function Accordion({ sections, allowMultiple = false }: AccordionProps) {
const [expandedIds, setExpandedIds] = useState<string[]>([]);
const toggleSection = (id: string) => {
if (allowMultiple) {
setExpandedIds(prev =>
prev.includes(id)
? prev.filter(i => i !== id)
: [...prev, id]
);
} else {
setExpandedIds(prev =>
prev.includes(id) ? [] : [id]
);
}
};
return (
<View style={styles.accordion}>
{sections.map(section => (
<AccordionItem
key={section.id}
title={section.title}
isExpanded={expandedIds.includes(section.id)}
onToggle={() => toggleSection(section.id)}
>
{section.content}
</AccordionItem>
))}
</View>
);
}
const styles = StyleSheet.create({
accordion: {
borderRadius: 12,
overflow: 'hidden',
},
accordionItem: {
backgroundColor: 'white',
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
accordionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
},
accordionTitle: {
fontSize: 16,
fontWeight: '600',
},
chevron: {
fontSize: 12,
color: '#666',
},
accordionContent: {
padding: 16,
paddingTop: 0,
},
});
// Usage
function FAQScreen() {
const sections = [
{
id: '1',
title: 'How do I create an account?',
content: <Text>To create an account, tap the Sign Up button...</Text>,
},
{
id: '2',
title: 'How do I reset my password?',
content: <Text>Go to Settings > Account > Reset Password...</Text>,
},
{
id: '3',
title: 'How do I contact support?',
content: <Text>You can reach us at support@example.com...</Text>,
},
];
return <Accordion sections={sections} />;
}
Exercise 3: Pull-to-Refresh Animation
Create a custom pull-to-refresh indicator with a unique animation.
Requirements:
- Custom refresh indicator (not the default spinner)
- Animate based on pull distance
- Different animation when refreshing vs. pulling
- Works with ScrollView or FlatList
Show Solution
import React, { useState } from 'react';
import { View, Text, StyleSheet, Dimensions } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
useAnimatedScrollHandler,
withSpring,
withRepeat,
withTiming,
interpolate,
Extrapolation,
runOnJS,
cancelAnimation,
} from 'react-native-reanimated';
const REFRESH_THRESHOLD = 80;
interface PullToRefreshProps {
onRefresh: () => Promise<void>;
children: React.ReactNode;
}
export function PullToRefresh({ onRefresh, children }: PullToRefreshProps) {
const [refreshing, setRefreshing] = useState(false);
const scrollY = useSharedValue(0);
const pullProgress = useSharedValue(0);
const refreshRotation = useSharedValue(0);
const triggerRefresh = async () => {
setRefreshing(true);
// Start spinning animation
refreshRotation.value = withRepeat(
withTiming(360, { duration: 1000 }),
-1,
false
);
try {
await onRefresh();
} finally {
cancelAnimation(refreshRotation);
refreshRotation.value = withTiming(0, { duration: 200 });
setRefreshing(false);
}
};
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
if (event.contentOffset.y < 0) {
pullProgress.value = Math.min(
Math.abs(event.contentOffset.y) / REFRESH_THRESHOLD,
1.5
);
} else {
pullProgress.value = 0;
}
},
onEndDrag: (event) => {
if (event.contentOffset.y < -REFRESH_THRESHOLD && !refreshing) {
runOnJS(triggerRefresh)();
}
},
});
// Indicator container style
const indicatorContainerStyle = useAnimatedStyle(() => {
const translateY = interpolate(
pullProgress.value,
[0, 1],
[-60, 20],
Extrapolation.CLAMP
);
return {
transform: [{ translateY }],
opacity: interpolate(
pullProgress.value,
[0, 0.5, 1],
[0, 0.5, 1],
Extrapolation.CLAMP
),
};
});
// Dots animation
const dot1Style = useAnimatedStyle(() => {
const scale = refreshing
? 1
: interpolate(
pullProgress.value,
[0, 0.3, 0.6, 1],
[0.3, 1, 0.5, 1],
Extrapolation.CLAMP
);
return {
transform: [
{ scale },
{ rotate: `${refreshRotation.value}deg` },
],
};
});
const dot2Style = useAnimatedStyle(() => {
const scale = refreshing
? 1
: interpolate(
pullProgress.value,
[0, 0.4, 0.7, 1],
[0.3, 0.5, 1, 1],
Extrapolation.CLAMP
);
return {
transform: [
{ scale },
{ rotate: `${refreshRotation.value + 120}deg` },
],
};
});
const dot3Style = useAnimatedStyle(() => {
const scale = refreshing
? 1
: interpolate(
pullProgress.value,
[0, 0.5, 0.8, 1],
[0.3, 0.5, 0.8, 1],
Extrapolation.CLAMP
);
return {
transform: [
{ scale },
{ rotate: `${refreshRotation.value + 240}deg` },
],
};
});
return (
<View style={styles.container}>
{/* Refresh Indicator */}
<Animated.View style={[styles.indicatorContainer, indicatorContainerStyle]}>
<View style={styles.dotsContainer}>
<Animated.View style={[styles.dot, styles.dot1, dot1Style]} />
<Animated.View style={[styles.dot, styles.dot2, dot2Style]} />
<Animated.View style={[styles.dot, styles.dot3, dot3Style]} />
</View>
<Text style={styles.indicatorText}>
{refreshing ? 'Refreshing...' : 'Pull to refresh'}
</Text>
</Animated.View>
{/* Scrollable Content */}
<Animated.ScrollView
onScroll={scrollHandler}
scrollEventThrottle={16}
contentContainerStyle={styles.scrollContent}
>
{children}
</Animated.ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
indicatorContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 10,
},
dotsContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
height: 40,
},
dot: {
width: 12,
height: 12,
borderRadius: 6,
marginHorizontal: 4,
},
dot1: { backgroundColor: '#FF3B30' },
dot2: { backgroundColor: '#34C759' },
dot3: { backgroundColor: '#007AFF' },
indicatorText: {
fontSize: 12,
color: '#666',
marginTop: 4,
},
scrollContent: {
paddingTop: 20,
},
});
// Usage
function RefreshableList() {
const [items, setItems] = useState(['Item 1', 'Item 2', 'Item 3']);
const handleRefresh = async () => {
await new Promise(resolve => setTimeout(resolve, 2000));
setItems(prev => [`New Item ${Date.now()}`, ...prev]);
};
return (
<PullToRefresh onRefresh={handleRefresh}>
{items.map((item, index) => (
<View key={index} style={styles.listItem}>
<Text>{item}</Text>
</View>
))}
</PullToRefresh>
);
}
Summary
Animation patterns are the building blocks of engaging mobile experiences. By mastering these common patterns, you can create polished, professional apps that feel native and responsive.
π― Key Takeaways
- Fade animations: Use for content transitions, modals, and visibility changes
- Slide animations: Perfect for drawers, page transitions, and directional reveals
- Scale and bounce: Provide tactile feedback for interactive elements
- Staggered lists: Use built-in entering/exiting animations or custom delays for list items
- Skeleton loading: Shimmer or pulse effects improve perceived performance
- Button animations: Ripples, loading states, and success/error feedback enhance interactions
- Page transitions: Hero animations and flips create visual continuity
Animation Pattern Quick Reference
| Pattern | Best Animation Type | Use Case |
|---|---|---|
| Fade In/Out | withTiming |
Modals, tooltips, transitions |
| Slide | withSpring |
Drawers, sheets, menus |
| Press feedback | withSpring (stiff) |
Buttons, cards, list items |
| Bounce/Pop | withSpring (bouncy) |
Notifications, badges, alerts |
| Loading pulse | withRepeat + withTiming |
Skeletons, activity indicators |
| Staggered list | withDelay + any |
Lists, grids, onboarding |
In the next lesson, we'll explore React Native Gesture Handler to create touch interactions that work seamlessly with these animations.