Practice and reinforce the concepts from Lesson 6
Create AR-powered camera apps by:
Time Limit: 5 minutes
# Create AR camera project
npx create-expo-app ARCamera --template blank
cd ARCamera
# Install camera and AR dependencies
npx expo install expo-camera
npx expo install expo-face-detector
npx expo install expo-gl
npx expo install expo-gl-cpp
npx expo install expo-three
npx expo install expo-media-library
Time Limit: 5 minutes
Update app.json
for camera permissions:
{
"expo": {
"name": "AR Camera",
"slug": "ar-camera",
"version": "1.0.0",
"platforms": ["ios", "android"],
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "This app uses camera for AR effects and photo capture.",
"NSPhotoLibraryUsageDescription": "This app saves photos to your library.",
"NSMicrophoneUsageDescription": "This app records audio with video."
}
},
"android": {
"permissions": [
"CAMERA",
"READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE",
"RECORD_AUDIO"
]
}
}
}
✅ Checkpoint: Permissions configured and dependencies installed!
Replace App.js
with basic camera functionality:
import React, { useState, useEffect, useRef } from 'react';
import {
StyleSheet,
Text,
View,
TouchableOpacity,
Dimensions,
Alert,
} from 'react-native';
import { Camera } from 'expo-camera';
import * as MediaLibrary from 'expo-media-library';
import { MaterialIcons } from '@expo/vector-icons';
const { width, height } = Dimensions.get('window');
export default function App() {
const [hasCameraPermission, setHasCameraPermission] = useState(null);
const [hasMediaLibraryPermission, setHasMediaLibraryPermission] = useState(null);
const [type, setType] = useState(Camera.Constants.Type.front);
const [flash, setFlash] = useState(Camera.Constants.FlashMode.off);
const [isRecording, setIsRecording] = useState(false);
const [photoUri, setPhotoUri] = useState(null);
const cameraRef = useRef(null);
useEffect(() => {
(async () => {
const { status: cameraStatus } = await Camera.requestCameraPermissionsAsync();
const { status: mediaStatus } = await MediaLibrary.requestPermissionsAsync();
setHasCameraPermission(cameraStatus === 'granted');
setHasMediaLibraryPermission(mediaStatus === 'granted');
})();
}, []);
const takePicture = async () => {
if (cameraRef.current) {
try {
const photo = await cameraRef.current.takePictureAsync({
quality: 0.8,
base64: true,
exif: false,
});
setPhotoUri(photo.uri);
if (hasMediaLibraryPermission) {
await MediaLibrary.saveToLibraryAsync(photo.uri);
Alert.alert('Success', 'Photo saved to gallery!');
}
} catch (error) {
console.error('Error taking picture:', error);
Alert.alert('Error', 'Failed to take picture');
}
}
};
const startRecording = async () => {
if (cameraRef.current && !isRecording) {
try {
setIsRecording(true);
const video = await cameraRef.current.recordAsync({
quality: Camera.Constants.VideoQuality['720p'],
maxDuration: 30,
});
if (hasMediaLibraryPermission) {
await MediaLibrary.saveToLibraryAsync(video.uri);
Alert.alert('Success', 'Video saved to gallery!');
}
} catch (error) {
console.error('Error recording video:', error);
Alert.alert('Error', 'Failed to record video');
} finally {
setIsRecording(false);
}
}
};
const stopRecording = () => {
if (cameraRef.current && isRecording) {
cameraRef.current.stopRecording();
}
};
const toggleCameraType = () => {
setType(
type === Camera.Constants.Type.back
? Camera.Constants.Type.front
: Camera.Constants.Type.back
);
};
const toggleFlash = () => {
setFlash(
flash === Camera.Constants.FlashMode.off
? Camera.Constants.FlashMode.on
: Camera.Constants.FlashMode.off
);
};
if (hasCameraPermission === null) {
return <View />;
}
if (hasCameraPermission === false) {
return (
<View style={styles.container}>
<Text style={styles.permissionText}>No access to camera</Text>
</View>
);
}
return (
<View style={styles.container}>
<Camera
style={styles.camera}
type={type}
flashMode={flash}
ref={cameraRef}
>
{/* Top Controls */}
<View style={styles.topControls}>
<TouchableOpacity style={styles.controlButton} onPress={toggleFlash}>
<MaterialIcons
name={flash === Camera.Constants.FlashMode.off ? 'flash-off' : 'flash-on'}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity style={styles.controlButton} onPress={toggleCameraType}>
<MaterialIcons name="flip-camera-ios" size={30} color="white" />
</TouchableOpacity>
</View>
{/* Recording Indicator */}
{isRecording && (
<View style={styles.recordingIndicator}>
<Text style={styles.recordingText}>● REC</Text>
</View>
)}
{/* Bottom Controls */}
<View style={styles.bottomControls}>
<TouchableOpacity
style={styles.captureButton}
onPress={takePicture}
>
<View style={styles.captureButtonInner} />
</TouchableOpacity>
<TouchableOpacity
style={[
styles.videoButton,
isRecording && styles.videoButtonRecording
]}
onPress={isRecording ? stopRecording : startRecording}
>
<MaterialIcons
name={isRecording ? 'stop' : 'videocam'}
size={30}
color="white"
/>
</TouchableOpacity>
</View>
</Camera>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
camera: {
flex: 1,
},
permissionText: {
fontSize: 18,
color: 'black',
textAlign: 'center',
marginTop: 100,
},
topControls: {
position: 'absolute',
top: 50,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 20,
},
controlButton: {
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 25,
width: 50,
height: 50,
alignItems: 'center',
justifyContent: 'center',
},
recordingIndicator: {
position: 'absolute',
top: 100,
left: 20,
backgroundColor: 'red',
borderRadius: 5,
paddingHorizontal: 10,
paddingVertical: 5,
},
recordingText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
bottomControls: {
position: 'absolute',
bottom: 50,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
},
captureButton: {
width: 70,
height: 70,
borderRadius: 35,
backgroundColor: 'white',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 3,
borderColor: 'rgba(255,255,255,0.5)',
},
captureButtonInner: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: 'white',
},
videoButton: {
backgroundColor: 'rgba(255,0,0,0.8)',
borderRadius: 25,
width: 50,
height: 50,
alignItems: 'center',
justifyContent: 'center',
},
videoButtonRecording: {
backgroundColor: 'rgba(255,0,0,1)',
},
});
Add face detection capabilities:
// Add to imports
import * as FaceDetector from 'expo-face-detector';
// Add face detection state
const [faces, setFaces] = useState([]);
const [faceDetectionEnabled, setFaceDetectionEnabled] = useState(true);
// Face detection handler
const handleFacesDetected = ({ faces }) => {
setFaces(faces);
};
// Add to Camera component props
<Camera
style={styles.camera}
type={type}
flashMode={flash}
ref={cameraRef}
onFacesDetected={faceDetectionEnabled ? handleFacesDetected : undefined}
faceDetectorSettings={{
mode: FaceDetector.FaceDetectorMode.fast,
detectLandmarks: FaceDetector.FaceDetectorLandmarks.all,
runClassifications: FaceDetector.FaceDetectorClassifications.all,
minDetectionInterval: 100,
tracking: true,
}}
>
{/* Existing camera content */}
{/* Face Detection Overlay */}
{faces.map((face, index) => (
<View
key={index}
style={[
styles.faceBox,
{
left: face.bounds.origin.x,
top: face.bounds.origin.y,
width: face.bounds.size.width,
height: face.bounds.size.height,
},
]}
>
<Text style={styles.faceText}>😊</Text>
{face.smilingProbability > 0.5 && (
<Text style={styles.smileIndicator}>SMILE!</Text>
)}
</View>
))}
</Camera>
// Add face detection styles
faceBox: {
position: 'absolute',
borderWidth: 2,
borderColor: 'yellow',
backgroundColor: 'transparent',
alignItems: 'center',
justifyContent: 'center',
},
faceText: {
fontSize: 30,
},
smileIndicator: {
color: 'yellow',
fontSize: 12,
fontWeight: 'bold',
backgroundColor: 'rgba(0,0,0,0.5)',
paddingHorizontal: 5,
},
Create simple AR overlays:
// Add effect state
const [currentEffect, setCurrentEffect] = useState('none');
// Effect definitions
const EFFECTS = {
none: { name: 'None', emoji: '❌' },
sunglasses: { name: 'Cool', emoji: '😎' },
crown: { name: 'Royal', emoji: '👑' },
heart_eyes: { name: 'Love', emoji: '😍' },
party: { name: 'Party', emoji: '🎉' },
};
// Effect overlay component
const EffectOverlay = ({ faces, effect }) => {
if (effect === 'none' || faces.length === 0) return null;
return faces.map((face, index) => {
const { bounds, leftEyePosition, rightEyePosition, noseBasePosition } = face;
switch (effect) {
case 'sunglasses':
return (
<View
key={index}
style={[
styles.sunglasses,
{
left: leftEyePosition.x - 40,
top: leftEyePosition.y - 10,
},
]}
>
<Text style={styles.effectText}>🕶️</Text>
</View>
);
case 'crown':
return (
<View
key={index}
style={[
styles.crown,
{
left: bounds.origin.x + bounds.size.width / 2 - 25,
top: bounds.origin.y - 30,
},
]}
>
<Text style={styles.effectText}>👑</Text>
</View>
);
case 'heart_eyes':
return (
<View key={index}>
<View
style={[
styles.heartEye,
{
left: leftEyePosition.x - 10,
top: leftEyePosition.y - 10,
},
]}
>
<Text style={styles.effectText}>❤️</Text>
</View>
<View
style={[
styles.heartEye,
{
left: rightEyePosition.x - 10,
top: rightEyePosition.y - 10,
},
]}
>
<Text style={styles.effectText}>❤️</Text>
</View>
</View>
);
case 'party':
return (
<View
key={index}
style={[
styles.partyEffect,
{
left: bounds.origin.x,
top: bounds.origin.y,
width: bounds.size.width,
height: bounds.size.height,
},
]}
>
{[...Array(5)].map((_, i) => (
<Text
key={i}
style={[
styles.confetti,
{
left: Math.random() * bounds.size.width,
top: Math.random() * bounds.size.height,
},
]}
>
🎉
</Text>
))}
</View>
);
default:
return null;
}
});
};
// Effects selector
const EffectsSelector = () => (
<View style={styles.effectsSelector}>
{Object.entries(EFFECTS).map(([key, effect]) => (
<TouchableOpacity
key={key}
style={[
styles.effectButton,
currentEffect === key && styles.effectButtonActive
]}
onPress={() => setCurrentEffect(key)}
>
<Text style={styles.effectEmoji}>{effect.emoji}</Text>
</TouchableOpacity>
))}
</View>
);
// Add to camera JSX
<EffectOverlay faces={faces} effect={currentEffect} />
<EffectsSelector />
// Add effect styles
effectsSelector: {
position: 'absolute',
right: 10,
top: 100,
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 25,
padding: 5,
},
effectButton: {
padding: 10,
borderRadius: 20,
marginVertical: 2,
},
effectButtonActive: {
backgroundColor: 'rgba(255,255,255,0.3)',
},
effectEmoji: {
fontSize: 24,
},
sunglasses: {
position: 'absolute',
},
crown: {
position: 'absolute',
},
heartEye: {
position: 'absolute',
},
partyEffect: {
position: 'absolute',
},
confetti: {
position: 'absolute',
fontSize: 16,
},
effectText: {
fontSize: 40,
},
Implement more sophisticated AR effects:
// Add animation state
const [animationFrame, setAnimationFrame] = useState(0);
// Animation loop
useEffect(() => {
const interval = setInterval(() => {
setAnimationFrame(prev => (prev + 1) % 60);
}, 100);
return () => clearInterval(interval);
}, []);
// Advanced effects with animation
const AdvancedEffectOverlay = ({ faces, effect, animationFrame }) => {
if (effect === 'none' || faces.length === 0) return null;
return faces.map((face, index) => {
const { bounds, leftEyePosition, rightEyePosition, noseBasePosition } = face;
switch (effect) {
case 'rainbow':
const colors = ['🔴', '🟠', '🟡', '🟢', '🔵', '🟣'];
const colorIndex = Math.floor(animationFrame / 10) % colors.length;
return (
<View
key={index}
style={[
styles.rainbowEffect,
{
left: bounds.origin.x - 20,
top: bounds.origin.y - 40,
width: bounds.size.width + 40,
},
]}
>
<Text style={styles.rainbowText}>
{Array(10).fill(colors[colorIndex]).join('')}
</Text>
</View>
);
case 'sparkles':
return (
<View
key={index}
style={[
styles.sparklesEffect,
{
left: bounds.origin.x - 30,
top: bounds.origin.y - 30,
width: bounds.size.width + 60,
height: bounds.size.height + 60,
},
]}
>
{[...Array(8)].map((_, i) => {
const angle = (i * 45) + (animationFrame * 6);
const radius = 40;
const x = Math.cos(angle * Math.PI / 180) * radius;
const y = Math.sin(angle * Math.PI / 180) * radius;
return (
<Text
key={i}
style={[
styles.sparkle,
{
left: (bounds.size.width + 60) / 2 + x,
top: (bounds.size.height + 60) / 2 + y,
},
]}
>
✨
</Text>
);
})}
</View>
);
case 'breathing':
const scale = 1 + Math.sin(animationFrame * 0.2) * 0.1;
return (
<View
key={index}
style={[
styles.breathingEffect,
{
left: bounds.origin.x + bounds.size.width / 2 - 25,
top: bounds.origin.y + bounds.size.height / 2 - 25,
transform: [{ scale }],
},
]}
>
<Text style={styles.effectText}>🌀</Text>
</View>
);
default:
return null;
}
});
};
// Face-based interactive effects
const InteractiveEffects = ({ faces }) => {
return faces.map((face, index) => {
const { smilingProbability, leftEyeOpenProbability, rightEyeOpenProbability } = face;
// Trigger effects based on facial expressions
let effect = null;
if (smilingProbability > 0.7) {
effect = '🌈 HAPPY! 🌈';
} else if (leftEyeOpenProbability < 0.3 && rightEyeOpenProbability < 0.3) {
effect = '😴 Sleepy...';
} else if (leftEyeOpenProbability < 0.3 || rightEyeOpenProbability < 0.3) {
effect = '😉 Wink!';
}
if (!effect) return null;
return (
<View
key={index}
style={[
styles.interactiveEffect,
{
left: face.bounds.origin.x,
top: face.bounds.origin.y - 50,
},
]}
>
<Text style={styles.interactiveText}>{effect}</Text>
</View>
);
});
};
// Add to camera JSX
<AdvancedEffectOverlay faces={faces} effect={currentEffect} animationFrame={animationFrame} />
<InteractiveEffects faces={faces} />
// Add advanced effect styles
rainbowEffect: {
position: 'absolute',
alignItems: 'center',
},
rainbowText: {
fontSize: 20,
},
sparklesEffect: {
position: 'absolute',
},
sparkle: {
position: 'absolute',
fontSize: 16,
},
breathingEffect: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
},
interactiveEffect: {
position: 'absolute',
backgroundColor: 'rgba(0,0,0,0.7)',
borderRadius: 15,
padding: 10,
},
interactiveText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
Create Instagram-style color filters:
// Add to imports
import { BlurView } from 'expo-blur';
// Color filter component
const ColorFilter = ({ filter, style }) => {
const filterStyles = {
vintage: {
backgroundColor: 'rgba(139, 69, 19, 0.3)',
},
noir: {
backgroundColor: 'rgba(0, 0, 0, 0.4)',
},
warm: {
backgroundColor: 'rgba(255, 165, 0, 0.2)',
},
cool: {
backgroundColor: 'rgba(0, 191, 255, 0.2)',
},
dramatic: {
backgroundColor: 'rgba(128, 0, 128, 0.3)',
},
};
if (filter === 'none') return null;
return (
<View
style={[
StyleSheet.absoluteFillObject,
filterStyles[filter],
style,
]}
pointerEvents="none"
/>
);
};
// Filter selector
const FilterSelector = ({ currentFilter, onFilterChange }) => {
const filters = [
{ key: 'none', name: 'None', color: '#fff' },
{ key: 'vintage', name: 'Vintage', color: '#8B4513' },
{ key: 'noir', name: 'Noir', color: '#000' },
{ key: 'warm', name: 'Warm', color: '#FFA500' },
{ key: 'cool', name: 'Cool', color: '#00BFFF' },
{ key: 'dramatic', name: 'Drama', color: '#800080' },
];
return (
<View style={styles.filterSelector}>
{filters.map(filter => (
<TouchableOpacity
key={filter.key}
style={[
styles.filterButton,
{ backgroundColor: filter.color },
currentFilter === filter.key && styles.filterButtonActive,
]}
onPress={() => onFilterChange(filter.key)}
>
<Text style={styles.filterText}>{filter.name}</Text>
</TouchableOpacity>
))}
</View>
);
};
Add decorative frames:
// Frame overlay component
const FrameOverlay = ({ frameType }) => {
if (frameType === 'none') return null;
const frames = {
polaroid: (
<View style={styles.polaroidFrame}>
<View style={styles.polaroidInner} />
<View style={styles.polaroidBottom}>
<Text style={styles.polaroidText}>Memories ✨</Text>
</View>
</View>
),
vintage: (
<View style={styles.vintageFrame}>
<View style={styles.vintageCorner} />
<View style={[styles.vintageCorner, styles.vintageCornerTR]} />
<View style={[styles.vintageCorner, styles.vintageCornerBL]} />
<View style={[styles.vintageCorner, styles.vintageCornerBR]} />
</View>
),
neon: (
<View style={styles.neonFrame}>
<View style={styles.neonGlow} />
</View>
),
};
return frames[frameType] || null;
};
// Frame styles
const frameStyles = StyleSheet.create({
polaroidFrame: {
position: 'absolute',
top: 50,
left: 20,
right: 20,
bottom: 150,
backgroundColor: 'white',
padding: 15,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 6,
},
polaroidInner: {
flex: 1,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#ddd',
},
polaroidBottom: {
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
polaroidText: {
color: '#333',
fontSize: 16,
fontStyle: 'italic',
},
vintageFrame: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
vintageCorner: {
position: 'absolute',
width: 50,
height: 50,
borderLeftWidth: 5,
borderTopWidth: 5,
borderColor: '#DAA520',
top: 20,
left: 20,
},
vintageCornerTR: {
right: 20,
left: undefined,
borderLeftWidth: 0,
borderRightWidth: 5,
},
vintageCornerBL: {
bottom: 20,
top: undefined,
borderTopWidth: 0,
borderBottomWidth: 5,
},
vintageCornerBR: {
bottom: 20,
right: 20,
top: undefined,
left: undefined,
borderTopWidth: 0,
borderLeftWidth: 0,
borderBottomWidth: 5,
borderRightWidth: 5,
},
neonFrame: {
position: 'absolute',
top: 30,
left: 30,
right: 30,
bottom: 30,
borderWidth: 3,
borderColor: '#00FFFF',
borderRadius: 20,
},
neonGlow: {
position: 'absolute',
top: -5,
left: -5,
right: -5,
bottom: -5,
borderWidth: 1,
borderColor: '#00FFFF',
borderRadius: 25,
shadowColor: '#00FFFF',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 1,
shadowRadius: 10,
},
});
Handle multiple faces simultaneously:
const MultiFaceEffects = ({ faces }) => {
if (faces.length < 2) return null;
// Draw connections between faces
const centerPoints = faces.map(face => ({
x: face.bounds.origin.x + face.bounds.size.width / 2,
y: face.bounds.origin.y + face.bounds.size.height / 2,
}));
return (
<View style={StyleSheet.absoluteFillObject}>
{centerPoints.map((point, index) => {
if (index === centerPoints.length - 1) return null;
const nextPoint = centerPoints[index + 1];
return (
<View
key={index}
style={[
styles.connectionLine,
{
left: point.x,
top: point.y,
width: Math.sqrt(
Math.pow(nextPoint.x - point.x, 2) +
Math.pow(nextPoint.y - point.y, 2)
),
transform: [{
rotate: `${Math.atan2(
nextPoint.y - point.y,
nextPoint.x - point.x
)}rad`
}],
},
]}
/>
);
})}
</View>
);
};
Activate effects with gestures:
import { PanGestureHandler, State } from 'react-native-gesture-handler';
const GestureEffects = () => {
const [gestureEffect, setGestureEffect] = useState(null);
const onGestureEvent = (event) => {
const { translationX, translationY } = event.nativeEvent;
if (Math.abs(translationX) > 50) {
setGestureEffect('swipe');
setTimeout(() => setGestureEffect(null), 2000);
} else if (translationY < -50) {
setGestureEffect('up');
setTimeout(() => setGestureEffect(null), 2000);
}
};
return (
<PanGestureHandler onGestureEvent={onGestureEvent}>
<View style={StyleSheet.absoluteFillObject}>
{gestureEffect === 'swipe' && (
<Text style={styles.gestureText}>SWIPE! 👋</Text>
)}
{gestureEffect === 'up' && (
<Text style={styles.gestureText}>UP! ⬆️</Text>
)}
</View>
</PanGestureHandler>
);
};
AR Camera App Successfully Built If:
Time Investment: 60 minutes total Difficulty Level: Advanced Prerequisites: React Native basics, understanding of camera/AR concepts Tools Needed: Physical device with camera (AR doesn't work in simulator)