Skip to main content

Module 8: Native Features and Device APIs

Camera and Media

Capturing photos, recording video, and accessing the media library

🎯 Learning Objectives

  • Display a camera preview and capture photos
  • Record video with the device camera
  • Switch between front and back cameras
  • Control flash, zoom, and focus
  • Pick images and videos from the media library
  • Save captured media to the device gallery
  • Build a complete camera interface with controls

Expo Camera Packages

Expo provides several packages for working with camera and media. Understanding when to use each one is important for building the right solution.

πŸ“¦ Package Overview

Package Use For Expo Go?
expo-camera Custom camera UI, live preview, video recording βœ… Yes
expo-image-picker Quick photo capture, library selection βœ… Yes
expo-media-library Save to gallery, browse all photos/videos βœ… Yes
expo-image-manipulator Resize, crop, rotate, compress images βœ… Yes
flowchart TD
    A[Need camera features?] -->|Yes| B{Custom camera UI?}
    A -->|No, just pick images| C[expo-image-picker]
    
    B -->|Yes, full control| D[expo-camera]
    B -->|No, quick capture| C
    
    D --> E{Save to gallery?}
    C --> E
    
    E -->|Yes| F[expo-media-library]
    E -->|No| G[Use image directly]
    
    style D fill:#667eea,color:#fff
    style C fill:#4CAF50,color:#fff
    style F fill:#FF9800,color:#fff
                

Installation

# Install all camera-related packages
npx expo install expo-camera expo-image-picker expo-media-library expo-image-manipulator

πŸ’‘ Quick Rule of Thumb

  • Profile photo upload? β†’ Use expo-image-picker
  • Instagram-style camera? β†’ Use expo-camera
  • Save captured photos? β†’ Add expo-media-library

Camera Preview

The expo-camera package provides a CameraView component that displays a live camera preview. Let's start with the basics.

Basic Camera Preview

import { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';

export default function BasicCamera() {
  const [permission, requestPermission] = useCameraPermissions();

  // Handle loading
  if (!permission) {
    return <View />;
  }

  // Handle no permission
  if (!permission.granted) {
    return (
      <View style={styles.container}>
        <Text style={styles.message}>
          We need camera permission to show the preview
        </Text>
        <Pressable style={styles.button} onPress={requestPermission}>
          <Text style={styles.buttonText}>Grant Permission</Text>
        </Pressable>
      </View>
    );
  }

  // Show camera
  return (
    <View style={styles.container}>
      <CameraView style={styles.camera} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
  },
  camera: {
    flex: 1,
  },
  message: {
    textAlign: 'center',
    paddingBottom: 10,
  },
  button: {
    backgroundColor: '#667eea',
    padding: 16,
    borderRadius: 8,
    marginHorizontal: 20,
    alignItems: 'center',
  },
  buttonText: {
    color: 'white',
    fontWeight: '600',
  },
});

Camera Facing (Front/Back)

import { useState } from 'react';
import { View, Pressable, Text, StyleSheet } from 'react-native';
import { CameraView, CameraType, useCameraPermissions } from 'expo-camera';

export default function CameraWithFlip() {
  const [facing, setFacing] = useState<CameraType>('back');
  const [permission, requestPermission] = useCameraPermissions();

  if (!permission?.granted) {
    return (
      <View style={styles.container}>
        <Pressable onPress={requestPermission}>
          <Text>Grant Permission</Text>
        </Pressable>
      </View>
    );
  }

  const toggleCameraFacing = () => {
    setFacing(current => (current === 'back' ? 'front' : 'back'));
  };

  return (
    <View style={styles.container}>
      <CameraView style={styles.camera} facing={facing}>
        <View style={styles.buttonContainer}>
          <Pressable style={styles.flipButton} onPress={toggleCameraFacing}>
            <Text style={styles.flipText}>πŸ”„ Flip</Text>
          </Pressable>
        </View>
      </CameraView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  camera: {
    flex: 1,
  },
  buttonContainer: {
    flex: 1,
    flexDirection: 'row',
    backgroundColor: 'transparent',
    margin: 64,
  },
  flipButton: {
    flex: 1,
    alignSelf: 'flex-end',
    alignItems: 'center',
    backgroundColor: 'rgba(0,0,0,0.4)',
    padding: 15,
    borderRadius: 10,
  },
  flipText: {
    fontSize: 18,
    fontWeight: 'bold',
    color: 'white',
  },
});

Taking Photos

To capture photos, you need a reference to the camera and call the takePictureAsync method.

Capture a Photo

import { useState, useRef } from 'react';
import { View, Image, Pressable, Text, StyleSheet } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';

