Maps in React Native: Adding interactive markers

Maps in React Native: Adding interactive markers

In this article, you'll learn how to add, customize and animate markers. We also cover how to implement a custom callout component

Introduction

In a previous article, we covered the initial setup process for a React Native project and explained how to configure a basic MapView component using the react-native-maps library. If you haven't read that article yet, I highly recommend you check it out.

This time, I’ll skip the project setup and basic MapView configuration and focus on adding markers, customizing their icons and callouts, implementing animations, and handling marker interactions.

Adding interactive markers

Markers are an essential component in react-native-maps that allow us to place points of interest on the map. Each marker can have various properties, such as coordinates (latitude and longitude), title, description, and even custom icons.

To add markers to the MapView, we first need to define their properties. Let's start by updating the App.tsx from the previous article.

import React from "react";
import { StyleSheet } from "react-native";
- import MapView, { PROVIDER_GOOGLE } from "react-native-maps";
+ import MapView, { Callout, Marker, PROVIDER_GOOGLE } from "react-native-maps";

export default function App() {
+ const markers = [
+   {
+     coordinate: {
+       latitude: -34.603851,
+       longitude: -58.381775,
+     },
+     title: "Obelisco",
+     description:
+       "The Obelisco is an iconic monument located in Buenos Aires, Argentina. Standing tall at the intersection of Avenida 9 de Julio and Avenida Corrientes, it serves as a symbol of the city and a tribute to its historical and cultural significance.",
+   },
+   {
+     coordinate: {
+       latitude: -34.6011,
+       longitude: -58.3835,
+     },
+     title: "Teatro Colón",
+     description:
+       "Teatro Colón, also known as the Colon Theatre, is a world-renowned opera house situated in Buenos Aires, Argentina. With its stunning architecture and rich history, it is considered one of the finest opera houses globally, hosting exceptional performances and captivating audiences with its grandeur.",
+   },
+   // Add more markers as needed
+ ];

+ const renderMarkers = () => {
+   return markers.map((marker, index) => (
+     <Marker
+       key={index}
+       coordinate={marker.coordinate}
+       title={marker.title}
+       description={marker.description}
+     />
+   ));
+ };

  return (
    <MapView
      provider={PROVIDER_GOOGLE} // Specify Google Maps as the provider
      style={styles.map}
      initialRegion={{
        latitude: -34.603738,
        longitude: -58.38157,
        latitudeDelta: 0.01,
        longitudeDelta: 0.01,
      }}
    >
+     {renderMarkers()}
    </MapView>
  );
}

const styles = StyleSheet.create({
  map: {
    flex: 1,
  },
});

A few things to mention about the code snippet above:

  • We updated our App.tsx to define an array of markers, each with its own coordinate, title, and description.
  • We then use the renderMarkers function to render a <Marker> component for each item in the markers array, passing in the corresponding properties.
  • By including the <Marker> components within the <MapView> component, the markers will be displayed on the map at their respective coordinates.

If everything went ok, you should see something like this:

Markers in react native maps with default callout

That’s fine for a basic scenario, but if you are like me, you may think that we could improve the UI for that “tooltip” bubble that appears after pressing a marker. That tooltip is called “Callout”, and react-native-maps includes a <Callout> component we can use to wrap our custom card, so let’s create a <CustomCallout> component!

