Skip to main content

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

Device Sensors šŸ“ Accelerometer Linear acceleration šŸ”„ Gyroscope Rotation rate 🧭 Magnetometer Compass heading šŸŒ”ļø Barometer Air pressure šŸ“± Device Motion Combined sensor data 🚶 Pedometer Step counting šŸ’” Light Sensor Ambient light (Android)

šŸ“± 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

šŸ“± X Right + Y Up + Z Out of screen +

šŸ’” 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.