export default function PhotoCapture() {
  const [photo, setPhoto] = useState<string | null>(null);
  const cameraRef = useRef<CameraView>(null);
  const [permission, requestPermission] = useCameraPermissions();

  if (!permission?.granted) {
    return (
      <View style={styles.container}>
        <Pressable style={styles.button} onPress={requestPermission}>
          <Text style={styles.buttonText}>Grant Permission</Text>
        </Pressable>
      </View>
    );
  }

  const takePicture = async () => {
    if (cameraRef.current) {
      const result = await cameraRef.current.takePictureAsync({
        quality: 0.8,        // 0-1, compression quality
        base64: false,       // Include base64 data
        exif: true,          // Include EXIF metadata
        skipProcessing: false, // Skip Android processing for speed
      });
      
      if (result) {
        setPhoto(result.uri);
        console.log('Photo taken:', result);
        // result contains: { uri, width, height, exif?, base64? }
      }
    }
  };

  // Show captured photo
  if (photo) {
    return (
      <View style={styles.container}>
        <Image source={{ uri: photo }} style={styles.preview} />
        <View style={styles.previewButtons}>
          <Pressable 
            style={[styles.button, styles.retakeButton]} 
            onPress={() => setPhoto(null)}
          >
            <Text style={styles.buttonText}>Retake</Text>
          </Pressable>
          <Pressable 
            style={[styles.button, styles.useButton]}
            onPress={() => console.log('Use photo:', photo)}
          >
            <Text style={styles.buttonText}>Use Photo</Text>
          </Pressable>
        </View>
      </View>
    );
  }

  // Show camera
  return (
    <View style={styles.container}>
      <CameraView ref={cameraRef} style={styles.camera}>
        <View style={styles.cameraOverlay}>
          <Pressable style={styles.captureButton} onPress={takePicture}>
            <View style={styles.captureButtonInner} />
          </Pressable>
        </View>
      </CameraView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: 'black',
  },
  camera: {
    flex: 1,
  },
  cameraOverlay: {
    flex: 1,
    backgroundColor: 'transparent',
    justifyContent: 'flex-end',
    alignItems: 'center',
    paddingBottom: 40,
  },
  captureButton: {
    width: 80,
    height: 80,
    borderRadius: 40,
    backgroundColor: 'rgba(255,255,255,0.3)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  captureButtonInner: {
    width: 65,
    height: 65,
    borderRadius: 35,
    backgroundColor: 'white',
  },
  preview: {
    flex: 1,
  },
  previewButtons: {
    flexDirection: 'row',
    justifyContent: 'space-around',
    padding: 20,
    backgroundColor: 'black',
  },
  button: {
    paddingHorizontal: 30,
    paddingVertical: 15,
    borderRadius: 8,
  },
  retakeButton: {
    backgroundColor: '#666',
  },
  useButton: {
    backgroundColor: '#667eea',
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
});

Photo Options Reference

πŸ“Έ takePictureAsync Options

const options = {
  // Image quality (0-1)
  quality: 0.8,
  
  // Include base64 encoded image
  base64: false,
  
  // Include EXIF metadata
  exif: true,
  
  // Skip processing on Android (faster but no rotation fix)
  skipProcessing: false,
  
  // Mirror the image (useful for selfies)
  mirror: false,
  
  // Image format (iOS only)
  imageType: 'jpg', // 'jpg' | 'png'
};

const result = await cameraRef.current.takePictureAsync(options);
// Returns: { uri, width, height, exif?, base64? }

Recording Video

Video recording requires microphone permission in addition to camera permission. The API is similar to taking photos but with start/stop control.

Video Recording Component

