UTA DevHub
UI Development/Splash Screen Guide

Integration with App Initialization

Seamlessly integrating splash screens with the app initialization flow, including progress reporting

Integration with App Initialization

Overview

Properly integrating your splash screen with the app initialization process ensures a smooth user experience. The splash screen should remain visible during critical initialization tasks and hide only when your app is ready for interaction.

Key Integration Principle

The splash screen serves as a visual bridge during the Pre-UI initialization stage. It should:

  • Remain visible until critical services are ready
  • Provide progress feedback for longer operations
  • Hide smoothly when the app is interactive
  • Handle errors gracefully

Basic Integration

Setting Up the Abstraction

First, create a splash screen abstraction to decouple your code from specific libraries:

// core/shared/splash-screen/SplashScreen.ts
import RNBootSplash from 'react-native-bootsplash';
 
/**
 * Abstraction for splash screen functionality
 * This allows for easy switching between different splash screen implementations.
 */
export class SplashScreen {
  /**
   * Hide the splash screen with optional fade transition
   * @param options Configuration options for hiding
   */
  static hide(options: { fade?: boolean; duration?: number } = {}) {
    const { fade = true, duration = 500 } = options;
    return RNBootSplash.hide({ fade, duration });
  }
 
  /**
   * Show the splash screen (usually only needed in development)
   */
  static show() {
    return RNBootSplash.show();
  }
 
  /**
   * Check if splash screen is visible
   */
  static isVisible(): Promise<boolean> {
    return RNBootSplash.isVisible();
  }
}

Integration Points

// app/_layout.tsx (Expo Router)
import { useEffect, useState } from 'react';
import { initializer, InitStage } from '@/core/shared/app/initialization';
import { SplashScreen } from '@/core/shared/splash-screen/SplashScreen';
 
export default function RootLayout() {
  const [preUIComplete, setPreUIComplete] = useState(false);
  
  useEffect(() => {
    async function initializePreUI() {
      try {
        // Execute Pre-UI stage (critical, blocking)
        await initializer.executeStage(InitStage.PRE_UI);
        
        // Mark Pre-UI complete
        setPreUIComplete(true);
        
        // Hide splash screen with smooth fade
        await SplashScreen.hide({ fade: true, duration: 500 });
        
        // Continue with non-blocking stages
        initializer.executeStage(InitStage.INITIAL_UI).then(() => {
          initializer.executeParallelStage(InitStage.BACKGROUND).then(() => {
            initializer.executeStage(InitStage.FINALIZATION);
          });
        });
      } catch (error) {
        console.error('Pre-UI initialization failed:', error);
        // Still hide splash screen even on error
        SplashScreen.hide({ fade: true });
      }
    }
    
    initializePreUI();
  }, []);
  
  // Keep splash screen visible until Pre-UI is complete
  if (!preUIComplete) {
    return null; // Return nothing, native splash screen still visible
  }
  
  // Main app layout
  return <Stack />;
}

Progress Reporting

For apps with potentially lengthy initialization processes, showing progress enhances user experience:

Enhanced SplashScreen Class

// core/shared/splash-screen/SplashScreen.ts
import RNBootSplash from 'react-native-bootsplash';
import { Platform, DeviceEventEmitter, NativeEventEmitter } from 'react-native';
 
export class SplashScreen {
  // Track if we're using a custom loading screen that can show progress
  private static usingCustomLoader = false;
  
  // Store the latest progress value (0-1)
  private static currentProgress = 0;
  
  // Event emitter for progress updates
  private static eventEmitter = Platform.OS === 'ios' 
    ? new NativeEventEmitter() 
    : DeviceEventEmitter;
  
  /**
   * Hide the splash screen with optional fade effect
   */
  static async hide(options?: { fade?: boolean; duration?: number }) {
    if (this.usingCustomLoader) {
      // If using custom loader, dispatch an event to hide it
      this.eventEmitter.emit('splash:hide');
      return;
    }
    
    // Default to native splash screen hiding
    return RNBootSplash.hide({
      fade: options?.fade ?? true,
      duration: options?.duration ?? 220
    });
  }
 
