Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions docs/ANIMATION_SCHEDULING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# Animation Scheduling Best Practices

## Overview

This document outlines the best practices for animation scheduling in the teachLink_mobile project, focusing on using `requestAnimationFrame` for smooth, frame-synced animations at 60fps.

## Why requestAnimationFrame?

Using `requestAnimationFrame` (rAF) instead of `setTimeout`/`setInterval` for animations provides several benefits:

- **Frame Synchronization**: rAF syncs with the browser's refresh rate, ensuring animations run at 60fps on capable devices
- **Battery Efficiency**: rAF pauses when the tab is inactive, saving battery
- **Smooth Animations**: Avoids jank caused by setTimeout's imprecise timing
- **Better Performance**: Browsers can optimize rAF callbacks more effectively

## Animation Scheduler Utility

The project includes a comprehensive animation scheduler utility at `src/utils/animationScheduler.ts` that provides:

### Core Classes and Functions

#### `AnimationScheduler`
A class for managing complex animations with frame-synced timing.

```typescript
const scheduler = new AnimationScheduler();
scheduler.schedule((timestamp) => {
// Animation logic
return true; // Return false to stop
}, 1000); // Optional duration in ms
```

#### `scheduleAnimationFrame`
A drop-in replacement for setTimeout that uses rAF for execution.

```typescript
const cancel = scheduleAnimationFrame(() => {
// Your code
}, 1000); // Optional delay

// Cancel if needed
cancel();
```

#### `debounceAnimationFrame`
Debounce function that ensures callbacks run on the next animation frame.

```typescript
const debouncedFn = debounceAnimationFrame((value) => {
// Handle value
}, 100);
```

#### `throttleAnimationFrame`
Throttle function that ensures callbacks run at most once per animation frame.

```typescript
const throttledFn = throttleAnimationFrame((event) => {
// Handle event
});
```

## When to Use requestAnimationFrame

### Use rAF for:
- Visual animations (transitions, transforms, opacity changes)
- Gesture timing (long press, double tap detection)
- UI feedback animations (toasts, loading states)
- Scroll-related animations
- Any animation that needs to run smoothly at 60fps

### Use setTimeout for:
- Network request timeouts
- Debouncing API calls
- Non-animation timing requirements
- Operations that don't need frame synchronization

## Implementation Examples

### Gesture Timing (Long Press)

```typescript
// Before: Using setTimeout
timerRef.current = setTimeout(() => {
onLongPress({ pageX, pageY });
}, durationMs);

// After: Using requestAnimationFrame
startTimeRef.current = performance.now();
const checkDuration = (timestamp: number) => {
const elapsed = timestamp - startTimeRef.current;
if (elapsed >= durationMs) {
onLongPress({ pageX, pageY });
} else {
rafRef.current = requestAnimationFrame(checkDuration);
}
};
rafRef.current = requestAnimationFrame(checkDuration);
```

### Toast Dismissal

```typescript
// Before: Using setTimeout
setTimeout(() => {
removeToast(id);
}, toastDuration);

// After: Using scheduleAnimationFrame
const cancelSchedule = scheduleAnimationFrame(() => {
removeToast(id);
}, toastDuration);
```

### Video Player Auto-Hide

```typescript
// Before: Using setTimeout
hideTimerRef.current = setTimeout(() => {
setControlsVisible(false);
}, AUTO_HIDE_MS);

// After: Using scheduleAnimationFrame
hideTimerRef.current = scheduleAnimationFrame(() => {
setControlsVisible(false);
}, AUTO_HIDE_MS);
```

## React Native Animated API

For React Native animations, prefer using the built-in `Animated` API or `react-native-reanimated`:

```typescript
// These are already optimized and use native drivers
Animated.timing(value, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}).start();

// react-native-reanimated (runs on UI thread)
withSpring(translateX.value, SPRING_CONFIG);
withTiming(translateY.value, { duration: 200 });
```

## Performance Considerations