import { useState, useRef } from 'react';
import { View, Pressable, Text, StyleSheet } from 'react-native';
import { CameraView, useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
import { Video, ResizeMode } from 'expo-av';

export default function VideoRecorder() {
  const [isRecording, setIsRecording] = useState(false);
  const [videoUri, setVideoUri] = useState<string | null>(null);
  const cameraRef = useRef<CameraView>(null);
  
  const [cameraPermission, requestCameraPermission] = useCameraPermissions();
  const [micPermission, requestMicPermission] = useMicrophonePermissions();

  // Check both permissions
  const hasPermissions = cameraPermission?.granted && micPermission?.granted;

  if (!hasPermissions) {
    return (
      <View style={styles.container}>
        <Text style={styles.message}>Camera and microphone access needed</Text>
        <Pressable 
          style={styles.button} 
          onPress={async () => {
            await requestCameraPermission();
            await requestMicPermission();
          }}
        >
          <Text style={styles.buttonText}>Grant Permissions</Text>
        </Pressable>
      </View>
    );
  }

  const startRecording = async () => {
    if (cameraRef.current) {
      setIsRecording(true);
      
      const video = await cameraRef.current.recordAsync({
        maxDuration: 60,      // Max recording time in seconds
        maxFileSize: 50 * 1024 * 1024, // 50MB max
        mute: false,          // Record audio
      });
      
      if (video) {
        setVideoUri(video.uri);
        console.log('Video recorded:', video);
      }
      
      setIsRecording(false);
    }
  };

  const stopRecording = () => {
    if (cameraRef.current && isRecording) {
      cameraRef.current.stopRecording();
    }
  };

  // Show recorded video
  if (videoUri) {
    return (
      <View style={styles.container}>
        <Video
          source={{ uri: videoUri }}
          style={styles.video}
          useNativeControls
          resizeMode={ResizeMode.CONTAIN}
          isLooping
        />
        <View style={styles.previewButtons}>
          <Pressable 
            style={[styles.button, styles.retakeButton]}
            onPress={() => setVideoUri(null)}
          >
            <Text style={styles.buttonText}>Record Again</Text>
          </Pressable>
        </View>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <CameraView 
        ref={cameraRef} 
        style={styles.camera}
        mode="video"
      >
        <View style={styles.cameraOverlay}>
          {isRecording && (
            <View style={styles.recordingIndicator}>
              <View style={styles.recordingDot} />
              <Text style={styles.recordingText}>Recording...</Text>
            </View>
          )}
          
          <Pressable 
            style={[
              styles.recordButton,
              isRecording && styles.recordButtonActive
            ]}
            onPress={isRecording ? stopRecording : startRecording}
          >
            <View style={[
              styles.recordButtonInner,
              isRecording && styles.recordButtonInnerActive
            ]} />
          </Pressable>
        </View>
      </CameraView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: 'black',
  },
  camera: {
    flex: 1,
  },
  video: {
    flex: 1,
  },
  cameraOverlay: {
    flex: 1,
    backgroundColor: 'transparent',
    justifyContent: 'flex-end',
    alignItems: 'center',
    paddingBottom: 40,
  },
  recordingIndicator: {
    flexDirection: 'row',
    alignItems: 'center',
    position: 'absolute',
    top: 50,
    backgroundColor: 'rgba(0,0,0,0.5)',
    paddingHorizontal: 16,
    paddingVertical: 8,
    borderRadius: 20,
  },
  recordingDot: {
    width: 12,
    height: 12,
    borderRadius: 6,
    backgroundColor: '#f44336',
    marginRight: 8,
  },
  recordingText: {
    color: 'white',
    fontSize: 14,
    fontWeight: '600',
  },
  recordButton: {
    width: 80,
    height: 80,
    borderRadius: 40,
    backgroundColor: 'rgba(255,255,255,0.3)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  recordButtonActive: {
    backgroundColor: 'rgba(244,67,54,0.3)',
  },
  recordButtonInner: {
    width: 65,
    height: 65,
    borderRadius: 35,
    backgroundColor: '#f44336',
  },
  recordButtonInnerActive: {
    width: 30,
    height: 30,
    borderRadius: 4,
  },
  message: {
    color: 'white',
    textAlign: 'center',
    marginBottom: 20,
  },
  button: {
    backgroundColor: '#667eea',
    paddingHorizontal: 30,
    paddingVertical: 15,
    borderRadius: 8,
    marginHorizontal: 20,
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
    textAlign: 'center',
  },
  previewButtons: {
    padding: 20,
    backgroundColor: 'black',
  },
  retakeButton: {
    backgroundColor: '#666',
  },
});

Camera Controls

Beyond basic capture, you can control flash, zoom, and other camera settings.

Flash Control

import { useState } from 'react';
import { CameraView, FlashMode } from 'expo-camera';

function CameraWithFlash() {
  const [flash, setFlash] = useState<FlashMode>('off');

  const toggleFlash = () => {
    setFlash(current => {
      switch (current) {
        case 'off': return 'on';
        case 'on': return 'auto';
        case 'auto': return 'off';
        default: return 'off';
      }
    });
  };

  const getFlashIcon = () => {
    switch (flash) {
      case 'on': return '⚑';
      case 'auto': return '⚑A';
      case 'off': return 'βš‘βœ•';
    }
  };

  return (
    <CameraView 
      style={{ flex: 1 }} 
      flash={flash}
    >
      <Pressable onPress={toggleFlash}>
        <Text>{getFlashIcon()}</Text>
      </Pressable>
    </CameraView>
  );
}

Zoom Control

import { useState } from 'react';
import { View, StyleSheet } from 'react-native';
import { CameraView } from 'expo-camera';
import Slider from '@react-native-community/slider';

function CameraWithZoom() {
  const [zoom, setZoom] = useState(0); // 0 to 1

  return (
    <View style={styles.container}>
      <CameraView 
        style={styles.camera} 
        zoom={zoom}
      />
      
      <View style={styles.controls}>
        <Text style={styles.zoomText}>Zoom: {(zoom * 100).toFixed(0)}%</Text>
        <Slider
          style={styles.slider}
          minimumValue={0}
          maximumValue={1}
          value={zoom}
          onValueChange={setZoom}
          minimumTrackTintColor="#667eea"
          maximumTrackTintColor="#ddd"
          thumbTintColor="#667eea"
        />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  camera: { flex: 1 },
  controls: {
    position: 'absolute',
    bottom: 100,
    left: 20,
    right: 20,
    backgroundColor: 'rgba(0,0,0,0.5)',
    padding: 15,
    borderRadius: 10,
  },
  zoomText: {
    color: 'white',
    textAlign: 'center',
    marginBottom: 10,
  },
  slider: {
    width: '100%',
  },
});

Pinch to Zoom with Gesture Handler

import { useState } from 'react';
import { StyleSheet } from 'react-native';
import { CameraView } from 'expo-camera';
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';

function CameraWithPinchZoom() {
  const [zoom, setZoom] = useState(0);
  const [lastZoom, setLastZoom] = useState(0);

  const pinchGesture = Gesture.Pinch()
    .onUpdate((event) => {
      // Scale factor to zoom level (0-1)
      const newZoom = Math.min(
        Math.max(lastZoom + (event.scale - 1) * 0.5, 0),
        1
      );
      setZoom(newZoom);
    })
    .onEnd(() => {
      setLastZoom(zoom);
    });

  return (
    <GestureDetector gesture={pinchGesture}>
      <Animated.View style={{ flex: 1 }}>
        <CameraView style={styles.camera} zoom={zoom} />
      </Animated.View>
    </GestureDetector>
  );
}

CameraView Props Reference

πŸŽ›οΈ CameraView Props

<CameraView
  // Camera selection
  facing="back"           // 'front' | 'back'
  
  // Flash mode
  flash="off"             // 'off' | 'on' | 'auto'
  
  // Zoom level (0-1)
  zoom={0}
  
  // Mode for photo or video
  mode="picture"          // 'picture' | 'video'
  
  // Enable/disable sounds
  mute={false}
  
  // Mirror the preview (selfie camera)
  mirror={false}
  
  // Enable barcode scanning
  barcodeScannerSettings={{
    barcodeTypes: ['qr', 'ean13'],
  }}
  
  // Callbacks
  onCameraReady={() => {}}
  onMountError={(error) => {}}
  onBarcodeScanned={(data) => {}}
/>

Image Picker

For most apps, expo-image-picker is the simpler choice. It provides a system UI for capturing photos or selecting from the libraryβ€”no need to build a custom camera interface.

Pick from Library

import { useState } from 'react';
import { View, Image, Pressable, Text, StyleSheet } from 'react-native';
import * as ImagePicker from 'expo-image-picker';

export default function ImagePickerDemo() {
  const [image, setImage] = useState<string | null>(null);

  const pickImage = async () => {
    // No permissions request needed for launching the image library
    const result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ['images'],
      allowsEditing: true,
      aspect: [4, 3],
      quality: 1,
    });

    if (!result.canceled) {
      setImage(result.assets[0].uri);
    }
  };

  return (
    <View style={styles.container}>
      {image && <Image source={{ uri: image }} style={styles.image} />}
      <Pressable style={styles.button} onPress={pickImage}>
        <Text style={styles.buttonText}>Pick an image</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  image: {
    width: 300,
    height: 300,
    borderRadius: 10,
    marginBottom: 20,
  },
  button: {
    backgroundColor: '#667eea',
    paddingHorizontal: 30,
    paddingVertical: 15,
    borderRadius: 8,
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
});

