Module 8: Native Features and Device APIs
Location Services
Getting position, tracking movement, and displaying maps
π― Learning Objectives
- Request and handle location permissions properly
- Get the user's current location with different accuracy levels
- Track location changes in real-time
- Understand foreground vs background location tracking
- Work with geocoding (addresses to coordinates)
- Display interactive maps with markers and regions
- Calculate distances between coordinates
Location Basics
Location services allow your app to determine where the user is in the world. This enables features like finding nearby places, navigation, fitness tracking, and location-based reminders.
Installation
# Install expo-location
npx expo install expo-location
# For maps (optional)
npx expo install react-native-maps
How Location Works
Mobile devices determine location using multiple sources:
β‘ Accuracy vs Battery Trade-off
| Accuracy | Use Case | Battery Impact |
|---|---|---|
| Highest | Navigation, fitness tracking | πππ High |
| High | Finding nearby places | ππ Medium |
| Balanced | City-level features | π Low |
| Low | Regional content | π Minimal |
Location Permissions
Location is one of the most privacy-sensitive permissions. Both iOS and Android have strict requirements for how apps request and use location.
Permission Types
import * as Location from 'expo-location';
// Foreground permission - location while app is open
const [foregroundPermission, requestForeground] = Location.useForegroundPermissions();
// Background permission - location when app is in background
const [backgroundPermission, requestBackground] = Location.useBackgroundPermissions();
β οΈ Important Permission Rules
- Foreground first: Always request foreground permission before background
- iOS options: "While Using" or "Always" - user chooses
- Android 10+: Background location is a separate permission
- App Store review: You must justify why you need background location
Requesting Foreground Permission
import { useState, useEffect } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import * as Location from 'expo-location';
export default function LocationPermissionDemo() {
const [permission, requestPermission] = Location.useForegroundPermissions();
const [location, setLocation] = useState<Location.LocationObject | null>(null);
const getLocation = async () => {
if (!permission?.granted) {
const result = await requestPermission();
if (!result.granted) {
return;
}
}
const currentLocation = await Location.getCurrentPositionAsync({});
setLocation(currentLocation);
};
return (
<View style={styles.container}>
<Text style={styles.status}>
Permission: {permission?.status ?? 'unknown'}
</Text>
{location && (
<Text style={styles.location}>
π {location.coords.latitude.toFixed(4)}, {location.coords.longitude.toFixed(4)}
</Text>
)}
<Pressable style={styles.button} onPress={getLocation}>
<Text style={styles.buttonText}>Get My Location</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
status: { fontSize: 16, marginBottom: 20 },
location: { fontSize: 18, fontWeight: '600', marginBottom: 20 },
button: { backgroundColor: '#667eea', paddingHorizontal: 30, paddingVertical: 15, borderRadius: 8 },
buttonText: { color: 'white', fontSize: 16, fontWeight: '600' },
});
Permission Configuration
// app.json
{
"expo": {
"plugins": [
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow MyApp to use your location to track your runs even when the app is in the background.",
"locationAlwaysPermission": "Allow MyApp to always access your location.",
"locationWhenInUsePermission": "Allow MyApp to access your location while using the app.",
"isAndroidBackgroundLocationEnabled": true,
"isAndroidForegroundServiceEnabled": true
}
]
],
"ios": {
"infoPlist": {
"NSLocationWhenInUseUsageDescription": "MyApp uses your location to show nearby restaurants.",
"NSLocationAlwaysAndWhenInUseUsageDescription": "MyApp uses your location to track your runs in the background.",
"UIBackgroundModes": ["location"]
}
},
"android": {
"permissions": [
"ACCESS_COARSE_LOCATION",
"ACCESS_FINE_LOCATION",
"ACCESS_BACKGROUND_LOCATION"
]
}
}
}
Getting Current Location
The most common use case is getting the user's current position once.
Basic Current Position
import * as Location from 'expo-location';
async function getCurrentLocation() {
// Request permission first
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
throw new Error('Location permission not granted');
}
// Get current position
const location = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.High,
});
return location;
// Returns:
// {
// coords: {
// latitude: 37.7749,
// longitude: -122.4194,
// altitude: 10.5,
// accuracy: 5,
// altitudeAccuracy: 10,
// heading: 90,
// speed: 0,
// },
// timestamp: 1234567890,
// }
}
Accuracy Options
import * as Location from 'expo-location';
// All accuracy levels
const accuracyLevels = {
// Most accurate, uses GPS, highest battery
highest: Location.Accuracy.Highest,
// ~10m accuracy
high: Location.Accuracy.High,
// ~100m accuracy, balanced
balanced: Location.Accuracy.Balanced,
// ~1km accuracy, low battery
low: Location.Accuracy.Low,
// ~3km accuracy, minimal battery
lowest: Location.Accuracy.Lowest,
// Best effort without GPS
bestForNavigation: Location.Accuracy.BestForNavigation,
};
// Usage
const location = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
Location with Timeout
async function getLocationWithTimeout(timeoutMs: number = 10000) {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Location timeout')), timeoutMs);
});
const locationPromise = Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.High,
});
return Promise.race([locationPromise, timeoutPromise]);
}
// Usage with error handling
try {
const location = await getLocationWithTimeout(5000);
console.log('Got location:', location.coords);
} catch (error) {
if (error.message === 'Location timeout') {
console.log('Location request timed out');
} else {
console.log('Location error:', error);
}
}
Last Known Location (Fast)
// Get last known location - instant but might be stale
async function getQuickLocation() {
// Try last known first (instant)
const lastKnown = await Location.getLastKnownPositionAsync();
if (lastKnown) {
const ageMs = Date.now() - lastKnown.timestamp;
const ageMinutes = ageMs / 1000 / 60;
// If less than 5 minutes old, use it
if (ageMinutes < 5) {
return lastKnown;
}
}
// Otherwise get fresh location
return Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
}
Custom Location Hook
import { useState, useEffect } from 'react';
import * as Location from 'expo-location';
interface UseLocationResult {
location: Location.LocationObject | null;
error: string | null;
loading: boolean;
refresh: () => Promise<void>;
}
export function useLocation(accuracy = Location.Accuracy.Balanced): UseLocationResult {
const [location, setLocation] = useState<Location.LocationObject | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const getLocation = async () => {
setLoading(true);
setError(null);
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
setError('Location permission denied');
return;
}
const currentLocation = await Location.getCurrentPositionAsync({ accuracy });
setLocation(currentLocation);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to get location');
} finally {
setLoading(false);
}
};
useEffect(() => {
getLocation();
}, []);
return { location, error, loading, refresh: getLocation };
}
// Usage
function MyComponent() {
const { location, error, loading, refresh } = useLocation();
if (loading) return <Text>Getting location...</Text>;
if (error) return <Text>Error: {error}</Text>;
return (
<View>
<Text>Lat: {location?.coords.latitude}</Text>
<Text>Lng: {location?.coords.longitude}</Text>
<Button title="Refresh" onPress={refresh} />
</View>
);
}
Location Tracking
For apps that need continuous location updates (fitness tracking, navigation), use location subscriptions.
Watch Position
import { useState, useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import * as Location from 'expo-location';
export default function LocationTracker() {
const [location, setLocation] = useState<Location.LocationObject | null>(null);
const [isTracking, setIsTracking] = useState(false);
useEffect(() => {
let subscription: Location.LocationSubscription | null = null;
const startTracking = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') return;
subscription = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.High,
timeInterval: 1000, // Update every 1 second
distanceInterval: 10, // Or when moved 10 meters
},
(newLocation) => {
setLocation(newLocation);
console.log('New location:', newLocation.coords);
}
);
setIsTracking(true);
};
startTracking();
// Cleanup on unmount
return () => {
if (subscription) {
subscription.remove();
}
};
}, []);
return (
<View style={styles.container}>
<Text style={styles.status}>
{isTracking ? 'π’ Tracking' : 'βͺ Not tracking'}
</Text>
{location && (
<View style={styles.locationInfo}>
<Text style={styles.coords}>
π {location.coords.latitude.toFixed(6)}, {location.coords.longitude.toFixed(6)}
</Text>
<Text style={styles.detail}>
Accuracy: Β±{location.coords.accuracy?.toFixed(0)}m
</Text>
<Text style={styles.detail}>
Speed: {((location.coords.speed ?? 0) * 3.6).toFixed(1)} km/h
</Text>
<Text style={styles.detail}>
Heading: {location.coords.heading?.toFixed(0)}Β°
</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 20 },
status: { fontSize: 18, textAlign: 'center', marginBottom: 20 },
locationInfo: { backgroundColor: '#f5f5f5', padding: 20, borderRadius: 12 },
coords: { fontSize: 16, fontWeight: '600', marginBottom: 12 },
detail: { fontSize: 14, color: '#666', marginBottom: 4 },
});
Track with Start/Stop Control
import { useState, useRef } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import * as Location from 'expo-location';
export default function ControllableTracker() {
const [locations, setLocations] = useState<Location.LocationObject[]>([]);
const [isTracking, setIsTracking] = useState(false);
const subscriptionRef = useRef<Location.LocationSubscription | null>(null);
const startTracking = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
alert('Location permission required');
return;
}
setLocations([]); // Reset locations
subscriptionRef.current = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.High,
timeInterval: 2000,
distanceInterval: 5,
},
(location) => {
setLocations(prev => [...prev, location]);
}
);
setIsTracking(true);
};
const stopTracking = () => {
if (subscriptionRef.current) {
subscriptionRef.current.remove();
subscriptionRef.current = null;
}
setIsTracking(false);
};
const calculateDistance = () => {
if (locations.length < 2) return 0;
let total = 0;
for (let i = 1; i < locations.length; i++) {
total += getDistanceMeters(
locations[i - 1].coords,
locations[i].coords
);
}
return total;
};
return (
<View style={styles.container}>
<Text style={styles.title}>GPS Tracker</Text>
<View style={styles.stats}>
<Text style={styles.stat}>Points: {locations.length}</Text>
<Text style={styles.stat}>Distance: {(calculateDistance() / 1000).toFixed(2)} km</Text>
</View>
<Pressable
style={[styles.button, isTracking ? styles.stopButton : styles.startButton]}
onPress={isTracking ? stopTracking : startTracking}
>
<Text style={styles.buttonText}>
{isTracking ? 'βΉ Stop Tracking' : 'βΆ Start Tracking'}
</Text>
</Pressable>
</View>
);
}
// Helper function (see Distance section for full implementation)
function getDistanceMeters(coord1: any, coord2: any): number {
const R = 6371e3; // Earth's radius in meters
const Ο1 = (coord1.latitude * Math.PI) / 180;
const Ο2 = (coord2.latitude * Math.PI) / 180;
const ΞΟ = ((coord2.latitude - coord1.latitude) * Math.PI) / 180;
const ΞΞ» = ((coord2.longitude - coord1.longitude) * Math.PI) / 180;
const a = Math.sin(ΞΟ / 2) ** 2 + Math.cos(Ο1) * Math.cos(Ο2) * Math.sin(ΞΞ» / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 20 },
title: { fontSize: 24, fontWeight: 'bold', textAlign: 'center', marginBottom: 30 },
stats: { backgroundColor: '#f5f5f5', padding: 20, borderRadius: 12, marginBottom: 30 },
stat: { fontSize: 18, marginBottom: 8 },
button: { padding: 20, borderRadius: 12, alignItems: 'center' },
startButton: { backgroundColor: '#4CAF50' },
stopButton: { backgroundColor: '#f44336' },
buttonText: { color: 'white', fontSize: 18, fontWeight: '600' },
});
Geocoding
Geocoding converts between addresses and coordinates. Expo Location provides built-in geocoding functions.
Reverse Geocoding (Coordinates β Address)
import * as Location from 'expo-location';
async function getAddressFromCoords(latitude: number, longitude: number) {
const results = await Location.reverseGeocodeAsync({
latitude,
longitude,
});
if (results.length > 0) {
const address = results[0];
console.log('Address:', address);
// {
// city: "San Francisco",
// country: "United States",
// district: "Mission District",
// isoCountryCode: "US",
// name: "123 Main St",
// postalCode: "94102",
// region: "California",
// street: "Main St",
// streetNumber: "123",
// subregion: "San Francisco County",
// timezone: "America/Los_Angeles",
// }
return address;
}
return null;
}
// Format as readable string
function formatAddress(address: Location.LocationGeocodedAddress): string {
const parts = [
address.streetNumber,
address.street,
address.city,
address.region,
address.postalCode,
].filter(Boolean);
return parts.join(', ');
}
Forward Geocoding (Address β Coordinates)
import * as Location from 'expo-location';
async function getCoordsFromAddress(address: string) {
const results = await Location.geocodeAsync(address);
if (results.length > 0) {
const { latitude, longitude } = results[0];
console.log('Coordinates:', latitude, longitude);
return { latitude, longitude };
}
return null;
}
// Usage
const coords = await getCoordsFromAddress('1600 Amphitheatre Parkway, Mountain View, CA');
// { latitude: 37.4220, longitude: -122.0841 }
Location with Address Component
import { useState, useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import * as Location from 'expo-location';
export default function CurrentLocationWithAddress() {
const [location, setLocation] = useState<Location.LocationObject | null>(null);
const [address, setAddress] = useState<Location.LocationGeocodedAddress | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
setLoading(false);
return;
}
// Get location
const loc = await Location.getCurrentPositionAsync({});
setLocation(loc);
// Get address
const addresses = await Location.reverseGeocodeAsync(loc.coords);
if (addresses.length > 0) {
setAddress(addresses[0]);
}
setLoading(false);
})();
}, []);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#667eea" />
<Text style={styles.loadingText}>Finding your location...</Text>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.icon}>π</Text>
{address && (
<View style={styles.addressCard}>
<Text style={styles.street}>{address.street} {address.streetNumber}</Text>
<Text style={styles.city}>{address.city}, {address.region} {address.postalCode}</Text>
<Text style={styles.country}>{address.country}</Text>
</View>
)}
{location && (
<Text style={styles.coords}>
{location.coords.latitude.toFixed(6)}, {location.coords.longitude.toFixed(6)}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
loadingText: { marginTop: 16, color: '#666' },
icon: { fontSize: 64, marginBottom: 20 },
addressCard: { backgroundColor: '#f5f5f5', padding: 20, borderRadius: 12, alignItems: 'center' },
street: { fontSize: 20, fontWeight: '600' },
city: { fontSize: 16, color: '#666', marginTop: 4 },
country: { fontSize: 14, color: '#999', marginTop: 4 },
coords: { marginTop: 20, fontSize: 12, color: '#999' },
});
Displaying Maps
The react-native-maps package provides native map components for iOS (Apple Maps) and Android (Google Maps).
Installation
# Install react-native-maps
npx expo install react-native-maps
Basic Map
import { View, StyleSheet } from 'react-native';
import MapView from 'react-native-maps';
export default function BasicMap() {
return (
<View style={styles.container}>
<MapView
style={styles.map}
initialRegion={{
latitude: 37.7749,
longitude: -122.4194,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
}}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
map: {
width: '100%',
height: '100%',
},
});
π‘ Understanding Delta Values
latitudeDelta and longitudeDelta control the zoom level:
- 0.01: Street level (~1km)
- 0.05: Neighborhood (~5km)
- 0.1: City area (~10km)
- 1.0: Region (~100km)
Map with Current Location
import { useState, useEffect, useRef } from 'react';
import { View, StyleSheet, Pressable, Text } from 'react-native';
import MapView, { Marker, Region } from 'react-native-maps';
import * as Location from 'expo-location';
export default function MapWithCurrentLocation() {
const [location, setLocation] = useState<Location.LocationObject | null>(null);
const mapRef = useRef<MapView>(null);
useEffect(() => {
(async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') return;
const loc = await Location.getCurrentPositionAsync({});
setLocation(loc);
})();
}, []);
const centerOnUser = () => {
if (location && mapRef.current) {
mapRef.current.animateToRegion({
latitude: location.coords.latitude,
longitude: location.coords.longitude,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
});
}
};
return (
<View style={styles.container}>
<MapView
ref={mapRef}
style={styles.map}
showsUserLocation={true}
showsMyLocationButton={false}
initialRegion={{
latitude: location?.coords.latitude ?? 37.7749,
longitude: location?.coords.longitude ?? -122.4194,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
}}
>
{location && (
<Marker
coordinate={{
latitude: location.coords.latitude,
longitude: location.coords.longitude,
}}
title="You are here"
description="Current location"
/>
)}
</MapView>
<Pressable style={styles.locationButton} onPress={centerOnUser}>
<Text style={styles.locationButtonText}>π</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
map: { flex: 1 },
locationButton: {
position: 'absolute',
bottom: 30,
right: 20,
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: 'white',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
locationButtonText: { fontSize: 24 },
});
Multiple Markers
import { View, StyleSheet } from 'react-native';
import MapView, { Marker, Callout } from 'react-native-maps';
import { Text } from 'react-native';
interface Place {
id: string;
name: string;
description: string;
latitude: number;
longitude: number;
}
const places: Place[] = [
{ id: '1', name: 'Golden Gate Bridge', description: 'Famous suspension bridge', latitude: 37.8199, longitude: -122.4783 },
{ id: '2', name: 'Alcatraz Island', description: 'Historic prison', latitude: 37.8267, longitude: -122.4230 },
{ id: '3', name: 'Fisherman\'s Wharf', description: 'Popular waterfront', latitude: 37.8080, longitude: -122.4177 },
];
export default function MapWithMarkers() {
return (
<View style={styles.container}>
<MapView
style={styles.map}
initialRegion={{
latitude: 37.8199,
longitude: -122.4500,
latitudeDelta: 0.1,
longitudeDelta: 0.1,
}}
>
{places.map((place) => (
<Marker
key={place.id}
coordinate={{
latitude: place.latitude,
longitude: place.longitude,
}}
title={place.name}
description={place.description}
pinColor="#667eea"
>
<Callout>
<View style={styles.callout}>
<Text style={styles.calloutTitle}>{place.name}</Text>
<Text style={styles.calloutDesc}>{place.description}</Text>
</View>
</Callout>
</Marker>
))}
</MapView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
map: { flex: 1 },
callout: { padding: 10, minWidth: 150 },
calloutTitle: { fontWeight: 'bold', fontSize: 14 },
calloutDesc: { color: '#666', marginTop: 4 },
});
Fit Map to Markers
import { useRef } from 'react';
import MapView, { Marker } from 'react-native-maps';
function MapFitToMarkers({ markers }) {
const mapRef = useRef<MapView>(null);
const fitToMarkers = () => {
if (mapRef.current && markers.length > 0) {
mapRef.current.fitToCoordinates(
markers.map(m => ({ latitude: m.latitude, longitude: m.longitude })),
{
edgePadding: { top: 50, right: 50, bottom: 50, left: 50 },
animated: true,
}
);
}
};
return (
<MapView
ref={mapRef}
style={{ flex: 1 }}
onMapReady={fitToMarkers}
>
{markers.map((marker, index) => (
<Marker
key={index}
coordinate={{ latitude: marker.latitude, longitude: marker.longitude }}
/>
))}
</MapView>
);
}
MapView Props Reference
πΊοΈ Common MapView Props
<MapView
// Region control
initialRegion={{ latitude, longitude, latitudeDelta, longitudeDelta }}
region={{ ... }} // Controlled region
onRegionChange={(region) => {}}
onRegionChangeComplete={(region) => {}}
// User location
showsUserLocation={true}
followsUserLocation={true}
showsMyLocationButton={true}
// Map type
mapType="standard" // 'standard' | 'satellite' | 'hybrid' | 'terrain'
// UI options
showsCompass={true}
showsScale={true}
showsTraffic={false}
showsBuildings={true}
showsIndoors={true}
// Interaction
zoomEnabled={true}
rotateEnabled={true}
scrollEnabled={true}
pitchEnabled={true}
// Events
onPress={(e) => console.log(e.nativeEvent.coordinate)}
onLongPress={(e) => {}}
onMarkerPress={(e) => {}}
onMapReady={() => {}}
/>
Distance Calculations
Calculating distance between two points on Earth requires accounting for the planet's curvature. The Haversine formula is the standard approach.
Haversine Formula
interface Coordinates {
latitude: number;
longitude: number;
}
/**
* Calculate distance between two coordinates using Haversine formula
* @returns Distance in meters
*/
function calculateDistance(coord1: Coordinates, coord2: Coordinates): number {
const R = 6371e3; // Earth's radius in meters
const lat1Rad = (coord1.latitude * Math.PI) / 180;
const lat2Rad = (coord2.latitude * Math.PI) / 180;
const deltaLat = ((coord2.latitude - coord1.latitude) * Math.PI) / 180;
const deltaLng = ((coord2.longitude - coord1.longitude) * Math.PI) / 180;
const a =
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
Math.cos(lat1Rad) * Math.cos(lat2Rad) *
Math.sin(deltaLng / 2) * Math.sin(deltaLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in meters
}
// Usage
const sanFrancisco = { latitude: 37.7749, longitude: -122.4194 };
const losAngeles = { latitude: 34.0522, longitude: -118.2437 };
const distanceMeters = calculateDistance(sanFrancisco, losAngeles);
const distanceKm = distanceMeters / 1000;
const distanceMiles = distanceMeters / 1609.34;
console.log(`Distance: ${distanceKm.toFixed(1)} km (${distanceMiles.toFixed(1)} miles)`);
Format Distance for Display
function formatDistance(meters: number): string {
if (meters < 1000) {
return `${Math.round(meters)}m`;
} else if (meters < 10000) {
return `${(meters / 1000).toFixed(1)}km`;
} else {
return `${Math.round(meters / 1000)}km`;
}
}
// Examples:
// formatDistance(500) β "500m"
// formatDistance(1500) β "1.5km"
// formatDistance(15000) β "15km"
Sort Places by Distance
interface Place {
id: string;
name: string;
latitude: number;
longitude: number;
}
function sortByDistance(
places: Place[],
userLocation: Coordinates
): (Place & { distance: number })[] {
return places
.map(place => ({
...place,
distance: calculateDistance(userLocation, {
latitude: place.latitude,
longitude: place.longitude,
}),
}))
.sort((a, b) => a.distance - b.distance);
}
// Usage
const userLocation = { latitude: 37.7749, longitude: -122.4194 };
const sortedPlaces = sortByDistance(places, userLocation);
sortedPlaces.forEach(place => {
console.log(`${place.name}: ${formatDistance(place.distance)}`);
});
Filter Places Within Radius
function placesWithinRadius(
places: Place[],
center: Coordinates,
radiusMeters: number
): Place[] {
return places.filter(place => {
const distance = calculateDistance(center, {
latitude: place.latitude,
longitude: place.longitude,
});
return distance <= radiusMeters;
});
}
// Get places within 5km
const nearbyPlaces = placesWithinRadius(places, userLocation, 5000);
Background Location
Background location allows your app to track location when it's not in the foreground. This is needed for fitness tracking, navigation, and location-based reminders.
β οΈ Important Considerations
- Battery drain: Background location significantly impacts battery life
- User trust: Users are wary of apps tracking them in the background
- App Store review: Apple requires justification for background location
- Android restrictions: Android 10+ requires separate permission
Request Background Permission
import * as Location from 'expo-location';
import * as TaskManager from 'expo-task-manager';
const LOCATION_TASK_NAME = 'background-location-task';
// Define the background task
TaskManager.defineTask(LOCATION_TASK_NAME, ({ data, error }) => {
if (error) {
console.error('Background location error:', error);
return;
}
if (data) {
const { locations } = data as { locations: Location.LocationObject[] };
console.log('Background locations:', locations);
// Process locations - save to storage, upload to server, etc.
locations.forEach(location => {
console.log(`Lat: ${location.coords.latitude}, Lng: ${location.coords.longitude}`);
});
}
});
async function startBackgroundTracking() {
// First get foreground permission
const { status: foregroundStatus } = await Location.requestForegroundPermissionsAsync();
if (foregroundStatus !== 'granted') {
console.log('Foreground permission denied');
return false;
}
// Then get background permission
const { status: backgroundStatus } = await Location.requestBackgroundPermissionsAsync();
if (backgroundStatus !== 'granted') {
console.log('Background permission denied');
return false;
}
// Start background location updates
await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, {
accuracy: Location.Accuracy.Balanced,
timeInterval: 10000, // Update every 10 seconds
distanceInterval: 50, // Or every 50 meters
foregroundService: {
notificationTitle: 'Location Tracking',
notificationBody: 'Tracking your location in the background',
notificationColor: '#667eea',
},
pausesUpdatesAutomatically: false,
showsBackgroundLocationIndicator: true, // iOS blue bar
});
return true;
}
async function stopBackgroundTracking() {
const isTracking = await Location.hasStartedLocationUpdatesAsync(LOCATION_TASK_NAME);
if (isTracking) {
await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME);
}
}
Check Background Status
async function checkBackgroundStatus() {
// Check if task is registered
const isRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME);
console.log('Task registered:', isRegistered);
// Check if updates are running
const isTracking = await Location.hasStartedLocationUpdatesAsync(LOCATION_TASK_NAME);
console.log('Currently tracking:', isTracking);
return { isRegistered, isTracking };
}
Background Location Component
import { useState, useEffect } from 'react';
import { View, Text, Pressable, StyleSheet, Alert } from 'react-native';
import * as Location from 'expo-location';
import * as TaskManager from 'expo-task-manager';
const TASK_NAME = 'background-location-task';
// Define task outside component
TaskManager.defineTask(TASK_NAME, ({ data, error }) => {
if (error) return;
const { locations } = data as { locations: Location.LocationObject[] };
// Handle locations - this runs in background
console.log('Got background locations:', locations.length);
});
export default function BackgroundLocationDemo() {
const [isTracking, setIsTracking] = useState(false);
const [hasPermission, setHasPermission] = useState(false);
useEffect(() => {
checkStatus();
}, []);
const checkStatus = async () => {
const tracking = await Location.hasStartedLocationUpdatesAsync(TASK_NAME);
setIsTracking(tracking);
const { status } = await Location.getBackgroundPermissionsAsync();
setHasPermission(status === 'granted');
};
const toggleTracking = async () => {
if (isTracking) {
await Location.stopLocationUpdatesAsync(TASK_NAME);
setIsTracking(false);
} else {
// Request permissions
const { status: fg } = await Location.requestForegroundPermissionsAsync();
if (fg !== 'granted') {
Alert.alert('Permission Required', 'Foreground location access is required');
return;
}
const { status: bg } = await Location.requestBackgroundPermissionsAsync();
if (bg !== 'granted') {
Alert.alert('Permission Required', 'Background location access is required');
return;
}
// Start tracking
await Location.startLocationUpdatesAsync(TASK_NAME, {
accuracy: Location.Accuracy.Balanced,
timeInterval: 30000,
distanceInterval: 100,
foregroundService: {
notificationTitle: 'Tracking Active',
notificationBody: 'Your location is being tracked',
},
});
setIsTracking(true);
setHasPermission(true);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>Background Location</Text>
<View style={styles.statusRow}>
<Text>Permission:</Text>
<Text style={hasPermission ? styles.granted : styles.denied}>
{hasPermission ? 'β Granted' : 'β Not granted'}
</Text>
</View>
<View style={styles.statusRow}>
<Text>Tracking:</Text>
<Text style={isTracking ? styles.granted : styles.denied}>
{isTracking ? 'π’ Active' : 'βͺ Inactive'}
</Text>
</View>
<Pressable
style={[styles.button, isTracking ? styles.stopButton : styles.startButton]}
onPress={toggleTracking}
>
<Text style={styles.buttonText}>
{isTracking ? 'Stop Tracking' : 'Start Background Tracking'}
</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 20 },
title: { fontSize: 24, fontWeight: 'bold', textAlign: 'center', marginBottom: 40 },
statusRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 12 },
granted: { color: '#4CAF50', fontWeight: '600' },
denied: { color: '#f44336', fontWeight: '600' },
button: { marginTop: 40, padding: 16, borderRadius: 8, alignItems: 'center' },
startButton: { backgroundColor: '#4CAF50' },
stopButton: { backgroundColor: '#f44336' },
buttonText: { color: 'white', fontSize: 16, fontWeight: '600' },
});
Hands-On Exercises
Exercise 1: Nearby Places Finder
Build a component that shows places near the user's current location.
Requirements:
- Get user's current location
- Display a list of sample places sorted by distance
- Show distance to each place
- Tapping a place shows it on a map
Show Hint
Use the sortByDistance function to order places. Use formatDistance for display. Pass coordinates to a MapView with a Marker when selecting a place.
Show Solution
import { useState, useEffect } from 'react';
import { View, Text, FlatList, Pressable, StyleSheet, Modal } from 'react-native';
import MapView, { Marker } from 'react-native-maps';
import * as Location from 'expo-location';
const SAMPLE_PLACES = [
{ id: '1', name: 'Coffee Shop', latitude: 37.7751, longitude: -122.4180 },
{ id: '2', name: 'Library', latitude: 37.7790, longitude: -122.4160 },
{ id: '3', name: 'Park', latitude: 37.7700, longitude: -122.4250 },
{ id: '4', name: 'Restaurant', latitude: 37.7800, longitude: -122.4100 },
{ id: '5', name: 'Gym', latitude: 37.7680, longitude: -122.4220 },
];
export default function NearbyPlacesFinder() {
const [location, setLocation] = useState<Location.LocationObject | null>(null);
const [places, setPlaces] = useState<any[]>([]);
const [selectedPlace, setSelectedPlace] = useState<any>(null);
useEffect(() => {
(async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') return;
const loc = await Location.getCurrentPositionAsync({});
setLocation(loc);
// Sort places by distance
const sorted = SAMPLE_PLACES.map(place => ({
...place,
distance: calculateDistance(
{ latitude: loc.coords.latitude, longitude: loc.coords.longitude },
{ latitude: place.latitude, longitude: place.longitude }
),
})).sort((a, b) => a.distance - b.distance);
setPlaces(sorted);
})();
}, []);
const formatDistance = (meters: number) => {
if (meters < 1000) return `${Math.round(meters)}m`;
return `${(meters / 1000).toFixed(1)}km`;
};
const calculateDistance = (c1: any, c2: any) => {
const R = 6371e3;
const p1 = (c1.latitude * Math.PI) / 180;
const p2 = (c2.latitude * Math.PI) / 180;
const dp = ((c2.latitude - c1.latitude) * Math.PI) / 180;
const dl = ((c2.longitude - c1.longitude) * Math.PI) / 180;
const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
return (
<View style={styles.container}>
<Text style={styles.title}>Nearby Places</Text>
<FlatList
data={places}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Pressable style={styles.placeRow} onPress={() => setSelectedPlace(item)}>
<Text style={styles.placeName}>{item.name}</Text>
<Text style={styles.placeDistance}>{formatDistance(item.distance)}</Text>
</Pressable>
)}
/>
<Modal visible={!!selectedPlace} animationType="slide">
<View style={styles.modalContainer}>
<Pressable style={styles.closeButton} onPress={() => setSelectedPlace(null)}>
<Text style={styles.closeText}>β Close</Text>
</Pressable>
{selectedPlace && (
<MapView
style={styles.map}
initialRegion={{
latitude: selectedPlace.latitude,
longitude: selectedPlace.longitude,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
>
<Marker coordinate={{ latitude: selectedPlace.latitude, longitude: selectedPlace.longitude }} title={selectedPlace.name} />
{location && <Marker coordinate={{ latitude: location.coords.latitude, longitude: location.coords.longitude }} title="You" pinColor="blue" />}
</MapView>
)}
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20 },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20 },
placeRow: { flexDirection: 'row', justifyContent: 'space-between', padding: 16, backgroundColor: '#f5f5f5', borderRadius: 8, marginBottom: 8 },
placeName: { fontSize: 16, fontWeight: '600' },
placeDistance: { color: '#667eea', fontWeight: '600' },
modalContainer: { flex: 1 },
closeButton: { padding: 16, backgroundColor: '#f5f5f5' },
closeText: { fontSize: 16, fontWeight: '600' },
map: { flex: 1 },
});
Exercise 2: Address Search with Map
Build an address search that geocodes input and shows the result on a map.
Requirements:
- Text input for address search
- Geocode the address when submitted
- Display the location on a map with a marker
- Show the formatted address below the map
Show Hint
Use Location.geocodeAsync() to convert the address to coordinates. Animate the map to the new coordinates using mapRef.current.animateToRegion().
Show Solution
import { useState, useRef } from 'react';
import { View, Text, TextInput, Pressable, StyleSheet, Keyboard } from 'react-native';
import MapView, { Marker } from 'react-native-maps';
import * as Location from 'expo-location';
export default function AddressSearch() {
const [address, setAddress] = useState('');
const [result, setResult] = useState<{ coords: any; formatted: string } | null>(null);
const [error, setError] = useState('');
const mapRef = useRef<MapView>(null);
const searchAddress = async () => {
Keyboard.dismiss();
setError('');
setResult(null);
try {
const geocoded = await Location.geocodeAsync(address);
if (geocoded.length === 0) {
setError('Address not found');
return;
}
const { latitude, longitude } = geocoded[0];
// Reverse geocode for formatted address
const reverse = await Location.reverseGeocodeAsync({ latitude, longitude });
const formatted = reverse[0]
? `${reverse[0].street} ${reverse[0].streetNumber}, ${reverse[0].city}, ${reverse[0].region}`
: address;
setResult({ coords: { latitude, longitude }, formatted });
// Animate map
mapRef.current?.animateToRegion({
latitude,
longitude,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
});
} catch (err) {
setError('Failed to search address');
}
};
return (
<View style={styles.container}>
<View style={styles.searchContainer}>
<TextInput
style={styles.input}
placeholder="Enter an address..."
value={address}
onChangeText={setAddress}
onSubmitEditing={searchAddress}
returnKeyType="search"
/>
<Pressable style={styles.searchButton} onPress={searchAddress}>
<Text style={styles.searchButtonText}>π</Text>
</Pressable>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
<MapView
ref={mapRef}
style={styles.map}
initialRegion={{
latitude: 37.7749,
longitude: -122.4194,
latitudeDelta: 0.1,
longitudeDelta: 0.1,
}}
>
{result && (
<Marker
coordinate={result.coords}
title="Search Result"
description={result.formatted}
/>
)}
</MapView>
{result && (
<View style={styles.resultCard}>
<Text style={styles.resultTitle}>π Found Location</Text>
<Text style={styles.resultAddress}>{result.formatted}</Text>
<Text style={styles.resultCoords}>
{result.coords.latitude.toFixed(6)}, {result.coords.longitude.toFixed(6)}
</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
searchContainer: { flexDirection: 'row', padding: 10, backgroundColor: '#f5f5f5' },
input: { flex: 1, backgroundColor: 'white', borderRadius: 8, padding: 12, fontSize: 16 },
searchButton: { marginLeft: 10, backgroundColor: '#667eea', width: 48, borderRadius: 8, justifyContent: 'center', alignItems: 'center' },
searchButtonText: { fontSize: 20 },
error: { padding: 10, color: '#f44336', textAlign: 'center' },
map: { flex: 1 },
resultCard: { padding: 16, backgroundColor: 'white', borderTopWidth: 1, borderTopColor: '#eee' },
resultTitle: { fontSize: 14, color: '#666' },
resultAddress: { fontSize: 16, fontWeight: '600', marginTop: 4 },
resultCoords: { fontSize: 12, color: '#999', marginTop: 4 },
});
Exercise 3: Running Tracker
Build a simple running tracker that records your route.
Requirements:
- Start/Stop tracking buttons
- Display total distance traveled
- Show elapsed time
- Draw the route on a map as a polyline
Show Hint
Use Location.watchPositionAsync() for continuous tracking. Store locations in an array. Use Polyline from react-native-maps to draw the route. Calculate total distance by summing distances between consecutive points.
Show Solution
import { useState, useRef, useEffect } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import MapView, { Polyline, Marker } from 'react-native-maps';
import * as Location from 'expo-location';
export default function RunningTracker() {
const [locations, setLocations] = useState<Location.LocationObject[]>([]);
const [isTracking, setIsTracking] = useState(false);
const [elapsedTime, setElapsedTime] = useState(0);
const subscriptionRef = useRef<Location.LocationSubscription | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const mapRef = useRef<MapView>(null);
useEffect(() => {
return () => {
subscriptionRef.current?.remove();
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
const startTracking = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') return;
setLocations([]);
setElapsedTime(0);
setIsTracking(true);
// Start timer
timerRef.current = setInterval(() => {
setElapsedTime(prev => prev + 1);
}, 1000);
// Start location tracking
subscriptionRef.current = await Location.watchPositionAsync(
{ accuracy: Location.Accuracy.High, timeInterval: 2000, distanceInterval: 5 },
(loc) => {
setLocations(prev => [...prev, loc]);
mapRef.current?.animateToRegion({
latitude: loc.coords.latitude,
longitude: loc.coords.longitude,
latitudeDelta: 0.005,
longitudeDelta: 0.005,
});
}
);
};
const stopTracking = () => {
subscriptionRef.current?.remove();
if (timerRef.current) clearInterval(timerRef.current);
setIsTracking(false);
};
const calculateDistance = () => {
if (locations.length < 2) return 0;
let total = 0;
for (let i = 1; i < locations.length; i++) {
const c1 = locations[i - 1].coords;
const c2 = locations[i].coords;
const R = 6371e3;
const p1 = (c1.latitude * Math.PI) / 180;
const p2 = (c2.latitude * Math.PI) / 180;
const dp = ((c2.latitude - c1.latitude) * Math.PI) / 180;
const dl = ((c2.longitude - c1.longitude) * Math.PI) / 180;
const a = Math.sin(dp/2)**2 + Math.cos(p1)*Math.cos(p2)*Math.sin(dl/2)**2;
total += R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
return total;
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const routeCoords = locations.map(l => ({ latitude: l.coords.latitude, longitude: l.coords.longitude }));
return (
<View style={styles.container}>
<MapView ref={mapRef} style={styles.map} showsUserLocation>
{routeCoords.length > 1 && <Polyline coordinates={routeCoords} strokeColor="#667eea" strokeWidth={4} />}
{routeCoords.length > 0 && <Marker coordinate={routeCoords[0]} pinColor="green" title="Start" />}
</MapView>
<View style={styles.stats}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{(calculateDistance() / 1000).toFixed(2)}</Text>
<Text style={styles.statLabel}>km</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatTime(elapsedTime)}</Text>
<Text style={styles.statLabel}>time</Text>
</View>
</View>
<Pressable
style={[styles.button, isTracking ? styles.stopButton : styles.startButton]}
onPress={isTracking ? stopTracking : startTracking}
>
<Text style={styles.buttonText}>{isTracking ? 'βΉ Stop' : 'βΆ Start Run'}</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
map: { flex: 1 },
stats: { flexDirection: 'row', justifyContent: 'space-around', padding: 20, backgroundColor: '#fff' },
statItem: { alignItems: 'center' },
statValue: { fontSize: 32, fontWeight: 'bold' },
statLabel: { fontSize: 14, color: '#666' },
button: { margin: 20, padding: 16, borderRadius: 12, alignItems: 'center' },
startButton: { backgroundColor: '#4CAF50' },
stopButton: { backgroundColor: '#f44336' },
buttonText: { color: 'white', fontSize: 18, fontWeight: '600' },
});
Summary
Location services are essential for many mobile app features. Remember to balance accuracy with battery life, request permissions appropriately, and respect user privacy.
π― Key Takeaways
- Permissions first: Always request and check permissions before accessing location
- Accuracy levels: Choose appropriate accuracy for your use case to save battery
- getCurrentPositionAsync: For one-time location needs
- watchPositionAsync: For continuous tracking with cleanup
- Geocoding: Convert between addresses and coordinates
- react-native-maps: Display interactive maps with markers
- Haversine formula: Calculate distances between coordinates
- Background location: Requires separate permission and careful justification
- Battery awareness: Higher accuracy = more battery drain
In the next lesson, we'll explore push notificationsβsending alerts to users even when your app isn't running.