Practice and reinforce the concepts from Lesson 2
Build a location-based app that displays multiple custom markers on a map with clustering for performance, custom callouts with detailed information, and interactive overlays to highlight specific areas.
A restaurant finder app that shows nearby restaurants with custom food icons as markers, clusters them when zoomed out, displays detailed information in callouts, and highlights delivery zones with circular overlays.

Create a data file for your restaurant locations:
// data/restaurants.js
export const restaurants = [
{
id: '1',
name: 'Pizza Palace',
type: 'Italian',
rating: 4.5,
deliveryTime: '25-35',
coordinate: {
latitude: 37.78825,
longitude: -122.4324,
},
deliveryRadius: 2000, // meters
},
{
id: '2',
name: 'Sushi Supreme',
type: 'Japanese',
rating: 4.8,
deliveryTime: '30-40',
coordinate: {
latitude: 37.79025,
longitude: -122.4344,
},
deliveryRadius: 1500,
},
{
id: '3',
name: 'Burger Boss',
type: 'American',
rating: 4.2,
deliveryTime: '20-30',
coordinate: {
latitude: 37.78625,
longitude: -122.4384,
},
deliveryRadius: 2500,
},
{
id: '4',
name: 'Taco Fiesta',
type: 'Mexican',
rating: 4.6,
deliveryTime: '15-25',
coordinate: {
latitude: 37.78925,
longitude: -122.4304,
},
deliveryRadius: 1800,
},
{
id: '5',
name: 'Thai Delight',
type: 'Thai',
rating: 4.7,
deliveryTime: '30-45',
coordinate: {
latitude: 37.78725,
longitude: -122.4364,
},
deliveryRadius: 2200,
},
];
export const getMarkerIcon = (type) => {
const icons = {
Italian: '🍕',
Japanese: '🍣',
American: '🍔',
Mexican: '🌮',
Thai: '🍜',
};
return icons[type] || '🍽️';
};
Build a reusable custom marker with image support:
// components/CustomMarker.js
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Marker } from 'react-native-maps';
const CustomMarker = ({ restaurant, onPress, isSelected }) => {
return (
<Marker
coordinate={restaurant.coordinate}
onPress={() => onPress(restaurant)}
tracksViewChanges={false} // Performance optimization
>
<View style={[
styles.markerContainer,
isSelected && styles.selectedMarker
]}>
<Text style={styles.markerIcon}>
{getMarkerIcon(restaurant.type)}
</Text>
{restaurant.rating >= 4.5 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>★</Text>
</View>
)}
</View>
</Marker>
);
};
const styles = StyleSheet.create({
markerContainer: {
backgroundColor: 'white',
padding: 8,
borderRadius: 20,
borderWidth: 2,
borderColor: '#FF6B6B',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 3,
elevation: 5,
},
selectedMarker: {
borderColor: '#4ECDC4',
borderWidth: 3,
transform: [{ scale: 1.2 }],
},
markerIcon: {
fontSize: 24,
},
badge: {
position: 'absolute',
top: -5,
right: -5,
backgroundColor: '#FFD700',
borderRadius: 10,
width: 20,
height: 20,
justifyContent: 'center',
alignItems: 'center',
},
badgeText: {
fontSize: 12,
color: 'white',
},
});
export default CustomMarker;
Add clustering logic to group nearby markers when zoomed out:
// App.js
import React, { useState } from 'react';
import { StyleSheet, View } from 'react-native';
import MapView, { Circle } from 'react-native-maps';
import CustomMarker from './components/CustomMarker';
import { restaurants } from './data/restaurants';
export default function App() {
const [selectedRestaurant, setSelectedRestaurant] = useState(null);
const [region, setRegion] = useState({
latitude: 37.78825,
longitude: -122.4324,
latitudeDelta: 0.02,
longitudeDelta: 0.02,
});
// Calculate if markers should cluster based on zoom level
const shouldCluster = region.latitudeDelta > 0.05;
const clusterMarkers = (markers) => {
if (!shouldCluster) return markers;
const clustered = [];
const processed = new Set();
markers.forEach((marker, index) => {
if (processed.has(index)) return;
const cluster = {
...marker,
clusteredMarkers: [marker],
};
markers.forEach((other, otherIndex) => {
if (index === otherIndex || processed.has(otherIndex)) return;
const distance = getDistance(
marker.coordinate,
other.coordinate
);
// Cluster markers within 500 meters
if (distance < 500) {
cluster.clusteredMarkers.push(other);
processed.add(otherIndex);
}
});
processed.add(index);
clustered.push(cluster);
});
return clustered;
};
const getDistance = (coord1, coord2) => {
const R = 6371e3; // Earth radius in meters
const lat1 = coord1.latitude * Math.PI / 180;
const lat2 = coord2.latitude * Math.PI / 180;
const deltaLat = (coord2.latitude - coord1.latitude) * Math.PI / 180;
const deltaLon = (coord2.longitude - coord1.longitude) * Math.PI / 180;
const a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
const clusteredRestaurants = clusterMarkers(restaurants);
return (
<View style={styles.container}>
<MapView
style={styles.map}
region={region}
onRegionChangeComplete={setRegion}
>
{clusteredRestaurants.map((restaurant) => {
const isCluster = restaurant.clusteredMarkers.length > 1;
if (isCluster) {
return (
<Marker
key={restaurant.id}
coordinate={restaurant.coordinate}
onPress={() => {
// Zoom in to see individual markers
setRegion({
...restaurant.coordinate,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
});
}}
>
<View style={styles.clusterMarker}>
<Text style={styles.clusterText}>
{restaurant.clusteredMarkers.length}
</Text>
</View>
</Marker>
);
}
return (
<CustomMarker
key={restaurant.id}
restaurant={restaurant}
onPress={setSelectedRestaurant}
isSelected={selectedRestaurant?.id === restaurant.id}
/>
);
})}
{/* Show delivery radius for selected restaurant */}
{selectedRestaurant && (
<Circle
center={selectedRestaurant.coordinate}
radius={selectedRestaurant.deliveryRadius}
fillColor="rgba(78, 205, 196, 0.2)"
strokeColor="rgba(78, 205, 196, 0.5)"
strokeWidth={2}
/>
)}
</MapView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
map: {
width: '100%',
height: '100%',
},
clusterMarker: {
backgroundColor: '#FF6B6B',
borderRadius: 25,
width: 50,
height: 50,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 3,
borderColor: 'white',
},
clusterText: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
},
});
💡 Tip: The
tracksViewChanges={false}prop on markers significantly improves performance by preventing re-renders when the marker content doesn't change.
Create an informative callout that appears when a marker is tapped:
// components/RestaurantCallout.js
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Callout } from 'react-native-maps';
const RestaurantCallout = ({ restaurant, onOrderPress }) => {
return (
<Callout
style={styles.callout}
onPress={() => onOrderPress(restaurant)}
>
<View style={styles.calloutContainer}>
<Text style={styles.name}>{restaurant.name}</Text>
<Text style={styles.type}>{restaurant.type}</Text>
<View style={styles.infoRow}>
<Text style={styles.rating}>⭐ {restaurant.rating}</Text>
<Text style={styles.delivery}>🕐 {restaurant.deliveryTime} min</Text>
</View>
<TouchableOpacity style={styles.orderButton}>
<Text style={styles.orderText}>View Menu</Text>
</TouchableOpacity>
</View>
</Callout>
);
};
const styles = StyleSheet.create({
callout: {
width: 200,
},
calloutContainer: {
padding: 10,
},
name: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 4,
},
type: {
fontSize: 14,
color: '#666',
marginBottom: 8,
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 10,
},
rating: {
fontSize: 14,
},
delivery: {
fontSize: 14,
},
orderButton: {
backgroundColor: '#4ECDC4',
padding: 8,
borderRadius: 5,
alignItems: 'center',
},
orderText: {
color: 'white',
fontWeight: 'bold',
},
});
export default RestaurantCallout;
Update your CustomMarker to include the callout:
// Add inside CustomMarker component
<Marker
coordinate={restaurant.coordinate}
onPress={() => onPress(restaurant)}
tracksViewChanges={false}
>
<View style={[styles.markerContainer, isSelected && styles.selectedMarker]}>
{/* existing marker content */}
</View>
<RestaurantCallout restaurant={restaurant} onOrderPress={onPress} />
</Marker>
Create a bottom sheet that shows detailed info when a marker is selected:
// components/BottomSheet.js
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Animated } from 'react-native';
const BottomSheet = ({ restaurant, onClose }) => {
if (!restaurant) return null;
return (
<Animated.View style={styles.container}>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeText}>×</Text>
</TouchableOpacity>
<Text style={styles.name}>{restaurant.name}</Text>
<Text style={styles.type}>{restaurant.type} Cuisine</Text>
<View style={styles.statsRow}>
<View style={styles.stat}>
<Text style={styles.statValue}>⭐ {restaurant.rating}</Text>
<Text style={styles.statLabel}>Rating</Text>
</View>
<View style={styles.stat}>
<Text style={styles.statValue}>{restaurant.deliveryTime}</Text>
<Text style={styles.statLabel}>Delivery</Text>
</View>
<View style={styles.stat}>
<Text style={styles.statValue}>
{(restaurant.deliveryRadius / 1000).toFixed(1)} km
</Text>
<Text style={styles.statLabel}>Radius</Text>
</View>
</View>
<TouchableOpacity style={styles.orderButton}>
<Text style={styles.orderButtonText}>Order Now</Text>
</TouchableOpacity>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'white',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: -3 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 10,
},
closeButton: {
position: 'absolute',
top: 10,
right: 10,
width: 30,
height: 30,
alignItems: 'center',
justifyContent: 'center',
},
closeText: {
fontSize: 30,
color: '#999',
},
name: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 5,
},
type: {
fontSize: 16,
color: '#666',
marginBottom: 20,
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: 20,
},
stat: {
alignItems: 'center',
},
statValue: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 5,
},
statLabel: {
fontSize: 12,
color: '#999',
},
orderButton: {
backgroundColor: '#4ECDC4',
padding: 15,
borderRadius: 10,
alignItems: 'center',
},
orderButtonText: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
},
});
export default BottomSheet;
Problem: Markers keep re-rendering and causing performance issues
Solution: Always set tracksViewChanges={false} on markers unless you need dynamic updates. This dramatically improves performance.
Problem: Callout doesn't appear when tapping marker Solution: Make sure the Callout component is a direct child of the Marker component. Also check that you're not blocking tap events with other views.
Problem: Clustering logic creates single-marker clusters Solution: Check your distance threshold calculation. Ensure you're using the same coordinate system and that the threshold makes sense for your zoom level.
Problem: Circle overlay is too faint to see Solution: Adjust the fillColor alpha channel (the last value in rgba). Values between 0.2 and 0.4 work well. Also increase strokeWidth for better visibility.
Problem: Custom marker images don't load
Solution: Use require() for local images and ensure paths are correct. For remote images, use React Native's Image component inside the marker.
For advanced students:
Animated Markers: Add bounce animation when markers are first rendered or selected using Animated API
Filter by Type: Add filter buttons at the top to show only specific cuisine types (Italian, Japanese, etc.)
Heatmap Overlay: Create a heatmap showing restaurant density using multiple overlapping circles with varying opacity
Custom Cluster Appearance: Make cluster markers show mini pie charts representing the cuisine types within the cluster
Directions Integration: Add a "Get Directions" button in the bottom sheet that opens Google Maps with navigation
In this activity, you:
In the next lesson, you'll explore the Places API to add search functionality. You'll learn how to implement autocomplete search, display search results, and integrate place details into your map interface.