Take Photo with System Camera

import * as ImagePicker from 'expo-image-picker';

async function takePhoto() {
  // Request camera permission
  const { status } = await ImagePicker.requestCameraPermissionsAsync();
  
  if (status !== 'granted') {
    alert('Camera permission is required');
    return null;
  }

  // Launch system camera
  const result = await ImagePicker.launchCameraAsync({
    allowsEditing: true,
    aspect: [1, 1],    // Square crop
    quality: 0.8,
  });

  if (!result.canceled) {
    return result.assets[0].uri;
  }
  
  return null;
}

Multiple Selection

import * as ImagePicker from 'expo-image-picker';

async function pickMultipleImages() {
  const result = await ImagePicker.launchImageLibraryAsync({
    mediaTypes: ['images'],
    allowsMultipleSelection: true,
    selectionLimit: 10,  // Max number of images
    quality: 0.8,
  });

  if (!result.canceled) {
    // result.assets is an array of selected images
    const imageUris = result.assets.map(asset => asset.uri);
    console.log('Selected images:', imageUris);
    return imageUris;
  }
  
  return [];
}

ImagePicker Options Reference

πŸ“· launchImageLibraryAsync / launchCameraAsync Options

const options = {
  // Media types to show
  mediaTypes: ['images'],           // ['images'] | ['videos'] | ['images', 'videos']
  
  // Allow editing before returning
  allowsEditing: true,
  
  // Aspect ratio for crop (when editing)
  aspect: [4, 3],                   // [width, height]
  
  // Image quality (0-1)
  quality: 1,
  
  // Include base64 data
  base64: false,
  
  // Include EXIF data
  exif: false,
  
  // Allow multiple selection (library only)
  allowsMultipleSelection: false,
  
  // Max selection count
  selectionLimit: 0,                // 0 = unlimited
  
  // Video options
  videoMaxDuration: 60,             // seconds
  videoQuality: 1,                  // 0-1
  
  // Presentation style (iOS)
  presentationStyle: 'pageSheet',   // 'fullScreen' | 'pageSheet' | etc.
};

const result = await ImagePicker.launchImageLibraryAsync(options);

// Result structure
{
  canceled: false,
  assets: [{
    uri: 'file://...',
    width: 1920,
    height: 1080,
    type: 'image',
    fileName: 'IMG_001.jpg',
    fileSize: 1234567,
    base64?: '...',
    exif?: {...},
    duration?: 10.5,  // for videos
  }]
}

Media Library

The expo-media-library package lets you save photos/videos to the device gallery and browse existing media.

Save Photo to Gallery

import * as MediaLibrary from 'expo-media-library';

async function saveToGallery(photoUri: string) {
  // Request permission
  const { status } = await MediaLibrary.requestPermissionsAsync();
  
  if (status !== 'granted') {
    alert('Permission to access media library is required');
    return null;
  }

  // Save the photo
  const asset = await MediaLibrary.createAssetAsync(photoUri);
  console.log('Saved to gallery:', asset);
  
  return asset;
}

// Save to a specific album
async function saveToAlbum(photoUri: string, albumName: string) {
  const { status } = await MediaLibrary.requestPermissionsAsync();
  
  if (status !== 'granted') {
    return null;
  }

  // Create the asset
  const asset = await MediaLibrary.createAssetAsync(photoUri);
  
  // Get or create the album
  let album = await MediaLibrary.getAlbumAsync(albumName);
  
  if (!album) {
    // Create new album with this asset
    album = await MediaLibrary.createAlbumAsync(albumName, asset, false);
  } else {
    // Add to existing album
    await MediaLibrary.addAssetsToAlbumAsync([asset], album, false);
  }
  
  return asset;
}

Browse Photos from Library

import { useState, useEffect } from 'react';
import { View, FlatList, Image, StyleSheet, Pressable, Text } from 'react-native';
import * as MediaLibrary from 'expo-media-library';