### Adaptive Frame Rate

The project includes `useAdaptiveFrameRate` hook to adjust animations based on device capabilities:

```typescript
const { durationMultiplier } = useAdaptiveFrameRate();

// Use multiplier for animation durations
Animated.timing(value, {
duration: 300 * durationMultiplier, // Scales based on device
useNativeDriver: true,
}).start();
```

### Cleanup

Always clean up animation callbacks to prevent memory leaks:

```typescript
useEffect(() => {
const scheduler = new AnimationScheduler();
scheduler.schedule(callback, duration);

return () => {
scheduler.dispose(); // Clean up
};
}, []);
```

## Testing Animation Performance

To verify 60fps performance:

1. Use React Native's `PerformanceOverlay` to monitor FPS
2. Test on low-end devices to ensure smooth performance
3. Use the `useAdaptiveFrameRate` hook for device-aware animations
4. Profile animations using React DevTools or Flipper

## Migration Checklist

When migrating from setTimeout to requestAnimationFrame:

- [ ] Identify all setTimeout calls used for animations
- [ ] Replace with scheduleAnimationFrame or direct rAF usage
- [ ] Ensure proper cleanup of rAF callbacks
- [ ] Test animations on different devices
- [ ] Verify 60fps performance
- [ ] Update documentation

## Common Pitfalls

1. **Forgetting to cancel rAF callbacks**: Always cancel on unmount
2. **Using rAF for non-animation timing**: Use setTimeout for network timeouts
3. **Not using native drivers**: Always use `useNativeDriver: true` when possible
4. **Ignoring device capabilities**: Use adaptive frame rate for low-end devices

## References

- [MDN: Window.requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame)
- [React Native: Animated API](https://reactnative.dev/docs/animations)
- [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/)
48 changes: 28 additions & 20 deletions src/components/mobile/MobileVideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,27 @@ import { Audio, AVPlaybackStatus, AVPlaybackStatusToSet, ResizeMode, Video } fro
import * as Network from 'expo-network';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Modal,
Pressable,
StyleProp,
StyleSheet,
Text,
View,
ViewStyle,
ActivityIndicator,
Modal,
Pressable,
StyleProp,
StyleSheet,
Text,
View,
ViewStyle,
} from 'react-native';

import VideoControls from './VideoControls';
import { usePictureInPicture, useVideoGestures } from '../../hooks';
import {
AUTO_QUALITY_ID,
deriveNetworkType,
getQualityOptions,
normalizeSources,
selectSourceById,
type NetworkType,
type NormalizedVideoSource,
type VideoSource,
AUTO_QUALITY_ID,
deriveNetworkType,
getQualityOptions,
normalizeSources,
selectSourceById,
type NetworkType,
type NormalizedVideoSource,
type VideoSource,
} from '../../services/videoQuality';
import { ErrorBoundary } from '../common/ErrorBoundary';