  /**
   * Update the loading progress (0-1)
   * This is only effective when using a custom React-based loading screen
   */
  static setProgress(progress: number) {
    // Ensure progress is between 0-1
    const normalizedProgress = Math.min(Math.max(progress, 0), 1);
    this.currentProgress = normalizedProgress;
    
    // Dispatch progress event
    this.eventEmitter.emit('splash:progress', normalizedProgress);
  }
 
  /**
   * Enable custom loader mode - transitions from native splash to React-based loader
   */
  static useCustomLoader() {
    this.usingCustomLoader = true;
    // Hide native splash with very quick fade to show custom one
    RNBootSplash.hide({ fade: true, duration: 100 });
  }
 
  /**
   * Get current progress value (0-1)
   */
  static getProgress() {
    return this.currentProgress;
  }
}

Custom Loading Screen Component

Create a React-based splash screen for progress reporting:

// core/shared/splash-screen/CustomSplashScreen.tsx
import React, { useEffect, useState } from 'react';
import { 
  View, 
  Text, 
  StyleSheet, 
  Image, 
  Animated,
  Dimensions,
  StatusBar
} from 'react-native';
import { SplashScreen } from './SplashScreen';
 
interface Props {
  onInitialized: () => void;
}
 
export function CustomSplashScreen({ onInitialized }: Props) {
  const [progress, setProgress] = useState(0);
  const [visible, setVisible] = useState(true);
  const progressAnim = React.useRef(new Animated.Value(0)).current;
  const fadeAnim = React.useRef(new Animated.Value(1)).current;
  
  useEffect(() => {
    // Listen for progress updates
    const progressListener = SplashScreen.eventEmitter.addListener(
      'splash:progress',
      (newProgress: number) => {
        setProgress(newProgress);
        
        // Animate progress bar
        Animated.timing(progressAnim, {
          toValue: newProgress,
          duration: 200,
          useNativeDriver: false
        }).start();
      }
    );
    
    // Listen for hide event
    const hideListener = SplashScreen.eventEmitter.addListener(
      'splash:hide',
      () => {
        // Fade out animation
        Animated.timing(fadeAnim, {
          toValue: 0,
          duration: 300,
          useNativeDriver: true,
        }).start(() => {
          setVisible(false);
          onInitialized();
        });
      }
    );
    
    return () => {
      progressListener.remove();
      hideListener.remove();
    };
  }, [onInitialized, progressAnim, fadeAnim]);
  
  if (!visible) return null;
  
  const { width } = Dimensions.get('window');
  const progressWidth = progressAnim.interpolate({
    inputRange: [0, 1],
    outputRange: [0, width * 0.8]
  });
  
  return (
    <Animated.View style={[styles.container, { opacity: fadeAnim }]}>
      <StatusBar hidden />
      <View style={styles.content}>
        <Image 
          source={require('@/assets/splash-logo.png')} 
          style={styles.logo} 
          resizeMode="contain" 
        />
        
        <Text style={styles.title}>Your App Name</Text>
        
        <View style={styles.progressContainer}>
          <Animated.View 
            style={[styles.progressBar, { width: progressWidth }]} 
          />
        </View>
        
        <Text style={styles.progressText}>
          Loading... {Math.round(progress * 100)}%
        </Text>
      </View>
    </Animated.View>
  );
}
 