export default function PhotoGallery() {
  const [photos, setPhotos] = useState<MediaLibrary.Asset[]>([]);
  const [permission, requestPermission] = MediaLibrary.usePermissions();
  const [hasMore, setHasMore] = useState(true);
  const [endCursor, setEndCursor] = useState<string | undefined>();

  const loadPhotos = async (after?: string) => {
    const { assets, hasNextPage, endCursor: newCursor } = await MediaLibrary.getAssetsAsync({
      first: 50,
      after,
      mediaType: 'photo',
      sortBy: [MediaLibrary.SortBy.creationTime],
    });

    if (after) {
      setPhotos(prev => [...prev, ...assets]);
    } else {
      setPhotos(assets);
    }
    
    setHasMore(hasNextPage);
    setEndCursor(newCursor);
  };

  useEffect(() => {
    if (permission?.granted) {
      loadPhotos();
    }
  }, [permission?.granted]);

  if (!permission?.granted) {
    return (
      <View style={styles.center}>
        <Text>Permission needed to view photos</Text>
        <Pressable style={styles.button} onPress={requestPermission}>
          <Text style={styles.buttonText}>Grant Permission</Text>
        </Pressable>
      </View>
    );
  }

  const loadMore = () => {
    if (hasMore && endCursor) {
      loadPhotos(endCursor);
    }
  };

  return (
    <FlatList
      data={photos}
      numColumns={3}
      keyExtractor={(item) => item.id}
      onEndReached={loadMore}
      onEndReachedThreshold={0.5}
      renderItem={({ item }) => (
        <Pressable 
          style={styles.photoContainer}
          onPress={() => console.log('Selected:', item.uri)}
        >
          <Image source={{ uri: item.uri }} style={styles.photo} />
        </Pressable>
      )}
    />
  );
}

const styles = StyleSheet.create({
  center: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  photoContainer: {
    flex: 1/3,
    aspectRatio: 1,
    padding: 1,
  },
  photo: {
    flex: 1,
  },
  button: {
    backgroundColor: '#667eea',
    paddingHorizontal: 20,
    paddingVertical: 12,
    borderRadius: 8,
    marginTop: 16,
  },
  buttonText: {
    color: 'white',
    fontWeight: '600',
  },
});

Get Albums

import * as MediaLibrary from 'expo-media-library';

async function getAlbums() {
  const albums = await MediaLibrary.getAlbumsAsync({
    includeSmartAlbums: true,  // iOS only: include Camera Roll, Favorites, etc.
  });
  
  console.log('Albums:', albums);
  // Each album: { id, title, assetCount, type }
  
  return albums;
}

// Get photos from a specific album
async function getAlbumPhotos(albumId: string) {
  const { assets } = await MediaLibrary.getAssetsAsync({
    album: albumId,
    first: 100,
    mediaType: 'photo',
  });
  
  return assets;
}

Delete Assets

import * as MediaLibrary from 'expo-media-library';
import { Alert } from 'react-native';

