Module 6: Navigation with Expo Router
Advanced Patterns
Master complex navigation with custom transitions and optimization
π― Learning Objectives
- Create custom screen transitions with Reanimated
- Implement shared element transitions between screens
- Build advanced modal patterns and bottom sheets
- Optimize navigation performance for large apps
- Handle complex navigation state scenarios
- Create conditional navigation flows
- Implement navigation analytics and logging
Custom Screen Transitions
While React Navigation provides built-in transitions, custom animations can make your app feel unique and polished. Use Reanimated for smooth, native-performance animations.
Built-in Animation Options
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function Layout() {
return (
<Stack
screenOptions={{
// Built-in animations
animation: 'slide_from_right', // default iOS
// animation: 'slide_from_bottom',
// animation: 'fade',
// animation: 'fade_from_bottom',
// animation: 'flip',
// animation: 'simple_push',
// animation: 'none',
// iOS-specific
animationTypeForReplace: 'push', // or 'pop'
// Timing
animationDuration: 350,
}}
>
<Stack.Screen name="index" />
<Stack.Screen
name="detail"
options={{
animation: 'fade_from_bottom',
}}
/>
</Stack>
);
}
Custom Transition with Reanimated
# Install Reanimated
npx expo install react-native-reanimated
// Custom fade and scale transition
import { Stack } from 'expo-router';
import {
StackCardStyleInterpolator,
StackCardInterpolationProps
} from '@react-navigation/stack';
import { Animated } from 'react-native';
// Custom interpolator for fade + scale effect
const forFadeScale: StackCardStyleInterpolator = ({
current,
next,
inverted,
layouts: { screen },
}: StackCardInterpolationProps) => {
const progress = Animated.add(
current.progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
extrapolate: 'clamp',
}),
next
? next.progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
extrapolate: 'clamp',
})
: 0
);
return {
cardStyle: {
opacity: current.progress.interpolate({
inputRange: [0, 0.5, 0.9, 1],
outputRange: [0, 0.25, 0.7, 1],
}),
transform: [
{
scale: current.progress.interpolate({
inputRange: [0, 1],
outputRange: [0.9, 1],
extrapolate: 'clamp',
}),
},
],
},
overlayStyle: {
opacity: current.progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 0.5],
extrapolate: 'clamp',
}),
},
};
};
export default function Layout() {
return (
<Stack
screenOptions={{
cardStyleInterpolator: forFadeScale,
gestureEnabled: true,
gestureDirection: 'horizontal',
}}
>
<Stack.Screen name="index" />
</Stack>
);
}
Slide from Different Directions
// Custom slide transitions
import { TransitionPresets } from '@react-navigation/stack';
// Slide from left (reverse of default)
const slideFromLeft: StackCardStyleInterpolator = ({ current, layouts }) => ({
cardStyle: {
transform: [
{
translateX: current.progress.interpolate({
inputRange: [0, 1],
outputRange: [-layouts.screen.width, 0],
}),
},
],
},
});
// Slide from top
const slideFromTop: StackCardStyleInterpolator = ({ current, layouts }) => ({
cardStyle: {
transform: [
{
translateY: current.progress.interpolate({
inputRange: [0, 1],
outputRange: [-layouts.screen.height, 0],
}),
},
],
},
});
// Usage per screen
<Stack.Screen
name="notifications"
options={{
cardStyleInterpolator: slideFromTop,
gestureDirection: 'vertical',
}}
/>
Platform-Specific Transitions
import { Platform } from 'react-native';
import { TransitionPresets } from '@react-navigation/stack';
const screenOptions = Platform.select({
ios: {
...TransitionPresets.SlideFromRightIOS,
gestureEnabled: true,
gestureResponseDistance: 50,
},
android: {
...TransitionPresets.FadeFromBottomAndroid,
gestureEnabled: false,
},
default: {
animation: 'fade',
},
});
export default function Layout() {
return (
<Stack screenOptions={screenOptions}>
{/* screens */}
</Stack>
);
}
Advanced Modal Patterns
Modals are versatile for focused interactions. Let's explore advanced patterns beyond basic modals.
Modal Stack Pattern
// Multiple modals that stack on top of each other
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
{/* Modal Group - can stack multiple */}
<Stack.Screen
name="modal/select-category"
options={{
presentation: 'modal',
title: 'Select Category',
}}
/>
<Stack.Screen
name="modal/select-subcategory"
options={{
presentation: 'modal',
title: 'Select Subcategory',
}}
/>
<Stack.Screen
name="modal/confirm"
options={{
presentation: 'transparentModal',
headerShown: false,
animation: 'fade',
}}
/>
</Stack>
);
}
Transparent Modal with Custom Background
// app/modal/confirm.tsx
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import Animated, { FadeIn, FadeOut, SlideInDown } from 'react-native-reanimated';
interface ConfirmModalProps {
title?: string;
message?: string;
}
export default function ConfirmModal() {
const router = useRouter();
const handleConfirm = () => {
// Do something
router.back();
};
const handleCancel = () => {
router.back();
};
return (
<View style={styles.container}>
{/* Backdrop */}
<Animated.View
entering={FadeIn}
exiting={FadeOut}
style={styles.backdrop}
>
<Pressable style={StyleSheet.absoluteFill} onPress={handleCancel} />
</Animated.View>
{/* Modal Content */}
<Animated.View
entering={SlideInDown.springify().damping(15)}
style={styles.modal}
>
<Text style={styles.title}>Confirm Action</Text>
<Text style={styles.message}>
Are you sure you want to proceed?
</Text>
<View style={styles.buttons}>
<Pressable style={styles.cancelButton} onPress={handleCancel}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
<Pressable style={styles.confirmButton} onPress={handleConfirm}>
<Text style={styles.confirmText}>Confirm</Text>
</Pressable>
</View>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
modal: {
backgroundColor: '#fff',
borderRadius: 16,
padding: 24,
width: '85%',
maxWidth: 400,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.25,
shadowRadius: 20,
elevation: 10,
},
title: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 12,
textAlign: 'center',
},
message: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 24,
},
buttons: {
flexDirection: 'row',
gap: 12,
},
cancelButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 8,
backgroundColor: '#f3f4f6',
alignItems: 'center',
},
cancelText: {
fontSize: 16,
fontWeight: '600',
color: '#374151',
},
confirmButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 8,
backgroundColor: '#6366f1',
alignItems: 'center',
},
confirmText: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
});
Form Sheet Modal (iOS Style)
// app/modal/edit-profile.tsx
<Stack.Screen
name="modal/edit-profile"
options={{
presentation: 'formSheet', // iOS 15+ style
sheetAllowedDetents: [0.5, 0.75, 1], // Height stops
sheetLargestUndimmedDetent: 0.5, // Allow interaction behind
sheetGrabberVisible: true, // Show grabber handle
sheetCornerRadius: 24,
}}
/>
flowchart TD
subgraph Stack["Navigation Stack"]
A[Main Screen]
B[Modal 1: Category]
C[Modal 2: Subcategory]
D[Modal 3: Confirm]
end
A -->|"push modal"| B
B -->|"push modal"| C
C -->|"push transparent"| D
D -->|"dismiss"| C
C -->|"dismiss"| B
B -->|"dismiss"| A
style A fill:#e8f5e9
style B fill:#e3f2fd
style C fill:#e3f2fd
style D fill:#fce4ec
Bottom Sheet Navigation
Bottom sheets provide a native-feeling way to present content that can be dismissed with a swipe gesture.
Using @gorhom/bottom-sheet
# Install bottom sheet library
npx expo install @gorhom/bottom-sheet react-native-gesture-handler react-native-reanimated
// components/NavigableBottomSheet.tsx
import { forwardRef, useCallback, useMemo } from 'react';
import { View, StyleSheet } from 'react-native';
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetView,
} from '@gorhom/bottom-sheet';
import { useRouter } from 'expo-router';
interface NavigableBottomSheetProps {
children: React.ReactNode;
snapPoints?: (string | number)[];
onDismiss?: () => void;
}
export const NavigableBottomSheet = forwardRef<
BottomSheet,
NavigableBottomSheetProps
>(({ children, snapPoints: customSnapPoints, onDismiss }, ref) => {
const router = useRouter();
const snapPoints = useMemo(
() => customSnapPoints || ['25%', '50%', '90%'],
[customSnapPoints]
);
const handleSheetChanges = useCallback((index: number) => {
if (index === -1) {
// Sheet closed
onDismiss?.();
}
}, [onDismiss]);
const renderBackdrop = useCallback(
(props: any) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
);
return (
<BottomSheet
ref={ref}
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
enablePanDownToClose
handleIndicatorStyle={styles.indicator}
>
<BottomSheetView style={styles.content}>
{children}
</BottomSheetView>
</BottomSheet>
);
});
const styles = StyleSheet.create({
indicator: {
backgroundColor: '#ccc',
width: 40,
},
content: {
flex: 1,
padding: 16,
},
});
Bottom Sheet with Navigation
// Using bottom sheet as a navigation destination
// app/sheets/actions.tsx
import { useRef, useEffect } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import BottomSheet from '@gorhom/bottom-sheet';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
const actions = [
{ id: 'share', icon: 'share-outline', label: 'Share', route: '/share' },
{ id: 'edit', icon: 'create-outline', label: 'Edit', route: '/edit' },
{ id: 'delete', icon: 'trash-outline', label: 'Delete', route: '/confirm-delete' },
{ id: 'report', icon: 'flag-outline', label: 'Report', route: '/report' },
];
export default function ActionsSheet() {
const router = useRouter();
const { itemId } = useLocalSearchParams<{ itemId: string }>();
const bottomSheetRef = useRef<BottomSheet>(null);
const handleAction = (route: string) => {
bottomSheetRef.current?.close();
setTimeout(() => {
router.push(`${route}?itemId=${itemId}`);
}, 300);
};
const handleDismiss = () => {
router.back();
};
return (
<View style={styles.container}>
<Pressable style={styles.backdrop} onPress={handleDismiss} />
<BottomSheet
ref={bottomSheetRef}
snapPoints={['40%']}
onClose={handleDismiss}
enablePanDownToClose
>
<View style={styles.content}>
<Text style={styles.title}>Actions</Text>
{actions.map((action) => (
<Pressable
key={action.id}
style={styles.actionItem}
onPress={() => handleAction(action.route)}
>
<Ionicons name={action.icon as any} size={24} color="#333" />
<Text style={styles.actionLabel}>{action.label}</Text>
</Pressable>
))}
</View>
</BottomSheet>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.3)',
},
content: {
padding: 16,
},
title: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 16,
},
actionItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
actionLabel: {
fontSize: 16,
marginLeft: 16,
},
});
Bottom Tab with Sheet Trigger
// Create a center "+" button that opens a sheet instead of navigating
// app/(tabs)/_layout.tsx
import { Tabs, useRouter } from 'expo-router';
import { Pressable, View, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
const router = useRouter();
return (
<Tabs>
<Tabs.Screen name="home" />
<Tabs.Screen name="search" />
{/* Center button - opens sheet instead of screen */}
<Tabs.Screen
name="create"
options={{
tabBarButton: () => (
<Pressable
style={styles.createButton}
onPress={() => router.push('/sheets/create')}
>
<View style={styles.createButtonInner}>
<Ionicons name="add" size={28} color="#fff" />
</View>
</Pressable>
),
}}
/>
<Tabs.Screen name="notifications" />
<Tabs.Screen name="profile" />
</Tabs>
);
}
const styles = StyleSheet.create({
createButton: {
top: -20,
justifyContent: 'center',
alignItems: 'center',
},
createButtonInner: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#6366f1',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#6366f1',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
});
Performance Optimization
Navigation performance is critical for user experience. Slow transitions and janky animations make your app feel sluggish. Let's optimize.
Screen Preloading
// Preload screens that users are likely to visit
import { useEffect } from 'react';
import { useRouter } from 'expo-router';
function ProductCard({ product }: { product: Product }) {
const router = useRouter();
// Preload detail screen when card becomes visible
useEffect(() => {
// Prefetch the route (if supported)
router.prefetch(`/product/${product.id}`);
}, [product.id]);
return (
<Pressable onPress={() => router.push(`/product/${product.id}`)}>
{/* card content */}
</Pressable>
);
}
Lazy Loading Screens
// Use React.lazy for heavy screens
import { Suspense, lazy } from 'react';
import { View, ActivityIndicator } from 'react-native';
// Lazy load heavy components
const HeavyChart = lazy(() => import('@/components/HeavyChart'));
const MapView = lazy(() => import('@/components/MapView'));
function AnalyticsScreen() {
return (
<View>
<Suspense fallback={<ActivityIndicator size="large" />}>
<HeavyChart />
</Suspense>
</View>
);
}
// For screens with maps - only load when needed
function LocationScreen() {
const [showMap, setShowMap] = useState(false);
return (
<View>
{showMap ? (
<Suspense fallback={<MapPlaceholder />}>
<MapView />
</Suspense>
) : (
<Button title="Load Map" onPress={() => setShowMap(true)} />
)}
</View>
);
}
Optimizing List Navigation
// Optimize FlatList for navigation performance
import { useCallback, memo } from 'react';
import { FlatList } from 'react-native';
import { useRouter } from 'expo-router';
// Memoize list items to prevent re-renders
const ProductItem = memo(function ProductItem({
product,
onPress
}: {
product: Product;
onPress: (id: string) => void;
}) {
return (
<Pressable onPress={() => onPress(product.id)}>
<Text>{product.title}</Text>
</Pressable>
);
});
function ProductList({ products }: { products: Product[] }) {
const router = useRouter();
// Memoize navigation handler
const handlePress = useCallback((id: string) => {
router.push(`/product/${id}`);
}, [router]);
// Memoize renderItem
const renderItem = useCallback(({ item }: { item: Product }) => (
<ProductItem product={item} onPress={handlePress} />
), [handlePress]);
// Memoize keyExtractor
const keyExtractor = useCallback((item: Product) => item.id, []);
return (
<FlatList
data={products}
renderItem={renderItem}
keyExtractor={keyExtractor}
// Performance optimizations
removeClippedSubviews
maxToRenderPerBatch={10}
windowSize={5}
initialNumToRender={10}
getItemLayout={(data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
/>
);
}
Preventing Unnecessary Re-renders
// Use useFocusEffect wisely - don't over-fetch
import { useFocusEffect } from '@react-navigation/native';
import { useCallback, useRef } from 'react';
function DataScreen() {
const hasLoaded = useRef(false);
const lastFetch = useRef<number>(0);
useFocusEffect(
useCallback(() => {
const now = Date.now();
const timeSinceLastFetch = now - lastFetch.current;
// Only refetch if:
// 1. Never loaded before, OR
// 2. More than 5 minutes since last fetch
if (!hasLoaded.current || timeSinceLastFetch > 5 * 60 * 1000) {
fetchData();
lastFetch.current = now;
hasLoaded.current = true;
}
}, [])
);
return (/* ... */);
}
// Use React Query for intelligent caching
import { useQuery } from '@tanstack/react-query';
function OptimizedDataScreen() {
const { data, isLoading } = useQuery({
queryKey: ['data'],
queryFn: fetchData,
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
cacheTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
});
return (/* ... */);
}
Reducing Navigation Stack Memory
// Replace instead of push for certain flows
import { useRouter } from 'expo-router';
function CheckoutFlow() {
const router = useRouter();
const handleComplete = () => {
// Replace entire checkout flow with success screen
// This removes checkout screens from memory
router.replace('/order-success');
};
// For wizard steps, consider replace instead of push
const goToNextStep = () => {
router.replace('/checkout/step-2'); // Not push
};
}
// Dismiss multiple screens at once
function DeepScreen() {
const router = useRouter();
const goBackToRoot = () => {
// Dismiss 3 screens at once
router.dismiss(3);
// Or use dismissAll
router.dismissAll();
};
}
Navigation Analytics
Tracking navigation helps you understand user behavior, identify pain points, and measure feature adoption.
Basic Screen Tracking
// hooks/useNavigationTracking.ts
import { useEffect } from 'react';
import { usePathname, useSegments } from 'expo-router';
export function useNavigationTracking() {
const pathname = usePathname();
const segments = useSegments();
useEffect(() => {
// Track screen view
trackScreenView({
screen_name: pathname,
screen_class: segments.join('/'),
timestamp: Date.now(),
});
}, [pathname, segments]);
}
// analytics.ts - example implementations
export function trackScreenView(params: {
screen_name: string;
screen_class: string;
timestamp: number;
}) {
// Firebase Analytics
// analytics().logScreenView(params);
// Mixpanel
// mixpanel.track('Screen View', params);
// Amplitude
// amplitude.logEvent('Screen View', params);
// Custom backend
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify({
event: 'screen_view',
...params,
}),
});
}
// Use in root layout
export default function RootLayout() {
useNavigationTracking();
return <Stack>{/* ... */}</Stack>;
}
Comprehensive Navigation Logger
// hooks/useNavigationLogger.ts
import { useEffect, useRef } from 'react';
import { usePathname, useSegments, useLocalSearchParams } from 'expo-router';
interface NavigationEvent {
type: 'screen_view' | 'navigation' | 'deep_link';
from?: string;
to: string;
params?: Record<string, string>;
timestamp: number;
duration?: number;
}
const navigationHistory: NavigationEvent[] = [];
export function useNavigationLogger() {
const pathname = usePathname();
const segments = useSegments();
const params = useLocalSearchParams();
const previousPath = useRef<string | null>(null);
const screenStartTime = useRef<number>(Date.now());
useEffect(() => {
const now = Date.now();
// Calculate time spent on previous screen
const duration = previousPath.current
? now - screenStartTime.current
: undefined;
// Log the navigation event
const event: NavigationEvent = {
type: 'screen_view',
from: previousPath.current || undefined,
to: pathname,
params: Object.keys(params).length > 0
? params as Record<string, string>
: undefined,
timestamp: now,
duration,
};
navigationHistory.push(event);
// Send to analytics
sendToAnalytics(event);
// Update refs
previousPath.current = pathname;
screenStartTime.current = now;
// Cleanup: track time when leaving screen
return () => {
const exitDuration = Date.now() - screenStartTime.current;
sendToAnalytics({
type: 'navigation',
from: pathname,
to: 'unknown', // Will be updated by next screen
timestamp: Date.now(),
duration: exitDuration,
});
};
}, [pathname]);
return { navigationHistory };
}
function sendToAnalytics(event: NavigationEvent) {
// Console logging for development
if (__DEV__) {
console.log('[Navigation]', {
...event,
duration: event.duration ? `${event.duration}ms` : undefined,
});
}
// Send to your analytics service
// analytics.track('navigation', event);
}
User Flow Analysis
// Track complete user flows
interface UserFlow {
flowName: string;
steps: string[];
startTime: number;
endTime?: number;
completed: boolean;
}
const activeFlows = new Map<string, UserFlow>();
export function startFlow(flowName: string) {
activeFlows.set(flowName, {
flowName,
steps: [],
startTime: Date.now(),
completed: false,
});
}
export function addFlowStep(flowName: string, step: string) {
const flow = activeFlows.get(flowName);
if (flow) {
flow.steps.push(step);
}
}
export function completeFlow(flowName: string, success: boolean) {
const flow = activeFlows.get(flowName);
if (flow) {
flow.endTime = Date.now();
flow.completed = success;
// Send flow analytics
sendFlowAnalytics(flow);
// Cleanup
activeFlows.delete(flowName);
}
}
// Usage in checkout flow
function CheckoutScreen() {
useEffect(() => {
startFlow('checkout');
addFlowStep('checkout', 'cart_review');
}, []);
const handlePaymentComplete = () => {
addFlowStep('checkout', 'payment_success');
completeFlow('checkout', true);
router.push('/order-confirmation');
};
}
Error Tracking in Navigation
// Track navigation errors
import { ErrorBoundary } from 'react-error-boundary';
function NavigationErrorFallback({ error, resetErrorBoundary }) {
useEffect(() => {
// Track the navigation error
trackError({
type: 'navigation_error',
error: error.message,
stack: error.stack,
timestamp: Date.now(),
});
}, [error]);
return (
<View style={styles.errorContainer}>
<Text>Something went wrong</Text>
<Button title="Go Home" onPress={() => {
resetErrorBoundary();
router.replace('/');
}} />
</View>
);
}
// Wrap your layout
export default function RootLayout() {
return (
<ErrorBoundary FallbackComponent={NavigationErrorFallback}>
<Stack>{/* ... */}</Stack>
</ErrorBoundary>
);
}
flowchart LR
A[User Action] --> B[Navigation Event]
B --> C{Analytics Pipeline}
C --> D[Screen Views]
C --> E[User Flows]
C --> F[Error Tracking]
C --> G[Performance Metrics]
D --> H[Dashboard]
E --> H
F --> H
G --> H
style H fill:#e8f5e9
Hands-On Exercises
Exercise 1: Custom Fade Transition
Create a custom screen transition that:
- Fades out the current screen while scaling down
- Fades in the new screen while scaling up
- Has a duration of 400ms
- Works with gesture navigation
β Solution
import { StackCardStyleInterpolator } from '@react-navigation/stack';
const fadeScaleTransition: StackCardStyleInterpolator = ({
current,
next,
}) => {
return {
cardStyle: {
opacity: current.progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
}),
transform: [
{
scale: current.progress.interpolate({
inputRange: [0, 1],
outputRange: [0.85, 1],
}),
},
],
},
overlayStyle: {
opacity: current.progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 0.3],
}),
},
};
};
// Usage
<Stack
screenOptions={{
cardStyleInterpolator: fadeScaleTransition,
transitionSpec: {
open: {
animation: 'timing',
config: { duration: 400 },
},
close: {
animation: 'timing',
config: { duration: 400 },
},
},
gestureEnabled: true,
gestureDirection: 'horizontal',
}}
>
Exercise 2: Shared Element Gallery
Build an image gallery with shared element transitions:
- Grid of thumbnails on the list screen
- Full-screen image on the detail screen
- Smooth transition between thumbnail and full image
π‘ Hint
Use Reanimated's sharedTransitionTag on both Animated.Image components with matching unique IDs. The transition happens automatically when navigating between screens.
Exercise 3: Action Sheet Navigator
Create a reusable action sheet that:
- Opens as a bottom sheet
- Accepts dynamic action items
- Navigates to different screens based on selection
- Closes smoothly before navigating
β Solution Outline
// app/sheets/[actions].tsx
import { useLocalSearchParams, useRouter } from 'expo-router';
import BottomSheet from '@gorhom/bottom-sheet';
export default function ActionSheet() {
const { actions } = useLocalSearchParams<{ actions: string }>();
const router = useRouter();
const parsedActions = JSON.parse(actions || '[]');
const sheetRef = useRef<BottomSheet>(null);
const handleSelect = async (route: string) => {
sheetRef.current?.close();
await new Promise(r => setTimeout(r, 300));
router.push(route);
};
return (
<BottomSheet ref={sheetRef} snapPoints={['40%']} enablePanDownToClose>
{parsedActions.map((action) => (
<Pressable key={action.id} onPress={() => handleSelect(action.route)}>
<Text>{action.label}</Text>
</Pressable>
))}
</BottomSheet>
);
}
// Usage
router.push({
pathname: '/sheets/actions',
params: {
actions: JSON.stringify([
{ id: 'edit', label: 'Edit', route: '/edit' },
{ id: 'delete', label: 'Delete', route: '/delete' },
]),
},
});
Exercise 4: Navigation Analytics Dashboard
Implement comprehensive navigation tracking:
- Track all screen views with timestamps
- Calculate time spent on each screen
- Track navigation paths (from β to)
- Create a debug screen showing navigation history
β Solution
// Store navigation history
const navigationStore = {
history: [] as NavigationEvent[],
addEvent(event: NavigationEvent) {
this.history.push(event);
// Keep last 100 events
if (this.history.length > 100) {
this.history.shift();
}
},
getAverageTimeOnScreen(screenName: string) {
const screenEvents = this.history.filter(
e => e.to === screenName && e.duration
);
if (screenEvents.length === 0) return 0;
const total = screenEvents.reduce((sum, e) => sum + (e.duration || 0), 0);
return total / screenEvents.length;
},
getMostVisitedScreens() {
const counts = this.history.reduce((acc, e) => {
acc[e.to] = (acc[e.to] || 0) + 1;
return acc;
}, {} as Record<string, number>);
return Object.entries(counts)
.sort(([, a], [, b]) => b - a)
.slice(0, 10);
},
};
// Debug screen
function NavigationDebugScreen() {
return (
<ScrollView>
<Text>Most Visited:</Text>
{navigationStore.getMostVisitedScreens().map(([screen, count]) => (
<Text key={screen}>{screen}: {count} views</Text>
))}
<Text>Recent History:</Text>
{navigationStore.history.slice(-20).reverse().map((e, i) => (
<Text key={i}>{e.from} β {e.to} ({e.duration}ms)</Text>
))}
</ScrollView>
);
}
Summary
Congratulations! You've completed the Navigation module and mastered advanced patterns for creating polished, performant navigation experiences.
π― Key Takeaways
- Custom Transitions: Use
cardStyleInterpolatorfor unique animations - Shared Elements: Match
sharedTransitionTagbetween screens for seamless transitions - Modal Patterns: Stack modals, use transparent modals for dialogs
- Bottom Sheets: @gorhom/bottom-sheet for native-feeling interactions
- Performance: Memoize, lazy load, use replace over push for flows
- Analytics: Track screen views, user flows, and navigation errors
π Module 6 Complete!
You've mastered navigation with Expo Router:
- β Navigation concepts and patterns
- β File-based routing with Expo Router
- β Stack, Tab, and Drawer navigation
- β Nested navigation architectures
- β Authentication flows
- β Deep linking and push notifications
- β Advanced patterns and optimization
What's Next?
In the next module, we'll explore State Managementβfrom React's built-in state to Context API, Zustand, and advanced state patterns for complex applications.