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.