async function deletePhoto(assetId: string) {
  // Confirm with user
  return new Promise((resolve) => {
    Alert.alert(
      'Delete Photo',
      'Are you sure you want to delete this photo?',
      [
        { text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
        {
          text: 'Delete',
          style: 'destructive',
          onPress: async () => {
            const deleted = await MediaLibrary.deleteAssetsAsync([assetId]);
            resolve(deleted);
          },
        },
      ]
    );
  });
}

Complete Camera App

Let's build a complete camera app with all the features we've covered: photo capture, front/back switching, flash, zoom, and saving to gallery.

import { useState, useRef } from 'react';
import { View, Image, Pressable, Text, StyleSheet, Alert } from 'react-native';
import { 
  CameraView, 
  CameraType, 
  FlashMode,
  useCameraPermissions 
} from 'expo-camera';
import * as MediaLibrary from 'expo-media-library';

export default function FullCameraApp() {
  // State
  const [facing, setFacing] = useState<CameraType>('back');
  const [flash, setFlash] = useState<FlashMode>('off');
  const [zoom, setZoom] = useState(0);
  const [photo, setPhoto] = useState<string | null>(null);
  const [isTakingPhoto, setIsTakingPhoto] = useState(false);
  
  // Refs
  const cameraRef = useRef<CameraView>(null);
  
  // Permissions
  const [cameraPermission, requestCameraPermission] = useCameraPermissions();
  const [mediaPermission, requestMediaPermission] = MediaLibrary.usePermissions();

  // Check permissions
  if (!cameraPermission?.granted) {
    return (
      <View style={styles.permissionContainer}>
        <Text style={styles.permissionText}>
          Camera access is required to take photos
        </Text>
        <Pressable style={styles.permissionButton} onPress={requestCameraPermission}>
          <Text style={styles.permissionButtonText}>Enable Camera</Text>
        </Pressable>
      </View>
    );
  }

  // Actions
  const toggleFacing = () => {
    setFacing(prev => prev === 'back' ? 'front' : 'back');
  };

  const cycleFlash = () => {
    setFlash(prev => {
      switch (prev) {
        case 'off': return 'on';
        case 'on': return 'auto';
        case 'auto': return 'off';
      }
    });
  };

  const getFlashIcon = () => {
    switch (flash) {
      case 'off': return 'βš‘βœ•';
      case 'on': return '⚑';
      case 'auto': return '⚑A';
    }
  };

  const takePicture = async () => {
    if (!cameraRef.current || isTakingPhoto) return;
    
    setIsTakingPhoto(true);
    
    try {
      const result = await cameraRef.current.takePictureAsync({
        quality: 0.9,
        exif: true,
      });
      
      if (result) {
        setPhoto(result.uri);
      }
    } catch (error) {
      Alert.alert('Error', 'Failed to take photo');
    } finally {
      setIsTakingPhoto(false);
    }
  };

  const savePhoto = async () => {
    if (!photo) return;
    
    // Request media permission if needed
    if (!mediaPermission?.granted) {
      const { granted } = await requestMediaPermission();
      if (!granted) {
        Alert.alert('Permission Required', 'Media library access is needed to save photos');
        return;
      }
    }
    
    try {
      await MediaLibrary.createAssetAsync(photo);
      Alert.alert('Saved!', 'Photo saved to your gallery');
      setPhoto(null);
    } catch (error) {
      Alert.alert('Error', 'Failed to save photo');
    }
  };

  const discardPhoto = () => {
    setPhoto(null);
  };

  // Photo preview screen
  if (photo) {
    return (
      <View style={styles.container}>
        <Image source={{ uri: photo }} style={styles.preview} />
        
        <View style={styles.previewControls}>
          <Pressable style={styles.previewButton} onPress={discardPhoto}>
            <Text style={styles.previewButtonText}>βœ• Discard</Text>
          </Pressable>
          <Pressable 
            style={[styles.previewButton, styles.saveButton]} 
            onPress={savePhoto}
          >
            <Text style={styles.previewButtonText}>πŸ’Ύ Save</Text>
          </Pressable>
        </View>
      </View>
    );
  }

  // Camera screen
  return (
    <View style={styles.container}>
      <CameraView
        ref={cameraRef}
        style={styles.camera}
        facing={facing}
        flash={flash}
        zoom={zoom}
      >
        {/* Top controls */}
        <View style={styles.topControls}>
          <Pressable style={styles.controlButton} onPress={cycleFlash}>
            <Text style={styles.controlText}>{getFlashIcon()}</Text>
          </Pressable>
        </View>

        {/* Bottom controls */}
        <View style={styles.bottomControls}>
          {/* Gallery button */}
          <Pressable style={styles.sideButton}>
            <Text style={styles.sideButtonText}>πŸ–ΌοΈ</Text>
          </Pressable>

          {/* Capture button */}
          <Pressable 
            style={[styles.captureButton, isTakingPhoto && styles.captureButtonDisabled]}
            onPress={takePicture}
            disabled={isTakingPhoto}
          >
            <View style={styles.captureButtonInner} />
          </Pressable>

          {/* Flip button */}
          <Pressable style={styles.sideButton} onPress={toggleFacing}>
            <Text style={styles.sideButtonText}>πŸ”„</Text>
          </Pressable>
        </View>

        {/* Zoom slider */}
        <View style={styles.zoomContainer}>
          <Text style={styles.zoomText}>{zoom === 0 ? '1x' : `${(1 + zoom * 4).toFixed(1)}x`}</Text>
          <View style={styles.zoomButtons}>
            {[0, 0.25, 0.5].map((z) => (
              <Pressable 
                key={z}
                style={[styles.zoomButton, zoom === z && styles.zoomButtonActive]}
                onPress={() => setZoom(z)}
              >
                <Text style={[styles.zoomButtonText, zoom === z && styles.zoomButtonTextActive]}>
                  {z === 0 ? '1x' : z === 0.25 ? '2x' : '3x'}
                </Text>
              </Pressable>
            ))}
          </View>
        </View>
      </CameraView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: 'black',
  },
  camera: {
    flex: 1,
  },
  preview: {
    flex: 1,
  },
  
  // Permission screen
  permissionContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'black',
    padding: 20,
  },
  permissionText: {
    color: 'white',
    fontSize: 16,
    textAlign: 'center',
    marginBottom: 20,
  },
  permissionButton: {
    backgroundColor: '#667eea',
    paddingHorizontal: 30,
    paddingVertical: 15,
    borderRadius: 8,
  },
  permissionButtonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
  
  // Top controls
  topControls: {
    flexDirection: 'row',
    justifyContent: 'flex-end',
    padding: 20,
    paddingTop: 60,
  },
  controlButton: {
    width: 44,
    height: 44,
    borderRadius: 22,
    backgroundColor: 'rgba(0,0,0,0.4)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  controlText: {
    fontSize: 18,
    color: 'white',
  },
  
  // Bottom controls
  bottomControls: {
    position: 'absolute',
    bottom: 40,
    left: 0,
    right: 0,
    flexDirection: 'row',
    justifyContent: 'space-around',
    alignItems: 'center',
    paddingHorizontal: 30,
  },
  sideButton: {
    width: 50,
    height: 50,
    borderRadius: 25,
    backgroundColor: 'rgba(255,255,255,0.2)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  sideButtonText: {
    fontSize: 24,
  },
  captureButton: {
    width: 80,
    height: 80,
    borderRadius: 40,
    backgroundColor: 'rgba(255,255,255,0.3)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  captureButtonDisabled: {
    opacity: 0.5,
  },
  captureButtonInner: {
    width: 65,
    height: 65,
    borderRadius: 35,
    backgroundColor: 'white',
  },
  
  // Zoom controls
  zoomContainer: {
    position: 'absolute',
    bottom: 140,
    left: 0,
    right: 0,
    alignItems: 'center',
  },
  zoomText: {
    color: 'white',
    fontSize: 12,
    marginBottom: 8,
  },
  zoomButtons: {
    flexDirection: 'row',
    backgroundColor: 'rgba(0,0,0,0.4)',
    borderRadius: 20,
    padding: 4,
  },
  zoomButton: {
    paddingHorizontal: 16,
    paddingVertical: 8,
    borderRadius: 16,
  },
  zoomButtonActive: {
    backgroundColor: 'white',
  },
  zoomButtonText: {
    color: 'white',
    fontSize: 12,
    fontWeight: '600',
  },
  zoomButtonTextActive: {
    color: 'black',
  },
  
  // Preview controls
  previewControls: {
    flexDirection: 'row',
    justifyContent: 'space-around',
    padding: 20,
    backgroundColor: 'black',
  },
  previewButton: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: '#444',
    paddingHorizontal: 30,
    paddingVertical: 15,
    borderRadius: 8,
  },
  saveButton: {
    backgroundColor: '#667eea',
  },
  previewButtonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
});

Hands-On Exercises

Exercise 1: Profile Photo Picker

Build a profile photo component that lets users take a photo or pick from library.