const styles = StyleSheet.create({
  container: {
    ...StyleSheet.absoluteFillObject,
    backgroundColor: '#FFFFFF',
    zIndex: 999,
  },
  content: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 24,
  },
  logo: {
    width: 150,
    height: 150,
    marginBottom: 24,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 48,
    color: '#333333',
  },
  progressContainer: {
    width: '80%',
    height: 4,
    backgroundColor: '#E0E0E0',
    borderRadius: 2,
    overflow: 'hidden',
    marginBottom: 8,
  },
  progressBar: {
    height: '100%',
    backgroundColor: '#2196F3',
    borderRadius: 2,
  },
  progressText: {
    fontSize: 14,
    color: '#757575',
    marginTop: 8,
  },
});

Integration with Progress Reporting

Enable Custom Loader

// In your app entry point
import { SplashScreen } from '@/core/shared/splash-screen/SplashScreen';
 
// Enable custom loader before any initialization
SplashScreen.useCustomLoader();

Report Progress During Initialization

// In your initialization tasks
import { SplashScreen } from '@/core/shared/splash-screen/SplashScreen';
 
export async function initializeApp() {
  // Pre-UI Stage
  SplashScreen.setProgress(0.1);
  await initializeStorage();
  
  SplashScreen.setProgress(0.2);
  await loadConfiguration();
  
  SplashScreen.setProgress(0.4);
  await authenticateUser();
  
  // Initial UI Stage
  SplashScreen.setProgress(0.6);
  await loadTheme();
  
  SplashScreen.setProgress(0.8);
  await preloadAssets();
  
  // Complete
  SplashScreen.setProgress(1.0);
  await delay(200); // Brief pause at 100%
  
  // Hide splash screen
  SplashScreen.hide();
}

Integrate with App Component

export default function App() {
  const [initialized, setInitialized] = useState(false);
  
  useEffect(() => {
    // Enable custom splash screen
    SplashScreen.useCustomLoader();
    
    // Run initialization with progress
    initializeApp().catch(error => {
      console.error('Initialization failed:', error);
      SplashScreen.hide();
    });
  }, []);
  
  return (
    <>
      {!initialized && (
        <CustomSplashScreen 
          onInitialized={() => setInitialized(true)}
        />
      )}
      {initialized && (
        <NavigationContainer>
          <AppNavigator />
        </NavigationContainer>
      )}
    </>
  );
}

Advanced Progress Tracking

Task-Based Progress

For more granular progress tracking based on individual tasks:

// core/shared/app/initialization/progress-tracker.ts
export class InitializationProgressTracker {
  private totalTasks = 0;
  private completedTasks = 0;
  private taskWeights: Map<string, number> = new Map();
  
  /**
   * Register a task with optional weight
   */
  registerTask(taskId: string, weight: number = 1) {
    this.taskWeights.set(taskId, weight);
    this.totalTasks += weight;
  }
  
  /**
   * Mark a task as complete
   */
  completeTask(taskId: string) {
    const weight = this.taskWeights.get(taskId) || 1;
    this.completedTasks += weight;
    
    const progress = this.completedTasks / this.totalTasks;
    SplashScreen.setProgress(progress);
  }
  
  /**
   * Reset progress tracking
   */
  reset() {
    this.totalTasks = 0;
    this.completedTasks = 0;
    this.taskWeights.clear();
  }
}
 
// Usage in initialization
const tracker = new InitializationProgressTracker();
 
// Register all tasks upfront
tracker.registerTask('storage', 1);
tracker.registerTask('auth', 2); // Auth is weighted more
tracker.registerTask('config', 1);
tracker.registerTask('theme', 1);
 
// Complete tasks as they finish
await initializeStorage();
tracker.completeTask('storage');
 
await authenticateUser();
tracker.completeTask('auth');
 
// ... continue with other tasks

Stage-Based Progress

Map initialization stages to progress ranges:

// core/shared/app/initialization/stage-progress.ts
export class StageProgressManager {
  private stageRanges = {
    [InitStage.PRE_UI]: { start: 0, end: 0.4 },
    [InitStage.INITIAL_UI]: { start: 0.4, end: 0.7 },
    [InitStage.BACKGROUND]: { start: 0.7, end: 0.9 },
    [InitStage.FINALIZATION]: { start: 0.9, end: 1.0 },
  };
  
