02 · Device APIs: Camera, Location & Haptics¶
The reason to build a native app instead of a website is often the hardware. Expo's SDK wraps the common device APIs in typed modules that work the same way on iOS and Android. This lesson uses three of them — camera, location and haptics — and focuses on the parts the docs' quick-starts skip: battery cost, accuracy trade-offs, lifecycle, and what you can and can't test on a simulator.
All three work in Expo Go for experimenting. Permission prompts are covered properly in the next lesson; here we use each module's permission hook in its simplest form.
Simulators are not phones
The iOS Simulator has no camera (a CameraView shows nothing useful), simulates location from a
preset or a GPX file, and has no haptic engine. Android emulators can feed a virtual camera
scene and a mock location. Test camera and haptics on a real device.
Haptics: the cheapest polish you'll ever add¶
import * as Haptics from 'expo-haptics';
Haptics.selectionAsync(); // tick: picker/segmented change
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); // tap: toggle, small confirm
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); // drop a dragged item
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); // task completed
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); // validation failed
Rules of thumb: tie haptics to meaningful state changes (habit completed, item deleted), not to
every tap; use the semantic types (Success, Warning, Error) rather than inventing patterns; and
remember users can disable system haptics, in which case these calls simply do nothing.
async function completeHabit(id: string) {
await markDone(id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
Location¶
import * as Location from 'expo-location';
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') return;
// One reading
const pos = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
console.log(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy); // accuracy in metres
// Cheap and instant, possibly old — great for a first guess
const last = await Location.getLastKnownPositionAsync({ maxAge: 5 * 60_000 });
Accuracy costs battery and time¶
Accuracy |
Typical use | Cost |
|---|---|---|
Lowest / Low |
"Which city am I in?" | Cell/Wi-Fi only, very cheap |
Balanced |
Nearby places, tagging a note | Moderate |
High |
Turn-by-turn or precise check-in | GPS, more battery, slower first fix |
Highest / BestForNavigation |
Navigation apps | Highest drain |
A cold GPS fix can take a while, especially indoors. A good pattern is "last known first, then
refine": show getLastKnownPositionAsync immediately, request a fresh position in the background,
and update when it arrives. Always look at coords.accuracy — a position with 2,000 m accuracy is
not a street address.
Watching location¶
const sub = await Location.watchPositionAsync(
{ accuracy: Location.Accuracy.Balanced, distanceInterval: 25, timeInterval: 10_000 },
(loc) => setPosition(loc.coords),
);
// later — always:
sub.remove();
distanceInterval (metres) and timeInterval (milliseconds) throttle updates. Forgetting
sub.remove() when a screen loses focus keeps the GPS running and drains the battery. Tracking in
the background (app not visible) needs a separate permission, expo-task-manager, platform
configuration, and a justification that app reviewers will scrutinise — only do it if the feature
truly requires it.
Reverse geocoding¶
const [place] = await Location.reverseGeocodeAsync({ latitude, longitude });
const label = [place?.name, place?.city].filter(Boolean).join(', ');
This uses the platform's geocoder (Apple's on iOS, Google's via the system on Android); it needs a network connection and is rate-limited, so cache results rather than calling it on every update.
Camera¶
expo-camera provides CameraView, a native camera preview you place in your layout like any view:
import { useRef, useState } from 'react';
import { Button, Image, Pressable, StyleSheet, Text, View } from 'react-native';
import { CameraView, CameraType, useCameraPermissions } from 'expo-camera';
import * as Haptics from 'expo-haptics';
export function CaptureScreen({ onCaptured }: { onCaptured: (uri: string) => void }) {
const [permission, requestPermission] = useCameraPermissions();
const [facing, setFacing] = useState<CameraType>('back');
const [ready, setReady] = useState(false);
const [preview, setPreview] = useState<string | null>(null);
const camera = useRef<CameraView>(null);
if (!permission) return <View />; // permission status still loading
if (!permission.granted) {
return (
<View style={styles.center}>
<Text style={styles.explain}>Field Notes uses the camera to attach a photo to a note.</Text>
<Button title="Allow camera" onPress={requestPermission} />
</View>
);
}
async function capture() {
if (!camera.current || !ready) return;
const photo = await camera.current.takePictureAsync({ quality: 0.7, skipProcessing: false });
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setPreview(photo.uri);
}
if (preview) {
return (
<View style={styles.fill}>
<Image source={{ uri: preview }} style={styles.fill} />
<View style={styles.row}>
<Button title="Retake" onPress={() => setPreview(null)} />
<Button title="Use photo" onPress={() => onCaptured(preview)} />
</View>
</View>
);
}
return (
<View style={styles.fill}>
<CameraView ref={camera} style={styles.fill} facing={facing} onCameraReady={() => setReady(true)} />
<View style={styles.row}>
<Button title="Flip" onPress={() => setFacing((f) => (f === 'back' ? 'front' : 'back'))} />
<Pressable onPress={capture} disabled={!ready} accessibilityRole="button" accessibilityLabel="Take photo" style={styles.shutter} />
</View>
</View>
);
}
const styles = StyleSheet.create({
fill: { flex: 1, backgroundColor: 'black' },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24, gap: 12 },
explain: { textAlign: 'center', fontSize: 16 },
row: { flexDirection: 'row', justifyContent: 'space-around', alignItems: 'center', padding: 24, backgroundColor: 'black' },
shutter: { width: 72, height: 72, borderRadius: 36, backgroundColor: 'white', borderWidth: 4, borderColor: '#cbd5e1' },
});
Notes from real use:
- Wait for
onCameraReadybefore callingtakePictureAsync, or the first capture can fail. - The photo's
uripoints to a file in the app's cache directory, which the OS may clear. To keep a photo, copy it into your app's document directory withexpo-file-system(the Level 3 project does this). quality(0–1) controls JPEG compression; full-resolution photos from modern phones are large, so pick the lowest quality your feature can tolerate.- Unmount the camera when the screen loses focus. A camera left running in a background tab keeps
the hardware busy and the camera-in-use indicator lit. With Expo Router, render
CameraViewonly whenuseIsFocused()is true. CameraViewalso scans barcodes and QR codes (barcodeScannerSettingsandonBarcodeScanned) without an extra library.
Worked example: "tag this note with where I am"¶
import * as Location from 'expo-location';
export type Tag = { latitude: number; longitude: number; accuracy: number | null; label: string | null; stale: boolean };
export async function locateForNote(): Promise<Tag | null> {
const perm = await Location.getForegroundPermissionsAsync();
if (!perm.granted) return null;
// 1. instant guess
const last = await Location.getLastKnownPositionAsync({ maxAge: 10 * 60_000, requiredAccuracy: 200 });
// 2. fresh fix, but don't wait forever
const fresh = await Promise.race([
Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
]).catch(() => null);
const best = fresh ?? last;
if (!best) return null;
let label: string | null = null;
try {
const [place] = await Location.reverseGeocodeAsync(best.coords);
label = [place?.name, place?.city].filter(Boolean).join(', ') || null;
} catch {
// offline: keep coordinates, add a label later
}
return { latitude: best.coords.latitude, longitude: best.coords.longitude, accuracy: best.coords.accuracy, label, stale: !fresh };
}
The stale flag lets the UI say "approximate location" honestly when only the last-known position
was available.
How It Actually Works¶
Each of these packages is an Expo module: a TypeScript API on top of Swift (iOS) and Kotlin
(Android) code, called through JSI. getCurrentPositionAsync creates a native location request
(CLLocationManager on iOS; the fused location provider or the platform location manager on
Android), returns a promise, and resolves it on the JS thread when the OS reports a fix.
CameraView is a native view component: Fabric creates a native view backed by AVFoundation on
iOS and CameraX on Android. Preview frames go straight from the camera hardware to the GPU-backed
preview layer — they never pass through JavaScript, which is why the preview is smooth even if your
JS is busy. takePictureAsync asks the native camera session to capture a still, writes a JPEG to the
cache directory on a background thread, and resolves with its file URI. Haptics call
UIFeedbackGenerator on iOS and the Vibrator/haptic feedback APIs on Android.
Common mistakes¶
- Testing camera or haptics only on a simulator and concluding they're broken.
- Watching location without removing the subscription.
Accuracy.Highestfor a feature that needs a city name.- Keeping photos in the cache directory and losing them later.
- Leaving
CameraViewmounted in a background tab. - Haptics on every interaction — it becomes noise and costs battery.
Exercise¶
- Add a haptic success tap when a habit is completed, and a light selection tick when the user changes tabs in a segmented control. Try it on a device and remove any haptic that feels like noise.
- Build a "Where am I?" screen that shows last-known position immediately, then refines with a
fresh
Balancedfix, displaying accuracy in metres and a "stale" badge. - Build the capture screen and confirm the camera turns off (indicator light goes out) when you
switch to another tab. If it doesn't, add
useIsFocused. - Add QR scanning: show the decoded text in an
Alert, and ignore repeated scans of the same code within two seconds.