Requirements:

  • Show a circular avatar placeholder
  • Tap to show action sheet: "Take Photo" or "Choose from Library"
  • Allow square cropping (1:1 aspect ratio)
  • Display the selected image in the avatar
Show Hint

Use Alert.alert with multiple buttons to create an action sheet. Use ImagePicker.launchCameraAsync and launchImageLibraryAsync with allowsEditing: true and aspect: [1, 1].

Show Solution
import { useState } from 'react';
import { View, Image, Pressable, Text, Alert, StyleSheet } from 'react-native';
import * as ImagePicker from 'expo-image-picker';

export default function ProfilePhotoPicker() {
  const [photo, setPhoto] = useState<string | null>(null);

  const pickImage = async (useCamera: boolean) => {
    const options: ImagePicker.ImagePickerOptions = {
      mediaTypes: ['images'],
      allowsEditing: true,
      aspect: [1, 1],
      quality: 0.8,
    };

    let result;
    
    if (useCamera) {
      const { status } = await ImagePicker.requestCameraPermissionsAsync();
      if (status !== 'granted') {
        Alert.alert('Permission needed', 'Camera permission is required');
        return;
      }
      result = await ImagePicker.launchCameraAsync(options);
    } else {
      result = await ImagePicker.launchImageLibraryAsync(options);
    }

    if (!result.canceled) {
      setPhoto(result.assets[0].uri);
    }
  };

  const showOptions = () => {
    Alert.alert(
      'Change Profile Photo',
      'Choose an option',
      [
        { text: 'Take Photo', onPress: () => pickImage(true) },
        { text: 'Choose from Library', onPress: () => pickImage(false) },
        { text: 'Cancel', style: 'cancel' },
      ]
    );
  };

  return (
    <View style={styles.container}>
      <Pressable onPress={showOptions}>
        <View style={styles.avatarContainer}>
          {photo ? (
            <Image source={{ uri: photo }} style={styles.avatar} />
          ) : (
            <View style={[styles.avatar, styles.placeholder]}>
              <Text style={styles.placeholderText}>πŸ‘€</Text>
            </View>
          )}
          <View style={styles.editBadge}>
            <Text style={styles.editBadgeText}>πŸ“·</Text>
          </View>
        </View>
      </Pressable>
      <Text style={styles.hint}>Tap to change photo</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { alignItems: 'center', padding: 20 },
  avatarContainer: { position: 'relative' },
  avatar: { width: 120, height: 120, borderRadius: 60 },
  placeholder: { backgroundColor: '#e0e0e0', justifyContent: 'center', alignItems: 'center' },
  placeholderText: { fontSize: 48 },
  editBadge: { position: 'absolute', bottom: 0, right: 0, backgroundColor: '#667eea', width: 36, height: 36, borderRadius: 18, justifyContent: 'center', alignItems: 'center', borderWidth: 3, borderColor: 'white' },
  editBadgeText: { fontSize: 16 },
  hint: { marginTop: 12, color: '#666', fontSize: 14 },
});

Exercise 2: QR Code Scanner

Build a QR code scanner using expo-camera's barcode scanning feature.

Requirements:

  • Display camera preview focused on scanning area
  • Draw a frame overlay showing scan area
  • When QR code detected, show the data in an alert
  • Allow scanning another code after dismissing alert
Show Hint

Use CameraView with barcodeScannerSettings={{ barcodeTypes: ['qr'] }} and the onBarcodeScanned prop. Use a state variable to prevent multiple scans of the same code.

Show Solution
import { useState } from 'react';
import { View, Text, StyleSheet, Alert, Pressable } from 'react-native';
import { CameraView, useCameraPermissions, BarcodeScanningResult } from 'expo-camera';

export default function QRScanner() {
  const [scanned, setScanned] = useState(false);
  const [permission, requestPermission] = useCameraPermissions();

  if (!permission?.granted) {
    return (
      <View style={styles.container}>
        <Text>Camera permission needed</Text>
        <Pressable style={styles.button} onPress={requestPermission}>
          <Text style={styles.buttonText}>Grant Permission</Text>
        </Pressable>
      </View>
    );
  }

  const handleBarCodeScanned = (result: BarcodeScanningResult) => {
    if (scanned) return;
    
    setScanned(true);
    
    Alert.alert(
      'QR Code Scanned!',
      result.data,
      [
        { 
          text: 'OK', 
          onPress: () => setScanned(false) 
        }
      ]
    );
  };

  return (
    <View style={styles.container}>
      <CameraView
        style={styles.camera}
        barcodeScannerSettings={{
          barcodeTypes: ['qr'],
        }}
        onBarcodeScanned={handleBarCodeScanned}
      >
        {/* Scan frame overlay */}
        <View style={styles.overlay}>
          <View style={styles.unfocusedContainer}></View>
          <View style={styles.middleContainer}>
            <View style={styles.unfocusedContainer}></View>
            <View style={styles.focusedContainer}>
              <View style={[styles.corner, styles.topLeft]} />
              <View style={[styles.corner, styles.topRight]} />
              <View style={[styles.corner, styles.bottomLeft]} />
              <View style={[styles.corner, styles.bottomRight]} />
            </View>
            <View style={styles.unfocusedContainer}></View>
          </View>
          <View style={styles.unfocusedContainer}></View>
        </View>
        
        <Text style={styles.instruction}>
          Point camera at a QR code
        </Text>
      </CameraView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  camera: { flex: 1, width: '100%' },
  button: { backgroundColor: '#667eea', padding: 16, borderRadius: 8, marginTop: 16 },
  buttonText: { color: 'white', fontWeight: '600' },
  overlay: { ...StyleSheet.absoluteFillObject },
  unfocusedContainer: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' },
  middleContainer: { flexDirection: 'row', flex: 1.5 },
  focusedContainer: { flex: 6, position: 'relative' },
  corner: { position: 'absolute', width: 30, height: 30, borderColor: '#667eea', borderWidth: 4 },
  topLeft: { top: 0, left: 0, borderRightWidth: 0, borderBottomWidth: 0 },
  topRight: { top: 0, right: 0, borderLeftWidth: 0, borderBottomWidth: 0 },
  bottomLeft: { bottom: 0, left: 0, borderRightWidth: 0, borderTopWidth: 0 },
  bottomRight: { bottom: 0, right: 0, borderLeftWidth: 0, borderTopWidth: 0 },
  instruction: { position: 'absolute', bottom: 100, alignSelf: 'center', color: 'white', fontSize: 16, backgroundColor: 'rgba(0,0,0,0.5)', padding: 12, borderRadius: 8 },
});