Customizing the Callout component

  1. Before working on our new component, we’ll need to update our array of markers to add a new property called imageUrl. We’ll use this URL to display an Image for each point of interest. We are also adding a new type called MarkerWithMetadata to get better IntelliSense later in the Callout component.

    // App.tsx
    
    + export type MarkerWithMetadata = {
    +   coordinate: MapMarkerProps["coordinate"];
    +   title?: MapMarkerProps["title"];
    +   description?: MapMarkerProps["description"];
    +   imageUrl?: string;
    + };
    
    export default function App() {
    
     const markers: MarkerWithMetadata[] = [
       {
         coordinate: {
           latitude: -34.603851,
           longitude: -58.381775,
         },
         title: "Obelisco",
    +     imageUrl: "https://upload.wikimedia.org/wikipedia/commons/f/fc/Buenos_Aires_%2820234294752%29.jpg",
         description: "The Obelisco is an iconic monument located in Buenos Aires, Argentina. Standing tall at the intersection of Avenida 9 de Julio and Avenida Corrientes, it serves as a symbol of the city and a tribute to its historical and cultural significance.",
       },
       {
         coordinate: {
           latitude: -34.6011,
           longitude: -58.3835,
         },
    +     imageUrl: "https://upload.wikimedia.org/wikipedia/commons/1/1e/Buenos_Aires_Teatro_Colon_2.jpg",
         title: "Teatro Colón",
         description: "Teatro Colón, also known as the Colon Theatre, is a world-renowned opera house situated in Buenos Aires, Argentina. With its stunning architecture and rich history, it is considered one of the finest opera houses globally, hosting exceptional performances and captivating audiences with its grandeur.",
       },
       // Add more markers as needed
     ];
    
     ...
    }
    
  2. Create a new folder called components and an empty file inside for our new component by running the following command on your terminal

     mkdir components
     touch components/CustomCallout.tsx
    
  3. Now, open the CustomCallout file and add the following code

     import React from "react";
     import { View, StyleSheet, Dimensions, Image, Text } from "react-native";
     import { Callout } from "react-native-maps";
     import { MarkerWithMetadata } from "../App";
    
     const screenWidth = Dimensions.get("window").width;
    
     const CustomCallout: React.FC<{
       marker: MarkerWithMetadata;
     }> = ({ marker }) => {
       return (
         <Callout tooltip>
           <View>
             <View style={styles.container}>
               <Image
                 source={{
                   uri: marker.imageUrl,
                 }}
                 resizeMode="cover"
                 style={{ width: 100, height: "100%" }}
               ></Image>
               <View style={{ paddingHorizontal: 16, paddingVertical: 8, flex: 1 }}>
                 <Text
                   style={{
                     fontWeight: "bold",
                     fontSize: 18,
                   }}
                 >
                   {marker.title}
                 </Text>
    
                 <Text>{marker.description}</Text>
               </View>
             </View>
             <View style={styles.triangle}></View>
           </View>
         </Callout>
       );
     };
    
     const styles = StyleSheet.create({
       container: {
         backgroundColor: "white",
         width: screenWidth * 0.8,
         flexDirection: "row",
         borderWidth: 2,
         borderRadius: 12,
         overflow: "hidden",
       },
       triangle: {
         left: (screenWidth * 0.8) / 2 - 10,
         width: 0,
         height: 0,
         backgroundColor: "transparent",
         borderStyle: "solid",
         borderTopWidth: 20,
         borderRightWidth: 10,
         borderBottomWidth: 0,
         borderLeftWidth: 10,
         borderTopColor: "black",
         borderRightColor: "transparent",
         borderBottomColor: "transparent",
         borderLeftColor: "transparent",
       },
     });
    
     export default CustomCallout;
    

    A few things to mention:

    • We receive the marker with our custom type. This includes all the data we need to render our new card.
    • We are wrapping our UI with the <Callout> component from react-native-maps. We also pass the tooltip prop so we can take absolute control over the UI.
    • I also wanted to add a “tooltip” triangle below, so I included the styles for the triangle. Feel free to remove it if you want.
  4. Finally, we need to update the App.tsx file to render our custom callout

    // App.tsx
    
    ...
    + import CustomCallout from "./components/CustomCallout";
    
    export default function App() {
     const markers: MarkerWithMetadata[] = [
       ...
     ];
    
     const renderMarkers = () => {
       return markers.map((marker, index) => {
         return (
           <Marker 
             key={index} 
             coordinate={marker.coordinate}
    -         title={marker.title}
    -         description={marker.description}        
           >
    +         <CustomCallout marker={marker}></CustomCallout>
           </Marker>
         );
       });
     };
    
     return (
       ...
     );
    }
    

That’s it! You should now look something like this:

Markers in React Native Maps with custom callout

Using a custom marker

Another cool thing about react-native-maps is that you can customize the marker icon to suit your needs. In the library docs, you can find two props available within the <Marker> component: image and icon.

The differences between these two properties are not clear to me (except for the fact that icon only works with the google maps provider), and in both cases you can pass a local image resource like this:

// App.tsx

...
+ import MarkerIcon from "./assets/marker.png";

export default function App() {
  const markers: MarkerWithMetadata[] = [
    ...
  ];

  const renderMarkers = () => {
    return markers.map((marker, index) => {
      return (
        <Marker
+         image={MarkerIcon}
+         // You can also use icon with Google Maps
+         icon={MarkerIcon}
          key={new Date().getTime() + index}
          coordinate={marker.coordinate}
        >
          <CustomCallout marker={marker}></CustomCallout>
        </Marker>
      );
    });
  };

  return (
    ...
  );
}

But there’s a catch! Because these props expect an ImageSource, it’s a little bit limited. As example, I couldn’t find an easy way to change the size of the image.

If you want to go crazy and implement a complex marker, you can add a children component inside the <Marker>, and the rendered content will replace the marker symbol. In this example, I’m only using an Image component and customizing the background color and borders, but feel free to experiment:

+ import MarkerIcon from "./assets/marker.png";

export default function App() {
  const markers: MarkerWithMetadata[] = [
    ...
  ];

  const renderMarkers = () => {
    return markers.map((marker, index) => {
      return (
        <Marker
          key={new Date().getTime() + index}
          coordinate={marker.coordinate}
        >
+         <Image source={MarkerIcon} style={styles.marker}></Image>
          <CustomCallout marker={marker}></CustomCallout>
        </Marker>
      );
    });
  };

  return (
    ...
  );
}

const styles = StyleSheet.create({
  map: {
    flex: 1,
  },
+ marker: {
+   width: 60,
+   height: 60,
+   resizeMode: "contain",
+   backgroundColor: "yellow",
+   borderRadius: 30,
+   borderWidth: 2,
+ }
});

After these changes, your custom markers should look like this:

Markers in React Native Maps using a custom Marker

This is just a basic example, but the possibilities are endless. As example, if you are working on a food delivery app, you could design a custom icon to represent how expensive is each restaurant, like: "💲", "💲💲", "💲💲💲", etc. You can then use conditional rendering to display the right icon based on your marker metadata.

Adding animations to markers

Disclaimer: I’m not good at animations, but I thought it could be fun to add a little touch to the map. What if each marker would provide subtle feedback to the user when it’s tapped?

Let’s make some changes to our App.tsx file, then I’ll explain a few points.

- import { StyleSheet, Image } from "react-native";
+ import { StyleSheet, Image, Animated } from "react-native";
...

export type MarkerWithMetadata = {
+ id: number;
  coordinate: MapMarkerProps["coordinate"];
  title?: MapMarkerProps["title"];
  description?: MapMarkerProps["description"];
  imageUrl?: string;
};

export default function App() {
  const markers: MarkerWithMetadata[] = [
    {
+     id: 1,
      ...
    },
    {
+     id: 2,
      ...
    },
    // Add more markers as needed
  ];

+ const markerScales = React.useRef<{
+   [key: number]: Animated.Value;
+ }>({});

+ for (const marker of markers) {
+   markerScales.current[marker.id] = new Animated.Value(1);
+ }

+ const handleMarkerPress = (marker: MarkerWithMetadata) => {
+   const scale = markerScales.current[marker.id];
+   Animated.timing(scale, {
+     toValue: 1.25,
+     duration: 100,
+     useNativeDriver: false,
+   }).start(() => {
+     Animated.timing(scale, {
+       toValue: 1,
+       duration: 100,
+       useNativeDriver: false,
+     }).start();
+   });
+ };

  const renderMarkers = () => {
    return markers.map((marker) => {
      return (
        <Marker
+         key={marker.id}
          coordinate={marker.coordinate}
          onPress={() => handleMarkerPress(marker)}
        >
+         <Animated.View
+           style={{
+             padding: 10,
+             transform: [{ scale: markerScales.current[marker.id] }],
+           }}
          >
            <Image source={MarkerIcon} style={[styles.marker]}></Image>
+         </Animated.View>
          <CustomCallout marker={marker}></CustomCallout>
        </Marker>
      );
    });
  };

  return (
    ...
}

What’s happening here?

  • We are adding the id prop on each marker:
    • The id prop is added to each marker to uniquely identify them. This id will be used later to update the scale of the tapped marker.
  • We are also adding an object to track the scale of each marker:
    • The markerScales object is created using useRef to keep track of the scale value for each marker. It is initialized as an empty object.
  • We then handle the marker’s onPress event:
    • The handleMarkerPress function is called when a marker is pressed. It takes the marker object as a parameter, which contains the metadata for the pressed marker. Inside the function, an animation is triggered using Animated.timing to increase the scale of the marker to 1.25 over a duration of 100 milliseconds. Once this animation completes, a second animation is started to bring the marker's scale back to 1. This creates a visual effect of the marker briefly expanding and returning to its original size.
  • Finally, we are wrapping our markers with Animated.View:
    • Inside the renderMarkers function, an Animated.View is used to wrap the marker. The transform style property is applied to the Animated.View to scale the marker based on the corresponding markerScales.current[marker.id].scale value.

If everything went as expected, you should look something like this:

https://i.imgur.com/VopFUH8.gif

Handling marker interactions

As we saw in the previous example, the <Marker> component comes with an onPress event that allows you to run a custom logic every time a marker is pressed.

But react-native-maps also includes a few more things I would like to mention:

  • If you set draggable in your marker, you can also use onDragStart and onDragEnd to update your marker’s position.

    // App.tsx
    
    export default function App() {
      ...
    
      const renderMarkers = () => {
        return markers.map((marker) => {
          return (
            <Marker
              key={marker.id}
    +         draggable
    +         onDragEnd={(event) => {
    +           // use the new coordinates to update marker location
    +           const newCoordinate = event.nativeEvent.coordinate;
    +           marker.coordinate = newCoordinate;
              }}
              coordinate={marker.coordinate}
              onPress={() => handleMarkerPress(marker)}
            >
              ...
            </Marker>
          );
        });
      };
    
      return (
        ...
      );
    }
    

    https://i.imgur.com/3Da7jiX.gif

  • You can also handle the onPress event inside the Callout component like this

    // CustomCallout.tsx
    
    ...
    
    const CustomCallout: React.FC<{
      marker: MarkerWithMetadata;
    }> = ({ marker }) => {
      return (
        <Callout
          tooltip
    +     onPress={() => {
    +       Alert.alert(`${marker.title} pressed`);
    +     }}
        >
          ...
        </Callout>
      );
    };
    
    ...
    
    export default CustomCallout;
    

That’s it for this article. Please let me know if you find it useful. I’m planning to keep writing about Maps in React Native, so feel free to reach out if there is any specific topic you would want me to cover in the future.

To learn more about the <Marker> component, you can also visit the docs on GitHub

Happy coding!