Module 8: Native Features and Device APIs
Sensors and Haptics
Motion sensors, device orientation, and tactile feedback
šÆ Learning Objectives
- Read accelerometer data for motion detection
- Use the gyroscope for rotation tracking
- Detect device orientation changes
- Implement shake detection
- Use the pedometer for step counting
- Add haptic feedback to enhance user experience
- Build interactive experiences with sensor data
Sensor Overview
Modern smartphones contain numerous sensors that enable rich, interactive experiences. Expo provides access to these through the expo-sensors package.
Installation
# Install expo-sensors and expo-haptics
npx expo install expo-sensors expo-haptics
Available Sensors
š± Sensor Availability
| Sensor | iOS | Android | Permission |
|---|---|---|---|
| Accelerometer | ā | ā | iOS: Motion |
| Gyroscope | ā | ā | iOS: Motion |
| Magnetometer | ā | ā | None |
| Barometer | ā | ā (if available) | None |
| Pedometer | ā | ā | iOS: Motion, Android: Activity |
| Light Sensor | ā | ā | None |
Accelerometer
The accelerometer measures the device's acceleration in three dimensions (x, y, z), including the effect of gravity. It's useful for detecting motion, orientation, and gestures.
Understanding Accelerometer Data
š” Accelerometer Values
- Flat on table face up: x ā 0, y ā 0, z ā 1 (gravity)
- Upright (portrait): x ā 0, y ā -1, z ā 0
- Tilted left: x increases positive
- Values in g-force: 1g ā 9.81 m/s²
Basic Accelerometer Usage
import { useState, useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Accelerometer } from 'expo-sensors';
export default function AccelerometerDemo() {
const [data, setData] = useState({ x: 0, y: 0, z: 0 });
useEffect(() => {
// Set update interval (in milliseconds)
Accelerometer.setUpdateInterval(100);
// Subscribe to accelerometer updates
const subscription = Accelerometer.addListener(accelerometerData => {
setData(accelerometerData);
});
// Cleanup on unmount
return () => subscription.remove();
}, []);
return (
<View style={styles.container}>
<Text style={styles.title}>Accelerometer Data</Text>
<Text style={styles.data}>X: {data.x.toFixed(3)}</Text>
<Text style={styles.data}>Y: {data.y.toFixed(3)}</Text>
<Text style={styles.data}>Z: {data.z.toFixed(3)}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20 },
data: { fontSize: 18, marginVertical: 5, fontFamily: 'monospace' },
});
Accelerometer with Start/Stop
import { useState, useEffect, useRef } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { Accelerometer, AccelerometerMeasurement } from 'expo-sensors';
export default function ControllableAccelerometer() {
const [data, setData] = useState<AccelerometerMeasurement>({ x: 0, y: 0, z: 0 });
const [isActive, setIsActive] = useState(false);
const subscriptionRef = useRef<ReturnType<typeof Accelerometer.addListener> | null>(null);
const startListening = () => {
Accelerometer.setUpdateInterval(100);
subscriptionRef.current = Accelerometer.addListener(setData);
setIsActive(true);
};
const stopListening = () => {
subscriptionRef.current?.remove();
subscriptionRef.current = null;
setIsActive(false);
};
useEffect(() => {
return () => {
subscriptionRef.current?.remove();
};
}, []);
return (
<View style={styles.container}>
<View style={styles.dataContainer}>
<Text style={styles.label}>X</Text>
<View style={[styles.bar, { width: Math.abs(data.x) * 100, backgroundColor: '#f44336' }]} />
<Text style={styles.value}>{data.x.toFixed(2)}</Text>
</View>
<View style={styles.dataContainer}>
<Text style={styles.label}>Y</Text>
<View style={[styles.bar, { width: Math.abs(data.y) * 100, backgroundColor: '#4CAF50' }]} />
<Text style={styles.value}>{data.y.toFixed(2)}</Text>
</View>
<View style={styles.dataContainer}>
<Text style={styles.label}>Z</Text>
<View style={[styles.bar, { width: Math.abs(data.z) * 100, backgroundColor: '#2196F3' }]} />
<Text style={styles.value}>{data.z.toFixed(2)}</Text>
</View>
<Pressable
style={[styles.button, isActive ? styles.stopButton : styles.startButton]}
onPress={isActive ? stopListening : startListening}
>
<Text style={styles.buttonText}>
{isActive ? 'Stop' : 'Start'}
</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 20 },
dataContainer: { flexDirection: 'row', alignItems: 'center', marginVertical: 10 },
label: { width: 30, fontSize: 18, fontWeight: 'bold' },
bar: { height: 20, borderRadius: 4, marginRight: 10 },
value: { fontSize: 16, fontFamily: 'monospace' },
button: { marginTop: 30, padding: 16, borderRadius: 8, alignItems: 'center' },
startButton: { backgroundColor: '#4CAF50' },
stopButton: { backgroundColor: '#f44336' },
buttonText: { color: 'white', fontSize: 18, fontWeight: '600' },
});
Check Sensor Availability
import { Accelerometer } from 'expo-sensors';
async function checkAccelerometer() {
const isAvailable = await Accelerometer.isAvailableAsync();
if (!isAvailable) {
console.log('Accelerometer is not available on this device');
return false;
}
// Request permission on iOS
const { status } = await Accelerometer.requestPermissionsAsync();
if (status !== 'granted') {
console.log('Motion permission denied');
return false;
}
return true;
}
Gyroscope
The gyroscope measures the device's rotation rate around three axes. Unlike the accelerometer, it measures angular velocity (how fast the device is rotating) rather than linear motion.
Basic Gyroscope Usage
import { useState, useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Gyroscope } from 'expo-sensors';
export default function GyroscopeDemo() {
const [rotation, setRotation] = useState({ x: 0, y: 0, z: 0 });
useEffect(() => {
Gyroscope.setUpdateInterval(100);
const subscription = Gyroscope.addListener(data => {
setRotation(data);
});
return () => subscription.remove();
}, []);
// Values are in radians per second
const toDegrees = (rad: number) => (rad * 180 / Math.PI).toFixed(1);
return (
<View style={styles.container}>
<Text style={styles.title}>Gyroscope (°/sec)</Text>
<Text style={styles.data}>Pitch (X): {toDegrees(rotation.x)}°/s</Text>
<Text style={styles.data}>Roll (Y): {toDegrees(rotation.y)}°/s</Text>
<Text style={styles.data}>Yaw (Z): {toDegrees(rotation.z)}°/s</Text>
<View style={styles.visualization}>
<View style={[
styles.indicator,
{
transform: [
{ rotateX: `${rotation.x * 20}deg` },
{ rotateY: `${rotation.y * 20}deg` },
{ rotateZ: `${rotation.z * 20}deg` },
]
}
]}>
<Text style={styles.indicatorText}>š±</Text>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20 },
data: { fontSize: 16, marginVertical: 5 },
visualization: { marginTop: 40, width: 150, height: 150, justifyContent: 'center', alignItems: 'center' },
indicator: { width: 100, height: 100, justifyContent: 'center', alignItems: 'center', backgroundColor: '#e0e0e0', borderRadius: 10 },
indicatorText: { fontSize: 48 },
});
š Gyroscope vs Accelerometer
- Accelerometer: Measures how fast the device is moving (linear)
- Gyroscope: Measures how fast the device is rotating (angular)
- Combined: Use both for accurate motion tracking
Device Motion
Device Motion combines data from multiple sensors (accelerometer, gyroscope, magnetometer) to provide processed, filtered motion data. It's more accurate and easier to use than raw sensor data.
Using Device Motion
import { useState, useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { DeviceMotion } from 'expo-sensors';
export default function DeviceMotionDemo() {
const [motion, setMotion] = useState<any>(null);
useEffect(() => {
DeviceMotion.setUpdateInterval(100);
const subscription = DeviceMotion.addListener(data => {
setMotion(data);
});
return () => subscription.remove();
}, []);
if (!motion) {
return (
<View style={styles.container}>
<Text>Waiting for motion data...</Text>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>Device Motion</Text>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Acceleration (with gravity)</Text>
<Text>X: {motion.accelerationIncludingGravity?.x?.toFixed(2)}</Text>
<Text>Y: {motion.accelerationIncludingGravity?.y?.toFixed(2)}</Text>
<Text>Z: {motion.accelerationIncludingGravity?.z?.toFixed(2)}</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Acceleration (without gravity)</Text>
<Text>X: {motion.acceleration?.x?.toFixed(2)}</Text>
<Text>Y: {motion.acceleration?.y?.toFixed(2)}</Text>
<Text>Z: {motion.acceleration?.z?.toFixed(2)}</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Rotation (degrees)</Text>
<Text>Alpha: {((motion.rotation?.alpha ?? 0) * 180 / Math.PI).toFixed(1)}°</Text>
<Text>Beta: {((motion.rotation?.beta ?? 0) * 180 / Math.PI).toFixed(1)}°</Text>
<Text>Gamma: {((motion.rotation?.gamma ?? 0) * 180 / Math.PI).toFixed(1)}°</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Orientation</Text>
<Text>{motion.orientation}°</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20 },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20 },
section: { marginBottom: 20, padding: 15, backgroundColor: '#f5f5f5', borderRadius: 8 },
sectionTitle: { fontWeight: '600', marginBottom: 8 },
});
Device Motion Data Structure
š DeviceMotion Data
{
// Acceleration including gravity (raw accelerometer)
accelerationIncludingGravity: { x, y, z },
// Acceleration without gravity (user movement only)
acceleration: { x, y, z },
// Rotation rate (gyroscope)
rotationRate: { alpha, beta, gamma },
// Device orientation in 3D space
rotation: { alpha, beta, gamma },
// Screen orientation in degrees (0, 90, 180, 270)
orientation: number,
}
Pedometer
The pedometer counts steps using the device's motion co-processor. It's energy-efficient and works even when the app is in the background.
Step Counting
import { useState, useEffect } from 'react';
import { View, Text, StyleSheet, Platform } from 'react-native';
import { Pedometer } from 'expo-sensors';
export default function PedometerDemo() {
const [isPedometerAvailable, setIsPedometerAvailable] = useState(false);
const [currentSteps, setCurrentSteps] = useState(0);
const [pastSteps, setPastSteps] = useState(0);
useEffect(() => {
checkAvailability();
subscribeToPedometer();
getPastSteps();
}, []);
const checkAvailability = async () => {
const available = await Pedometer.isAvailableAsync();
setIsPedometerAvailable(available);
};
const subscribeToPedometer = async () => {
const available = await Pedometer.isAvailableAsync();
if (!available) return;
// Request permission on iOS
if (Platform.OS === 'ios') {
const { status } = await Pedometer.requestPermissionsAsync();
if (status !== 'granted') return;
}
// Listen for live step updates
const subscription = Pedometer.watchStepCount(result => {
setCurrentSteps(result.steps);
});
return () => subscription.remove();
};
const getPastSteps = async () => {
const available = await Pedometer.isAvailableAsync();
if (!available) return;
// Get steps from the past 24 hours
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 1);
const result = await Pedometer.getStepCountAsync(start, end);
setPastSteps(result.steps);
};
return (
<View style={styles.container}>
<Text style={styles.title}>š¶ Pedometer</Text>
<Text style={styles.available}>
{isPedometerAvailable ? 'ā
Available' : 'ā Not available'}
</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>Steps Since App Open</Text>
<Text style={styles.steps}>{currentSteps}</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>Steps (Past 24 Hours)</Text>
<Text style={styles.steps}>{pastSteps}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
title: { fontSize: 32, fontWeight: 'bold', marginBottom: 10 },
available: { fontSize: 16, marginBottom: 30 },
card: { backgroundColor: '#f5f5f5', padding: 30, borderRadius: 16, alignItems: 'center', marginBottom: 20, width: '100%' },
cardTitle: { fontSize: 14, color: '#666', marginBottom: 10 },
steps: { fontSize: 48, fontWeight: 'bold', color: '#667eea' },
});
Historical Step Data
import { Pedometer } from 'expo-sensors';
// Get steps for a specific date range
async function getStepsForRange(startDate: Date, endDate: Date) {
const available = await Pedometer.isAvailableAsync();
if (!available) return null;
const result = await Pedometer.getStepCountAsync(startDate, endDate);
return result.steps;
}
// Get today's steps
async function getTodaySteps() {
const now = new Date();
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return getStepsForRange(startOfDay, now);
}
// Get this week's steps
async function getWeekSteps() {
const now = new Date();
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - now.getDay()); // Start of week (Sunday)
startOfWeek.setHours(0, 0, 0, 0);
return getStepsForRange(startOfWeek, now);
}
// Get steps per day for the past week
async function getStepsPerDay(days: number = 7) {
const stepsPerDay: { date: Date; steps: number }[] = [];
for (let i = 0; i < days; i++) {
const date = new Date();
date.setDate(date.getDate() - i);
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const end = new Date(start);
end.setDate(end.getDate() + 1);
const steps = await getStepsForRange(start, end);
stepsPerDay.push({ date: start, steps: steps ?? 0 });
}
return stepsPerDay.reverse();
}
Shake Detection
Detecting device shakes is a common use case for triggering actions like "undo" or refreshing content. We can implement this using the accelerometer.
Simple Shake Detector
import { useState, useEffect, useRef } from 'react';
import { View, Text, StyleSheet, Vibration } from 'react-native';
import { Accelerometer } from 'expo-sensors';
const SHAKE_THRESHOLD = 1.5; // Adjust sensitivity
const SHAKE_TIMEOUT = 500; // Minimum time between shakes
export default function ShakeDetector() {
const [shakeCount, setShakeCount] = useState(0);
const lastShakeRef = useRef(0);
useEffect(() => {
Accelerometer.setUpdateInterval(100);
const subscription = Accelerometer.addListener(data => {
const { x, y, z } = data;
// Calculate total acceleration magnitude
const acceleration = Math.sqrt(x * x + y * y + z * z);
// Check if it's a shake
if (acceleration > SHAKE_THRESHOLD) {
const now = Date.now();
// Debounce: only count if enough time has passed
if (now - lastShakeRef.current > SHAKE_TIMEOUT) {
lastShakeRef.current = now;
setShakeCount(prev => prev + 1);
Vibration.vibrate(100); // Feedback
}
}
});
return () => subscription.remove();
}, []);
return (
<View style={styles.container}>
<Text style={styles.title}>𫨠Shake Me!</Text>
<Text style={styles.count}>{shakeCount}</Text>
<Text style={styles.label}>shakes detected</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 32, marginBottom: 40 },
count: { fontSize: 80, fontWeight: 'bold', color: '#667eea' },
label: { fontSize: 18, color: '#666' },
});
Reusable Shake Hook
import { useEffect, useRef, useCallback } from 'react';
import { Accelerometer } from 'expo-sensors';
interface ShakeOptions {
threshold?: number;
timeout?: number;
updateInterval?: number;
}
export function useShake(
onShake: () => void,
options: ShakeOptions = {}
) {
const {
threshold = 1.5,
timeout = 500,
updateInterval = 100,
} = options;
const lastShakeRef = useRef(0);
const callbackRef = useRef(onShake);
// Keep callback ref updated
useEffect(() => {
callbackRef.current = onShake;
}, [onShake]);
useEffect(() => {
Accelerometer.setUpdateInterval(updateInterval);
const subscription = Accelerometer.addListener(({ x, y, z }) => {
const acceleration = Math.sqrt(x * x + y * y + z * z);
if (acceleration > threshold) {
const now = Date.now();
if (now - lastShakeRef.current > timeout) {
lastShakeRef.current = now;
callbackRef.current();
}
}
});
return () => subscription.remove();
}, [threshold, timeout, updateInterval]);
}
// Usage
function MyComponent() {
const handleShake = useCallback(() => {
console.log('Device was shaken!');
// Trigger undo, refresh, etc.
}, []);
useShake(handleShake, { threshold: 1.8 });
return <View>{/* Your content */}</View>;
}
Haptic Feedback
Haptic feedback provides tactile responses to user interactions, making your app feel more responsive and polished. Expo provides the expo-haptics package for this.
Haptic Types
import * as Haptics from 'expo-haptics';
// Impact feedback - for button taps, collisions
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
// Notification feedback - for success/warning/error states
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
// Selection feedback - for picker/slider changes
await Haptics.selectionAsync();
š³ When to Use Each Type
| Type | Use Cases |
|---|---|
| Impact Light | Subtle taps, toggle switches, light UI interactions |
| Impact Medium | Button presses, card selections |
| Impact Heavy | Significant actions, drag drop, collisions |
| Notification Success | Task completed, form submitted, save successful |
| Notification Warning | Approaching limit, potential issue |
| Notification Error | Validation error, action failed |
| Selection | Picker changes, slider ticks, segmented controls |
Haptic Demo Component
import { View, Text, Pressable, StyleSheet } from 'react-native';
import * as Haptics from 'expo-haptics';
export default function HapticsDemo() {
const hapticOptions = [
{ label: 'Light Impact', action: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light), color: '#81C784' },
{ label: 'Medium Impact', action: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium), color: '#4CAF50' },
{ label: 'Heavy Impact', action: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy), color: '#2E7D32' },
{ label: 'Success', action: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success), color: '#2196F3' },
{ label: 'Warning', action: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning), color: '#FF9800' },
{ label: 'Error', action: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error), color: '#f44336' },
{ label: 'Selection', action: () => Haptics.selectionAsync(), color: '#9C27B0' },
];
return (
<View style={styles.container}>
<Text style={styles.title}>š³ Haptic Feedback</Text>
{hapticOptions.map((option, index) => (
<Pressable
key={index}
style={[styles.button, { backgroundColor: option.color }]}
onPress={option.action}
>
<Text style={styles.buttonText}>{option.label}</Text>
</Pressable>
))}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 20 },
title: { fontSize: 28, fontWeight: 'bold', textAlign: 'center', marginBottom: 30 },
button: { padding: 16, borderRadius: 8, marginBottom: 12, alignItems: 'center' },
buttonText: { color: 'white', fontSize: 16, fontWeight: '600' },
});
Haptic Button Component
import { Pressable, PressableProps, StyleSheet } from 'react-native';
import * as Haptics from 'expo-haptics';
interface HapticButtonProps extends PressableProps {
hapticStyle?: 'light' | 'medium' | 'heavy' | 'selection';
children: React.ReactNode;
}
export function HapticButton({
hapticStyle = 'medium',
onPress,
children,
...props
}: HapticButtonProps) {
const handlePress = async (event: any) => {
// Trigger haptic
switch (hapticStyle) {
case 'light':
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
break;
case 'medium':
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
break;
case 'heavy':
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
break;
case 'selection':
await Haptics.selectionAsync();
break;
}
// Call original onPress
onPress?.(event);
};
return (
<Pressable onPress={handlePress} {...props}>
{children}
</Pressable>
);
}
// Usage
<HapticButton
hapticStyle="heavy"
onPress={() => console.log('Pressed!')}
style={styles.button}
>
<Text>Press Me</Text>
</HapticButton>
Haptic Slider
import { useState, useRef } from 'react';
import { View, StyleSheet } from 'react-native';
import Slider from '@react-native-community/slider';
import * as Haptics from 'expo-haptics';
export function HapticSlider({
step = 1,
onValueChange,
...props
}) {
const lastValueRef = useRef(0);
const handleValueChange = (value: number) => {
// Check if we crossed a step boundary
const currentStep = Math.round(value / step);
const lastStep = Math.round(lastValueRef.current / step);
if (currentStep !== lastStep) {
Haptics.selectionAsync();
}
lastValueRef.current = value;
onValueChange?.(value);
};
return (
<Slider
step={step}
onValueChange={handleValueChange}
{...props}
/>
);
}
// Usage
<HapticSlider
minimumValue={0}
maximumValue={100}
step={10}
onValueChange={(value) => console.log(value)}
/>
Practical Examples
Level/Bubble Tool
import { useState, useEffect } from 'react';
import { View, Text, StyleSheet, Dimensions } from 'react-native';
import { Accelerometer } from 'expo-sensors';
import * as Haptics from 'expo-haptics';
const { width } = Dimensions.get('window');
const LEVEL_THRESHOLD = 0.02; // How close to level counts as "level"
export default function LevelTool() {
const [tilt, setTilt] = useState({ x: 0, y: 0 });
const [isLevel, setIsLevel] = useState(false);
useEffect(() => {
Accelerometer.setUpdateInterval(50);
const subscription = Accelerometer.addListener(data => {
setTilt({ x: data.x, y: data.y });
// Check if device is level
const level = Math.abs(data.x) < LEVEL_THRESHOLD && Math.abs(data.y) < LEVEL_THRESHOLD;
if (level && !isLevel) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
setIsLevel(level);
});
return () => subscription.remove();
}, [isLevel]);
// Calculate bubble position
const bubbleX = tilt.x * (width / 4);
const bubbleY = tilt.y * (width / 4);
return (
<View style={styles.container}>
<Text style={styles.title}>š Level Tool</Text>
<View style={[styles.levelContainer, isLevel && styles.levelContainerLevel]}>
{/* Cross hairs */}
<View style={styles.crossHairH} />
<View style={styles.crossHairV} />
{/* Bubble */}
<View style={[
styles.bubble,
{
transform: [
{ translateX: bubbleX },
{ translateY: bubbleY },
],
},
isLevel && styles.bubbleLevel,
]} />
{/* Center circle */}
<View style={[styles.centerCircle, isLevel && styles.centerCircleLevel]} />
</View>
<Text style={[styles.status, isLevel && styles.statusLevel]}>
{isLevel ? 'ā LEVEL' : 'Tilt to level'}
</Text>
<Text style={styles.degrees}>
X: {(tilt.x * 90).toFixed(1)}° | Y: {(tilt.y * 90).toFixed(1)}°
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#1a1a1a' },
title: { fontSize: 24, fontWeight: 'bold', color: 'white', marginBottom: 40 },
levelContainer: { width: width - 80, height: width - 80, borderRadius: (width - 80) / 2, backgroundColor: '#333', justifyContent: 'center', alignItems: 'center', borderWidth: 4, borderColor: '#555' },
levelContainerLevel: { borderColor: '#4CAF50' },
crossHairH: { position: 'absolute', width: '80%', height: 2, backgroundColor: '#555' },
crossHairV: { position: 'absolute', width: 2, height: '80%', backgroundColor: '#555' },
bubble: { width: 40, height: 40, borderRadius: 20, backgroundColor: '#f44336' },
bubbleLevel: { backgroundColor: '#4CAF50' },
centerCircle: { position: 'absolute', width: 60, height: 60, borderRadius: 30, borderWidth: 2, borderColor: '#555' },
centerCircleLevel: { borderColor: '#4CAF50' },
status: { marginTop: 40, fontSize: 20, fontWeight: '600', color: '#888' },
statusLevel: { color: '#4CAF50' },
degrees: { marginTop: 16, fontSize: 14, color: '#666' },
});
Tilt-Controlled Ball
import { useState, useEffect } from 'react';
import { View, StyleSheet, Dimensions } from 'react-native';
import { Accelerometer } from 'expo-sensors';
import Animated, { useAnimatedStyle, withSpring } from 'react-native-reanimated';
const { width, height } = Dimensions.get('window');
const BALL_SIZE = 50;
const BOUNDS = {
minX: BALL_SIZE / 2,
maxX: width - BALL_SIZE / 2,
minY: BALL_SIZE / 2,
maxY: height - 200,
};
export default function TiltBall() {
const [position, setPosition] = useState({ x: width / 2, y: height / 2 - 100 });
const [velocity, setVelocity] = useState({ x: 0, y: 0 });
useEffect(() => {
Accelerometer.setUpdateInterval(16); // ~60fps
const subscription = Accelerometer.addListener(data => {
setVelocity(prev => ({
x: prev.x + data.x * 2,
y: prev.y - data.y * 2,
}));
});
// Physics update loop
const interval = setInterval(() => {
setPosition(prev => {
let newX = prev.x + velocity.x;
let newY = prev.y + velocity.y;
// Bounce off walls
if (newX < BOUNDS.minX || newX > BOUNDS.maxX) {
setVelocity(v => ({ ...v, x: -v.x * 0.8 }));
newX = Math.max(BOUNDS.minX, Math.min(BOUNDS.maxX, newX));
}
if (newY < BOUNDS.minY || newY > BOUNDS.maxY) {
setVelocity(v => ({ ...v, y: -v.y * 0.8 }));
newY = Math.max(BOUNDS.minY, Math.min(BOUNDS.maxY, newY));
}
return { x: newX, y: newY };
});
// Apply friction
setVelocity(v => ({ x: v.x * 0.98, y: v.y * 0.98 }));
}, 16);
return () => {
subscription.remove();
clearInterval(interval);
};
}, [velocity]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: withSpring(position.x - BALL_SIZE / 2) },
{ translateY: withSpring(position.y - BALL_SIZE / 2) },
],
}));
return (
<View style={styles.container}>
<Animated.View style={[styles.ball, animatedStyle]} />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#1a1a1a' },
ball: { width: BALL_SIZE, height: BALL_SIZE, borderRadius: BALL_SIZE / 2, backgroundColor: '#667eea', position: 'absolute' },
});
Compass
import { useState, useEffect } from 'react';
import { View, Text, StyleSheet, Dimensions } from 'react-native';
import { Magnetometer } from 'expo-sensors';
const { width } = Dimensions.get('window');
export default function Compass() {
const [heading, setHeading] = useState(0);
useEffect(() => {
Magnetometer.setUpdateInterval(100);
const subscription = Magnetometer.addListener(data => {
// Calculate heading from magnetometer data
let angle = Math.atan2(data.y, data.x) * (180 / Math.PI);
// Normalize to 0-360
if (angle < 0) angle += 360;
setHeading(angle);
});
return () => subscription.remove();
}, []);
const getDirection = (angle: number) => {
if (angle >= 337.5 || angle < 22.5) return 'N';
if (angle >= 22.5 && angle < 67.5) return 'NE';
if (angle >= 67.5 && angle < 112.5) return 'E';
if (angle >= 112.5 && angle < 157.5) return 'SE';
if (angle >= 157.5 && angle < 202.5) return 'S';
if (angle >= 202.5 && angle < 247.5) return 'SW';
if (angle >= 247.5 && angle < 292.5) return 'W';
return 'NW';
};
return (
<View style={styles.container}>
<View style={styles.compassContainer}>
<View style={[styles.compass, { transform: [{ rotate: `-${heading}deg` }] }]}>
<View style={styles.needleContainer}>
<View style={styles.needleNorth} />
<View style={styles.needleSouth} />
</View>
{/* Cardinal directions */}
<Text style={[styles.cardinal, styles.north]}>N</Text>
<Text style={[styles.cardinal, styles.south]}>S</Text>
<Text style={[styles.cardinal, styles.east]}>E</Text>
<Text style={[styles.cardinal, styles.west]}>W</Text>
</View>
{/* Fixed indicator */}
<View style={styles.indicator} />
</View>
<Text style={styles.heading}>{Math.round(heading)}°</Text>
<Text style={styles.direction}>{getDirection(heading)}</Text>
</View>
);
}
const COMPASS_SIZE = width - 80;
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#1a1a1a' },
compassContainer: { width: COMPASS_SIZE, height: COMPASS_SIZE, justifyContent: 'center', alignItems: 'center' },
compass: { width: COMPASS_SIZE, height: COMPASS_SIZE, borderRadius: COMPASS_SIZE / 2, backgroundColor: '#333', borderWidth: 4, borderColor: '#555', justifyContent: 'center', alignItems: 'center' },
needleContainer: { position: 'absolute', width: 10, height: COMPASS_SIZE * 0.7, alignItems: 'center' },
needleNorth: { width: 10, height: '50%', backgroundColor: '#f44336', borderTopLeftRadius: 5, borderTopRightRadius: 5 },
needleSouth: { width: 10, height: '50%', backgroundColor: '#ccc', borderBottomLeftRadius: 5, borderBottomRightRadius: 5 },
cardinal: { position: 'absolute', fontSize: 20, fontWeight: 'bold', color: '#888' },
north: { top: 20, color: '#f44336' },
south: { bottom: 20 },
east: { right: 20 },
west: { left: 20 },
indicator: { position: 'absolute', top: -10, width: 0, height: 0, borderLeftWidth: 10, borderRightWidth: 10, borderBottomWidth: 20, borderLeftColor: 'transparent', borderRightColor: 'transparent', borderBottomColor: '#667eea' },
heading: { marginTop: 40, fontSize: 48, fontWeight: 'bold', color: 'white' },
direction: { fontSize: 24, color: '#888' },
});
Hands-On Exercises
Exercise 1: Step Counter Widget
Build a step counter widget that shows daily progress toward a goal.
Requirements:
- Display today's step count
- Show a circular progress indicator
- Allow setting a daily goal
- Play haptic feedback when goal is reached
Show Hint
Use Pedometer.getStepCountAsync() with today's date range. Create a circular progress using SVG or a library. Store the goal in AsyncStorage. Use Haptics.notificationAsync(Success) when goal is reached.
Show Solution
import { useState, useEffect, useRef } from 'react';
import { View, Text, Pressable, StyleSheet, TextInput, Modal } from 'react-native';
import { Pedometer } from 'expo-sensors';
import * as Haptics from 'expo-haptics';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Svg, { Circle } from 'react-native-svg';
const GOAL_KEY = 'stepGoal';
const DEFAULT_GOAL = 10000;
export default function StepCounterWidget() {
const [steps, setSteps] = useState(0);
const [goal, setGoal] = useState(DEFAULT_GOAL);
const [showGoalModal, setShowGoalModal] = useState(false);
const [newGoal, setNewGoal] = useState('');
const goalReachedRef = useRef(false);
useEffect(() => {
loadGoal();
loadTodaySteps();
const subscription = Pedometer.watchStepCount(result => {
loadTodaySteps();
});
return () => subscription.remove();
}, []);
useEffect(() => {
if (steps >= goal && !goalReachedRef.current) {
goalReachedRef.current = true;
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else if (steps < goal) {
goalReachedRef.current = false;
}
}, [steps, goal]);
const loadGoal = async () => {
const saved = await AsyncStorage.getItem(GOAL_KEY);
if (saved) setGoal(parseInt(saved));
};
const loadTodaySteps = async () => {
const available = await Pedometer.isAvailableAsync();
if (!available) return;
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const result = await Pedometer.getStepCountAsync(start, now);
setSteps(result.steps);
};
const saveGoal = async () => {
const parsed = parseInt(newGoal);
if (parsed > 0) {
setGoal(parsed);
await AsyncStorage.setItem(GOAL_KEY, parsed.toString());
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
setShowGoalModal(false);
setNewGoal('');
};
const progress = Math.min(steps / goal, 1);
const circumference = 2 * Math.PI * 90;
const strokeDashoffset = circumference * (1 - progress);
return (
<View style={styles.container}>
<Pressable onPress={() => setShowGoalModal(true)}>
<Svg width={220} height={220} viewBox="0 0 200 200">
<Circle cx="100" cy="100" r="90" stroke="#333" strokeWidth="12" fill="none" />
<Circle
cx="100" cy="100" r="90"
stroke={progress >= 1 ? '#4CAF50' : '#667eea'}
strokeWidth="12" fill="none"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
transform="rotate(-90 100 100)"
/>
</Svg>
<View style={styles.centerContent}>
<Text style={styles.steps}>{steps.toLocaleString()}</Text>
<Text style={styles.label}>steps</Text>
<Text style={styles.goal}>Goal: {goal.toLocaleString()}</Text>
</View>
</Pressable>
<Modal visible={showGoalModal} transparent animationType="fade">
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Set Daily Goal</Text>
<TextInput
style={styles.input}
keyboardType="number-pad"
placeholder="Enter step goal"
value={newGoal}
onChangeText={setNewGoal}
/>
<View style={styles.modalButtons}>
<Pressable style={styles.cancelButton} onPress={() => setShowGoalModal(false)}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
<Pressable style={styles.saveButton} onPress={saveGoal}>
<Text style={styles.saveText}>Save</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#1a1a1a' },
centerContent: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center' },
steps: { fontSize: 42, fontWeight: 'bold', color: 'white' },
label: { fontSize: 16, color: '#888' },
goal: { fontSize: 14, color: '#666', marginTop: 8 },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', alignItems: 'center' },
modalContent: { backgroundColor: 'white', padding: 24, borderRadius: 16, width: 280 },
modalTitle: { fontSize: 20, fontWeight: '600', marginBottom: 16 },
input: { borderWidth: 1, borderColor: '#ddd', borderRadius: 8, padding: 12, fontSize: 16, marginBottom: 16 },
modalButtons: { flexDirection: 'row', justifyContent: 'flex-end' },
cancelButton: { padding: 12 },
cancelText: { color: '#666' },
saveButton: { backgroundColor: '#667eea', padding: 12, borderRadius: 8, marginLeft: 8 },
saveText: { color: 'white', fontWeight: '600' },
});
Exercise 2: Dice Roller
Build a dice roller that rolls when you shake the device.
Requirements:
- Display a die face (1-6)
- Roll on shake or button tap
- Animate the roll with random values
- Play haptic feedback during roll and when stopped
Show Hint
Use the shake detection pattern from earlier. Animate by rapidly changing the die value for a brief period before settling. Use Haptics.selectionAsync() during roll and Haptics.impactAsync(Heavy) when stopped.
Show Solution
import { useState, useEffect, useRef } from 'react';
import { View, Text, Pressable, StyleSheet, Animated } from 'react-native';
import { Accelerometer } from 'expo-sensors';
import * as Haptics from 'expo-haptics';
const DICE_FACES = ['ā', 'ā', 'ā', 'ā', 'ā', 'ā
'];
const SHAKE_THRESHOLD = 1.8;
export default function DiceRoller() {
const [dieValue, setDieValue] = useState(1);
const [isRolling, setIsRolling] = useState(false);
const shakeTimeRef = useRef(0);
const scaleAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
Accelerometer.setUpdateInterval(100);
const subscription = Accelerometer.addListener(({ x, y, z }) => {
const acceleration = Math.sqrt(x * x + y * y + z * z);
if (acceleration > SHAKE_THRESHOLD && !isRolling) {
const now = Date.now();
if (now - shakeTimeRef.current > 1000) {
shakeTimeRef.current = now;
rollDice();
}
}
});
return () => subscription.remove();
}, [isRolling]);
const rollDice = async () => {
if (isRolling) return;
setIsRolling(true);
// Animate scale
Animated.sequence([
Animated.timing(scaleAnim, { toValue: 0.8, duration: 100, useNativeDriver: true }),
Animated.timing(scaleAnim, { toValue: 1.1, duration: 100, useNativeDriver: true }),
Animated.timing(scaleAnim, { toValue: 1, duration: 100, useNativeDriver: true }),
]).start();
// Roll animation
const rolls = 15;
for (let i = 0; i < rolls; i++) {
await new Promise(resolve => setTimeout(resolve, 50 + i * 10));
setDieValue(Math.floor(Math.random() * 6) + 1);
Haptics.selectionAsync();
}
// Final result
const finalValue = Math.floor(Math.random() * 6) + 1;
setDieValue(finalValue);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
setIsRolling(false);
};
return (
<View style={styles.container}>
<Text style={styles.title}>š² Shake to Roll!</Text>
<Pressable onPress={rollDice} disabled={isRolling}>
<Animated.View style={[styles.dieContainer, { transform: [{ scale: scaleAnim }] }]}>
<Text style={styles.die}>{DICE_FACES[dieValue - 1]}</Text>
</Animated.View>
</Pressable>
<Text style={styles.value}>{dieValue}</Text>
<Pressable style={styles.button} onPress={rollDice} disabled={isRolling}>
<Text style={styles.buttonText}>
{isRolling ? 'Rolling...' : 'Tap to Roll'}
</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#1a1a1a' },
title: { fontSize: 24, fontWeight: 'bold', color: 'white', marginBottom: 40 },
dieContainer: { width: 150, height: 150, backgroundColor: 'white', borderRadius: 20, justifyContent: 'center', alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 8 },
die: { fontSize: 100 },
value: { fontSize: 48, fontWeight: 'bold', color: 'white', marginTop: 20 },
button: { marginTop: 40, backgroundColor: '#667eea', paddingHorizontal: 40, paddingVertical: 16, borderRadius: 30 },
buttonText: { color: 'white', fontSize: 18, fontWeight: '600' },
});
Exercise 3: Motion-Controlled Game
Create a simple game where you tilt the device to move a character to collect items.
Requirements:
- Tilt device to move a character
- Randomly spawn collectible items
- Track score
- Haptic feedback on collection
Show Hint
Use accelerometer x/y values to update character position. Use setInterval to spawn items at random positions. Check collision by comparing distances. Use Haptics.impactAsync(Light) on collection.
Show Solution
import { useState, useEffect, useRef } from 'react';
import { View, Text, StyleSheet, Dimensions } from 'react-native';
import { Accelerometer } from 'expo-sensors';
import * as Haptics from 'expo-haptics';
const { width, height } = Dimensions.get('window');
const PLAYER_SIZE = 40;
const ITEM_SIZE = 30;
const COLLECT_DISTANCE = 35;
interface Item {
id: number;
x: number;
y: number;
}
export default function TiltGame() {
const [playerPos, setPlayerPos] = useState({ x: width / 2, y: height / 2 });
const [items, setItems] = useState<Item[]>([]);
const [score, setScore] = useState(0);
const itemIdRef = useRef(0);
useEffect(() => {
Accelerometer.setUpdateInterval(16);
const subscription = Accelerometer.addListener(({ x, y }) => {
setPlayerPos(prev => ({
x: Math.max(PLAYER_SIZE/2, Math.min(width - PLAYER_SIZE/2, prev.x + x * 8)),
y: Math.max(PLAYER_SIZE/2 + 100, Math.min(height - PLAYER_SIZE/2 - 100, prev.y - y * 8)),
}));
});
// Spawn items
const spawnInterval = setInterval(() => {
const newItem: Item = {
id: itemIdRef.current++,
x: ITEM_SIZE + Math.random() * (width - ITEM_SIZE * 2),
y: ITEM_SIZE + 100 + Math.random() * (height - ITEM_SIZE * 2 - 200),
};
setItems(prev => [...prev.slice(-9), newItem]);
}, 1500);
return () => {
subscription.remove();
clearInterval(spawnInterval);
};
}, []);
// Check collisions
useEffect(() => {
items.forEach(item => {
const distance = Math.sqrt(
Math.pow(playerPos.x - item.x, 2) +
Math.pow(playerPos.y - item.y, 2)
);
if (distance < COLLECT_DISTANCE) {
setItems(prev => prev.filter(i => i.id !== item.id));
setScore(prev => prev + 1);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
});
}, [playerPos, items]);
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.score}>Score: {score}</Text>
</View>
{/* Items */}
{items.map(item => (
<View
key={item.id}
style={[styles.item, { left: item.x - ITEM_SIZE/2, top: item.y - ITEM_SIZE/2 }]}
>
<Text style={styles.itemEmoji}>ā</Text>
</View>
))}
{/* Player */}
<View
style={[
styles.player,
{ left: playerPos.x - PLAYER_SIZE/2, top: playerPos.y - PLAYER_SIZE/2 }
]}
>
<Text style={styles.playerEmoji}>š</Text>
</View>
<View style={styles.footer}>
<Text style={styles.instructions}>Tilt to move and collect stars!</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#1a1a2e' },
header: { position: 'absolute', top: 60, left: 0, right: 0, alignItems: 'center', zIndex: 10 },
score: { fontSize: 24, fontWeight: 'bold', color: 'white' },
player: { position: 'absolute', width: PLAYER_SIZE, height: PLAYER_SIZE, justifyContent: 'center', alignItems: 'center' },
playerEmoji: { fontSize: 32 },
item: { position: 'absolute', width: ITEM_SIZE, height: ITEM_SIZE, justifyContent: 'center', alignItems: 'center' },
itemEmoji: { fontSize: 24 },
footer: { position: 'absolute', bottom: 60, left: 0, right: 0, alignItems: 'center' },
instructions: { color: '#666', fontSize: 14 },
});
Summary
Sensors and haptics add a new dimension to mobile apps, enabling interactive and immersive experiences. Use them thoughtfully to enhance your app without draining the battery.
šÆ Key Takeaways
- Accelerometer: Measures linear acceleration including gravity (x, y, z)
- Gyroscope: Measures rotation rate (angular velocity)
- Device Motion: Combined, processed sensor data with gravity separated
- Pedometer: Energy-efficient step counting with historical data
- Magnetometer: Compass heading for direction apps
- Shake detection: Monitor acceleration magnitude with debouncing
- Haptic feedback: Impact, notification, and selection types
- Battery awareness: Higher update rates = more battery drain
- Always cleanup: Remove sensor subscriptions on unmount
- Check availability: Not all sensors exist on all devices
In the next lesson, we'll explore sharing content and linking to other apps and URLs.