Exercise 3: Photo Gallery with Delete

Build a gallery that displays photos from a specific album with delete functionality.

Requirements:

  • Load photos from device library
  • Display in a 3-column grid
  • Long-press to select for deletion
  • Show delete confirmation before removing
Show Hint

Use MediaLibrary.getAssetsAsync() for loading. Track selected photos in state. Use Pressable with onLongPress for selection. Call MediaLibrary.deleteAssetsAsync() after confirmation.

Show Solution
import { useState, useEffect } from 'react';
import { View, FlatList, Image, Pressable, Text, Alert, StyleSheet } from 'react-native';
import * as MediaLibrary from 'expo-media-library';

export default function PhotoGalleryWithDelete() {
  const [photos, setPhotos] = useState<MediaLibrary.Asset[]>([]);
  const [selected, setSelected] = useState<Set<string>>(new Set());
  const [permission, requestPermission] = MediaLibrary.usePermissions();
  const [isSelecting, setIsSelecting] = useState(false);

  const loadPhotos = async () => {
    const { assets } = await MediaLibrary.getAssetsAsync({
      first: 100,
      mediaType: 'photo',
      sortBy: [MediaLibrary.SortBy.creationTime],
    });
    setPhotos(assets);
  };

  useEffect(() => {
    if (permission?.granted) loadPhotos();
  }, [permission?.granted]);

  if (!permission?.granted) {
    return (
      <View style={styles.center}>
        <Pressable style={styles.button} onPress={requestPermission}>
          <Text style={styles.buttonText}>Grant Permission</Text>
        </Pressable>
      </View>
    );
  }

  const toggleSelect = (id: string) => {
    setSelected(prev => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
        if (next.size === 0) setIsSelecting(false);
      } else {
        next.add(id);
      }
      return next;
    });
  };

  const startSelecting = (id: string) => {
    setIsSelecting(true);
    setSelected(new Set([id]));
  };

  const deleteSelected = () => {
    Alert.alert(
      'Delete Photos',
      `Delete ${selected.size} photo(s)?`,
      [
        { text: 'Cancel', style: 'cancel' },
        {
          text: 'Delete',
          style: 'destructive',
          onPress: async () => {
            await MediaLibrary.deleteAssetsAsync([...selected]);
            setSelected(new Set());
            setIsSelecting(false);
            loadPhotos();
          },
        },
      ]
    );
  };

  const cancelSelection = () => {
    setSelected(new Set());
    setIsSelecting(false);
  };

  return (
    <View style={styles.container}>
      {isSelecting && (
        <View style={styles.toolbar}>
          <Pressable onPress={cancelSelection}>
            <Text style={styles.toolbarText}>Cancel</Text>
          </Pressable>
          <Text style={styles.toolbarText}>{selected.size} selected</Text>
          <Pressable onPress={deleteSelected}>
            <Text style={[styles.toolbarText, styles.deleteText]}>Delete</Text>
          </Pressable>
        </View>
      )}
      
      <FlatList
        data={photos}
        numColumns={3}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <Pressable
            style={styles.photoContainer}
            onPress={() => isSelecting && toggleSelect(item.id)}
            onLongPress={() => !isSelecting && startSelecting(item.id)}
          >
            <Image source={{ uri: item.uri }} style={styles.photo} />
            {selected.has(item.id) && (
              <View style={styles.selectedOverlay}>
                <Text style={styles.checkmark}>βœ“</Text>
              </View>
            )}
          </Pressable>
        )}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  button: { backgroundColor: '#667eea', padding: 16, borderRadius: 8 },
  buttonText: { color: 'white', fontWeight: '600' },
  toolbar: { flexDirection: 'row', justifyContent: 'space-between', padding: 16, backgroundColor: '#f5f5f5' },
  toolbarText: { fontSize: 16, fontWeight: '600' },
  deleteText: { color: '#f44336' },
  photoContainer: { flex: 1/3, aspectRatio: 1, padding: 1 },
  photo: { flex: 1 },
  selectedOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(102,126,234,0.5)', justifyContent: 'center', alignItems: 'center' },
  checkmark: { fontSize: 32, color: 'white' },
});

Summary

You now have a complete toolkit for working with camera and media in React Native. Choose the right tool for your needs and remember to handle permissions properly.

🎯 Key Takeaways

  • expo-image-picker for quick photo capture and library selection
  • expo-camera for custom camera UI with full control
  • expo-media-library for saving to gallery and browsing media
  • CameraView uses refs for takePictureAsync and recordAsync
  • Video recording requires microphone permission too
  • Flash, zoom, and facing are controlled via props
  • Barcode scanning is built into CameraView
  • Always handle permissions before accessing camera features

In the next lesson, we'll explore location servicesβ€”getting the user's position, tracking movement, and working with maps.