  /**
   * Update progress for a stage
   */
  updateStageProgress(stage: InitStage, stageProgress: number) {
    const range = this.stageRanges[stage];
    const overallProgress = range.start + (range.end - range.start) * stageProgress;
    SplashScreen.setProgress(overallProgress);
  }
}

Error Handling

Graceful Error Recovery

async function initializeWithErrorHandling() {
  try {
    await initializeApp();
  } catch (error) {
    console.error('Initialization error:', error);
    
    // Show error state in custom splash
    SplashScreen.eventEmitter.emit('splash:error', {
      message: 'Failed to initialize app',
      retry: () => initializeWithErrorHandling()
    });
    
    // Or hide splash and show error screen
    setTimeout(() => {
      SplashScreen.hide();
      // Navigate to error screen
    }, 2000);
  }
}

Error State in Custom Splash

// Add to CustomSplashScreen component
const [error, setError] = useState<{ message: string; retry?: () => void } | null>(null);
 
useEffect(() => {
  const errorListener = SplashScreen.eventEmitter.addListener(
    'splash:error',
    (errorInfo) => setError(errorInfo)
  );
  
  return () => errorListener.remove();
}, []);
 
// In render
{error && (
  <View style={styles.errorContainer}>
    <Text style={styles.errorText}>{error.message}</Text>
    {error.retry && (
      <TouchableOpacity onPress={error.retry} style={styles.retryButton}>
        <Text style={styles.retryText}>Retry</Text>
      </TouchableOpacity>
    )}
  </View>
)}

Performance Considerations

Performance Tips

  • Don't update progress too frequently (max 10 updates/second)
  • Batch multiple quick tasks before updating progress
  • Use native animations for smooth visual updates
  • Keep custom splash screen lightweight
  • Preload splash screen assets

Optimized Progress Updates

// Debounced progress updates
class DebouncedProgress {
  private pendingProgress: number | null = null;
  private updateTimer: NodeJS.Timeout | null = null;
  
  update(progress: number) {
    this.pendingProgress = progress;
    
    if (!this.updateTimer) {
      this.updateTimer = setTimeout(() => {
        if (this.pendingProgress !== null) {
          SplashScreen.setProgress(this.pendingProgress);
        }
        this.updateTimer = null;
      }, 100); // Update max every 100ms
    }
  }
}

Testing

Unit Tests

// __tests__/splash-screen-integration.test.ts
import { SplashScreen } from '@/core/shared/splash-screen/SplashScreen';
 
describe('SplashScreen Integration', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });
  
  it('hides splash after initialization', async () => {
    const hideSpy = jest.spyOn(SplashScreen, 'hide');
    
    await initializeApp();
    
    expect(hideSpy).toHaveBeenCalledWith({ fade: true, duration: 500 });
  });
  
  it('reports progress correctly', async () => {
    const progressSpy = jest.spyOn(SplashScreen, 'setProgress');
    
    await initializeApp();
    
    expect(progressSpy).toHaveBeenCalledWith(expect.any(Number));
    expect(progressSpy).toHaveBeenLastCalledWith(1.0);
  });
  
  it('handles initialization errors', async () => {
    const error = new Error('Init failed');
    jest.spyOn(storage, 'initialize').mockRejectedValue(error);
    
    await expect(initializeApp()).rejects.toThrow(error);
    expect(SplashScreen.hide).toHaveBeenCalled();
  });
});

Summary

Proper integration ensures your splash screen enhances rather than delays the user experience:

  • Hide at the right time: After critical initialization completes
  • Show progress: For operations taking more than 2 seconds
  • Handle errors: Always hide the splash screen, even on failure
  • Test thoroughly: Verify behavior in all scenarios
  • Optimize performance: Keep initialization fast and progress smooth

Next Steps