Expand Down Expand Up @@ -77,7 +77,7 @@ const MobileVideoPlayer = ({
const videoRef = useRef<Video | null>(null);
const autoPlayHandledRef = useRef(false);
const lastToggleRef = useRef(0);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | (() => void) | null>(null);
const resumeStatusRef = useRef<AVPlaybackStatusToSet | null>(null);

const [networkType, setNetworkType] = useState<NetworkType>('unknown');
Expand Down Expand Up @@ -119,9 +119,13 @@ const MobileVideoPlayer = ({
return;
}
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
if (typeof hideTimerRef.current === 'function') {
hideTimerRef.current();
} else {
clearTimeout(hideTimerRef.current);
}
}
hideTimerRef.current = setTimeout(() => {
hideTimerRef.current = scheduleAnimationFrame(() => {
setControlsVisible(false);
}, AUTO_HIDE_MS);
}, []);
Expand Down Expand Up @@ -308,7 +312,11 @@ const MobileVideoPlayer = ({
}
return () => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
if (typeof hideTimerRef.current === 'function') {
hideTimerRef.current();
} else {
clearTimeout(hideTimerRef.current);
}
}
};
}, [controlsVisibleEffective, error, isPlaying, isScrubbing, scheduleAutoHide]);
Expand Down
7 changes: 5 additions & 2 deletions src/components/mobile/OfflineIndicatorProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,14 @@ export const OfflineIndicatorProvider = (props: any) => {

setToasts((prev: any) => [...prev, toast]);

// Auto-remove toast after duration
setTimeout(() => {
// Auto-remove toast after duration using requestAnimationFrame for frame-synced timing
const cancelSchedule = scheduleAnimationFrame(() => {
removeToast(id);
}, toastDuration);

// Store cancel function for cleanup if needed
(toast as any).cancelSchedule = cancelSchedule;

return id;
};

Expand Down
5 changes: 3 additions & 2 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export * from './usePinchZoom';
export * from './usePrefetchImages';
export * from './useSafeArea';
export * from './useScreenReader';
export * from './useStreamingData';
export * from './useSwipe';
export * from './useVideoGestures';
export * from './useVoiceRecognition';
Expand All @@ -35,8 +36,8 @@ export { OptimizedLongPressView, useOptimizedLongPress } from './useOptimizedLon
export { OptimizedPinchZoomView, useOptimizedPinchZoom } from './useOptimizedPinchZoom';
export { OptimizedSwipeView, useOptimizedSwipe } from './useOptimizedSwipe';
export { OptimizedVideoGesturesView, useOptimizedVideoGestures } from './useOptimizedVideoGestures';

export * from './useHealthDashboard';
export * from './usePredictivePreload';
export * from './useOptimizedClipboard';

export * from './useReactProfiler';
export * from './useReactProfiler';
37 changes: 29 additions & 8 deletions src/hooks/useGestures.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import { AccessibilityInfo } from 'react-native';
import type { GestureResponderEvent, ViewProps } from 'react-native';
import { AccessibilityInfo } from 'react-native';

/**
* A tiny gesture "arbiter" to prevent recognizers (swipe/pinch/long-press/etc.)
Expand Down Expand Up @@ -167,14 +167,23 @@ export function useDoubleTap(options: UseDoubleTapOptions) {

const tap1Ref = React.useRef<{ t: number; x: number; y: number } | null>(null);
const startRef = React.useRef<{ x: number; y: number } | null>(null);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | number | null>(null);
const rafRef = React.useRef<number | null>(null);
const startTimeRef = React.useRef<number | null>(null);
const movedTooFarRef = React.useRef(false);

const clearTimer = React.useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
if (typeof timerRef.current === 'number') {
clearTimeout(timerRef.current);
}
timerRef.current = null;
}
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
startTimeRef.current = null;
}, []);

const reset = React.useCallback(() => {
Expand Down Expand Up @@ -226,11 +235,23 @@ export function useDoubleTap(options: UseDoubleTapOptions) {
// First tap: wait for the second.
tap1Ref.current = { t: now, x, y };
clearTimer();
timerRef.current = setTimeout(() => {
const stored = tap1Ref.current;
tap1Ref.current = null;
if (stored) onSingleTap?.({ pageX: stored.x, pageY: stored.y });
}, maxDelayMs);
startTimeRef.current = performance.now();

// Use requestAnimationFrame for frame-synced timing
const checkDuration = (timestamp: number) => {
const elapsed = timestamp - (startTimeRef.current ?? timestamp);
if (elapsed >= maxDelayMs) {
// Duration elapsed, trigger single tap
const stored = tap1Ref.current;
tap1Ref.current = null;
if (stored) onSingleTap?.({ pageX: stored.x, pageY: stored.y });
} else {
// Continue checking
rafRef.current = requestAnimationFrame(checkDuration);
}
};

rafRef.current = requestAnimationFrame(checkDuration);
},
onResponderTerminate: () => reset(),
};
Expand Down
Loading
Loading