Add a map to your Android app (Kotlin with Compose)

1. Before You Begin

This codelab teaches you how to integrate Maps SDK for Android with your app and use its core features by building an app that displays a map of mountains in Colorado, USA, using various types of markers. Additionally, you'll learn to draw other shapes on the map.

Here's what it will look like when you are finished with the codelab:

Prerequisites

What you'll do

  • Enable and use the Maps Compose library for the Maps SDK for Android to add a GoogleMap to an Android app
  • Add and customize markers
  • Draw polygons on the map
  • Control the viewpoint of the camera programmatically

What you'll need

2. Get set up

For the following enablement step, you need to enable Maps SDK for Android.

Set up Google Maps Platform

If you do not already have a Google Cloud Platform account and a project with billing enabled, please see the Getting Started with Google Maps Platform guide to create a billing account and a project.

  1. In the Cloud Console, click the project drop-down menu and select the project that you want to use for this codelab.

  1. Enable the Google Maps Platform APIs and SDKs required for this codelab in the Google Cloud Marketplace. To do so, follow the steps in this video or this documentation.
  2. Generate an API key in the Credentials page of Cloud Console. You can follow the steps in this video or this documentation. All requests to Google Maps Platform require an API key.

3. Quick start

To get you started as quickly as possible, here's some starter code to help you follow along with this codelab. You are welcome to jump to the solution, but if you want to follow along with all the steps to build it yourself, keep reading.

  1. Clone the repository if you have git installed.
git clone https://github.com/googlemaps-samples/codelab-maps-platform-101-compose.git

Alternatively, you can click the following button to download the source code.

  1. Upon getting the code, go ahead and open the project found inside the starter directory in Android Studio.

4. Add your API key to the project

This section describes how to store your API key so that it can be securely referenced by your app. You shouldn't check your API key into your version control system, so we recommend storing it in the secrets.properties file, which will be placed in your local copy of the root directory of your project. For more information about the secrets.properties file, see Gradle properties files.

To streamline this task, we recommend that you use the Secrets Gradle Plugin for Android.

To install the Secrets Gradle Plugin for Android in your Google Maps project:

  1. In Android Studio, open your top-level build.gradle.kts file and add the following code to the dependencies element under buildscript.
    buildscript {
        dependencies {
            classpath("com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1")
        }
    }
    
  2. Open your module-level build.gradle.kts file and add the following code to the plugins element.
    plugins {
        // ...
        id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
    }
    
  3. In your module-level build.gradle.kts file, ensure that targetSdk and compileSdk are set to at least 34.
  4. Save the file and sync your project with Gradle.
  5. Open the secrets.properties file in your top-level directory, and then add the following code. Replace YOUR_API_KEY with your API key. Store your key in this file because secrets.properties is excluded from being checked into a version control system.
    MAPS_API_KEY=YOUR_API_KEY
    
  6. Save the file.
  7. Create the local.defaults.properties file in your top-level directory, the same folder as the secrets.properties file, and then add the following code.
        MAPS_API_KEY=DEFAULT_API_KEY
    
    The purpose of this file is to provide a backup location for the API key if the secrets.properties file is not found so that builds don't fail. This will happen when you clone the app from a version control system and you have not yet created a secrets.properties file locally to provide your API key.
  8. Save the file.
  9. In your AndroidManifest.xml file, go to com.google.android.geo.API_KEY and update the android:value attribute. If the <meta-data> tag doesn't exist, create it as a child of the <application> tag.
        <meta-data
            android:name="com.google.android.geo.API_KEY"
            android:value="${MAPS_API_KEY}" />
    
  10. In Android Studio, open your module-level build.gradle.kts file and edit the secrets property. If the secrets property does not exist, add it.Edit the properties of the plugin to set propertiesFileName to secrets.properties, set defaultPropertiesFileName to local.defaults.properties, and set any other properties.
    secrets {
        // Optionally specify a different file name containing your secrets.
        // The plugin defaults to "local.properties"
        propertiesFileName = "secrets.properties"
    
        // A properties file containing default secret values. This file can be
        // checked in version control.
        defaultPropertiesFileName = "local.defaults.properties"
    }
    

5. Add Google Maps

In this section, you will add a Google Map so that it loads when you launch the app.

Add Maps Compose dependencies

Now that your API key can be accessed inside the app, the next step is to add the Maps SDK for Android dependency to your app's build.gradle.kts file. To build with Jetpack Compose, use the Maps Compose library that provides elements of the Maps SDK for Android as composable functions and data types.

build.gradle.kts

In the app level build.gradle.kts file replace the non-compose Maps SDK for Android dependencies:

dependencies {
    // ...

    // Google Maps SDK -- these are here for the data model.  Remove these dependencies and replace
    // with the compose versions.
    implementation("com.google.android.gms:play-services-maps:18.2.0")
    // KTX for the Maps SDK for Android library
    implementation("com.google.maps.android:maps-ktx:5.0.0")
    // KTX for the Maps SDK for Android Utility Library
    implementation("com.google.maps.android:maps-utils-ktx:5.0.0")
}

with their composable counterparts:

dependencies {
    // ...

    // Google Maps Compose library
    val mapsComposeVersion = "4.4.1"
    implementation("com.google.maps.android:maps-compose:$mapsComposeVersion")
    // Google Maps Compose utility library
    implementation("com.google.maps.android:maps-compose-utils:$mapsComposeVersion")
    // Google Maps Compose widgets library
    implementation("com.google.maps.android:maps-compose-widgets:$mapsComposeVersion")
}

Add a Google Map composable

In MountainMap.kt, add the GoogleMap composable inside the Box composable nested within the MapMountain composable.

import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.GoogleMapComposable
// ...

@Composable
fun MountainMap(
    paddingValues: PaddingValues,
    viewState: MountainsScreenViewState.MountainList,
    eventFlow: Flow<MountainsScreenEvent>,
    selectedMarkerType: MarkerType,
) {
    var isMapLoaded by remember { mutableStateOf(false) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues)
    ) {
        // Add GoogleMap here
        GoogleMap(
            modifier = Modifier.fillMaxSize(),
            onMapLoaded = { isMapLoaded = true }
        )

        // ...
    }
}

Now build and run the app. Behold! You should see a map centered on the notorious Null Island, also known as latitude zero and longitude zero. Later, you'll learn how to position the map to the location and zoom level that you want, but for now celebrate your first victory!

6. Cloud-based map styling

You can customize the style of your map using Cloud-based map styling.

Create a Map ID

If you have not yet created a map ID with a map style associated to it, see the Map IDs guide to complete the following steps:

  1. Create a map ID.
  2. Associate a map ID to a map style.

Add the Map ID to your app

To use the map ID you created, when instantiating your GoogleMap composable, use the map ID when creating a GoogleMapOptions object which is assigned to the googleMapOptionsFactory parameter in the constructor.

GoogleMap(
    // ...
    googleMapOptionsFactory = {
        GoogleMapOptions().mapId("MyMapId")
    }
)

Once you've completed this, go ahead and run the app to see your map in the style that you selected!

7. Load the marker data

The main task of the app is to load a collection of mountains from local storage and display those mountains in the GoogleMap. In this step, you'll take a tour of the provided infrastructure for loading the mountain data and presenting it to the UI.

Mountain

The Mountain data class holds all of the data about each mountain.

data class Mountain(
    val id: Int,
    val name: String,
    val location: LatLng,
    val elevation: Meters,
)

Note that the mountains will later be partitioned based on their elevation. Mountains that are at least 14,000 feet tall are called fourteeners. The starter code includes an extension function do this check for you.

/**
 * Extension function to determine whether a mountain is a "14er", i.e., has an elevation greater
 * than 14,000 feet (~4267 meters).
 */
fun Mountain.is14er() = elevation >= 14_000.feet

MountainsScreenViewState

The MountainsScreenViewState class holds all of the data needed to render the view. It can either be in a Loading or MountainList state depending on whether the list of mountains has finished loading.

/**
 * Sealed class representing the state of the mountain map view.
 */
sealed class MountainsScreenViewState {
  data object Loading : MountainsScreenViewState()
  data class MountainList(
    // List of the mountains to display
    val mountains: List<Mountain>,

    // Bounding box that contains all of the mountains
    val boundingBox: LatLngBounds,

    // Switch indicating whether all the mountains or just the 14ers
    val showingAllPeaks: Boolean = false,
  ) : MountainsScreenViewState()
}

Provided classes: MountainsRepository and MountainsViewModel

In the starter project, the class MountainsRepository has been provided for you. This class reads a list of mountains places that are stored in a GPS Exchange Format, or GPX file, top_peaks.gpx. Calling mountainsRepository.loadMountains() returns a StateFlow<List<Mountain>>.

MountainsRepository

class MountainsRepository(@ApplicationContext val context: Context) {
  private val _mountains = MutableStateFlow(emptyList<Mountain>())
  val mountains: StateFlow<List<Mountain>> = _mountains
  private var loaded = false

  /**
   * Loads the list of mountains from the list of mountains from the raw resource.
   */
  suspend fun loadMountains(): StateFlow<List<Mountain>> {
    if (!loaded) {
      loaded = true
      _mountains.value = withContext(Dispatchers.IO) {
        context.resources.openRawResource(R.raw.top_peaks).use { inputStream ->
          readMountains(inputStream)
        }
      }
    }
    return mountains
  }

  /**
   * Reads the [Waypoint]s from the given [inputStream] and returns a list of [Mountain]s.
   */
  private fun readMountains(inputStream: InputStream) =
    readWaypoints(inputStream).mapIndexed { index, waypoint ->
      waypoint.toMountain(index)
    }.toList()

  // ...
}

MountainsViewModel

MountainsViewModel is a ViewModel class which loads the collections of mountains and exposes that collections as well as other parts of the UI state via the mountainsScreenViewState. mountainsScreenViewState is a hot StateFlow that the UI can observe as a mutable state using the collectAsState extension function.

Following sound architectural principles, MountainsViewModel holds all the app's state. The UI sends user interactions to the view model using the onEvent method.

@HiltViewModel
class MountainsViewModel
@Inject
constructor(
  mountainsRepository: MountainsRepository
) : ViewModel() {
  private val _eventChannel = Channel<MountainsScreenEvent>()

  // Event channel to send events to the UI
  internal fun getEventChannel() = _eventChannel.receiveAsFlow()

  // Whether or not to show all of the high peaks
  private var showAllMountains = MutableStateFlow(false)

  val mountainsScreenViewState =
    mountainsRepository.mountains.combine(showAllMountains) { allMountains, showAllMountains ->
      if (allMountains.isEmpty()) {
        MountainsScreenViewState.Loading
      } else {
        val filteredMountains =
          if (showAllMountains) allMountains else allMountains.filter { it.is14er() }
        val boundingBox = filteredMountains.map { it.location }.toLatLngBounds()
        MountainsScreenViewState.MountainList(
          mountains = filteredMountains,
          boundingBox = boundingBox,
          showingAllPeaks = showAllMountains,
        )
      }
    }.stateIn(
      scope = viewModelScope,
      started = SharingStarted.WhileSubscribed(5000),
      initialValue = MountainsScreenViewState.Loading
    )

  init {
    // Load the full set of mountains
    viewModelScope.launch {
      mountainsRepository.loadMountains()
    }
  }

  // Handle user events
  fun onEvent(event: MountainsViewModelEvent) {
    when (event) {
      OnZoomAll -> onZoomAll()
      OnToggleAllPeaks -> toggleAllPeaks()
    }
  }

  private fun onZoomAll() {
    sendScreenEvent(MountainsScreenEvent.OnZoomAll)
  }

  private fun toggleAllPeaks() {
    showAllMountains.value = !showAllMountains.value
  }

  // Send events back to the UI via the event channel
  private fun sendScreenEvent(event: MountainsScreenEvent) {
    viewModelScope.launch { _eventChannel.send(event) }
  }
}

If you are curious about the implementation of these classes, you can access them on GitHub or open the MountainsRepository and MountainsViewModel classes in Android Studio.

Use the ViewModel

The view model is used in MainActivity to get the viewState. You will use the viewState to the render the markers later in this codelab. Note this code is already included in the starter project and is shown here for reference only.

val viewModel: MountainsViewModel by viewModels()
val screenViewState = viewModel.mountainsScreenViewState.collectAsState()
val viewState = screenViewState.value

8. Position the camera

A GoogleMap default centers to latitude zero, longitude zero. The markers you will be rendering are located in State of Colorado in the USA. The viewState provided by the view model presents a LatLngBounds which contains all of the markers.

In MountainMap.kt create a CameraPositionState initialized to the center of the bounding box. Set the cameraPositionState parameter of the GoogleMap to the cameraPositionState variable you just created.

fun MountainMap(
    // ...
) {
    // ...
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(viewState.boundingBox.center, 5f)
    }

    GoogleMap(
        // ...
        cameraPositionState = cameraPositionState,
    )
}

Now run the code and watch the map center on Colorado.

Zoom to the marker extents

To really focus the map on the markers add the zoomAll function to the end of the MountainMap.kt file. Note that this function needs a CoroutineScope because animating the camera to a new location is an asynchronous operation that takes time to complete.

fun zoomAll(
    scope: CoroutineScope,
    cameraPositionState: CameraPositionState,
    boundingBox: LatLngBounds
) {
    scope.launch {
        cameraPositionState.animate(
            update = CameraUpdateFactory.newLatLngBounds(boundingBox, 64),
            durationMs = 1000
        )
    }
}

Next, add code to invoke the zoomAll function whenever the bounds around the marker collection changes or when the user clicks the zoom extents button in the TopApp bar. Note the zoom extents button is already wired up to send events to the view model. You only need to collect those events from the view model and call the zoomAll function in response.

Extents button

fun MountainMap(
    // ...
) {
    // ...
    val scope = rememberCoroutineScope()

    LaunchedEffect(key1 = viewState.boundingBox) {
        zoomAll(scope, cameraPositionState, viewState.boundingBox)
    }

    LaunchedEffect(true) {
        eventFlow.collect { event ->
            when (event) {
                MountainsScreenEvent.OnZoomAll -> {
                    zoomAll(scope, cameraPositionState, viewState.boundingBox)
                }
            }
        }
    }
}

Now when you run the app, the map will start focused over the area where the markers will go. You can reposition and change the zoom and clicking the zoom extents button will refocus the map around the marker area. That's forward progress! But the map really should have something to look at. And that's what you'll do that in the next step!

9. Basic markers

In this step, you add Markers to the map that represent points of interest that you want to highlight on the map. You will use the list of mountains that have been provided in the starter project and add these places as markers on the map.

Start by adding a content block to the GoogleMap. There will be multiple marker types, so add a when statement to branch to each type and you'll implement each in turn in the proceeding steps.

GoogleMap(
    // ...
) {
    when (selectedMarkerType) {
        MarkerType.Basic -> {
            BasicMarkersMapContent(
                mountains = viewState.mountains,
            )
        }

        MarkerType.Advanced -> {
            AdvancedMarkersMapContent(
                mountains = viewState.mountains,
            )
        }

        MarkerType.Clustered -> {
            ClusteringMarkersMapContent(
                mountains = viewState.mountains,
            )
        }
    }
}

Add markers

Annotate BasicMarkersMapContent with @GoogleMapComposable. Note that you are limited to using @GoogleMapComposable functions in the GoogleMap content block. The mountains object has a list of Mountain objects. You will add a marker for each mountain in that list, using the location, name, and elevation from the Mountain object. The location is used to set the Marker's state parameter which, in turn, controls the marker's position.

// ...
import com.google.android.gms.maps.model.Marker
import com.google.maps.android.compose.GoogleMapComposable
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberMarkerState

@Composable
@GoogleMapComposable
fun BasicMarkersMapContent(
    mountains: List<Mountain>,
    onMountainClick: (Marker) -> Boolean = { false }
) {
    mountains.forEach { mountain ->
        Marker(
            state = rememberMarkerState(position = mountain.location),
            title = mountain.name,
            snippet = mountain.elevation.toElevationString(),
            tag = mountain,
            onClick = { marker ->
                onMountainClick(marker)
                false
            },
            zIndex = if (mountain.is14er()) 5f else 2f
        )
    }
}

Go ahead and run the app you will see the markers you just added!

Customize markers

There are several customization options for markers you have just added to help them stand out and convey useful information to users. In this task, you'll explore some of those by customizing the image of each marker.

The starter project includes a helper function, vectorToBitmap, to create to BitmapDescriptors from a @DrawableResource.

The starter code includes a mountain icon, baseline_filter_hdr_24.xml, that you will use to customize the markers.

The vectorToBitmap function converts a vector drawable into a BitmapDescriptor for use with the maps library. The icon colors are set using a BitmapParameters instance.

data class BitmapParameters(
    @DrawableRes val id: Int,
    @ColorInt val iconColor: Int,
    @ColorInt val backgroundColor: Int? = null,
    val backgroundAlpha: Int = 168,
    val padding: Int = 16,
)

fun vectorToBitmap(context: Context, parameters: BitmapParameters): BitmapDescriptor {
    // ...
}

Use the vectorToBitmap function to create two customized BitmapDescriptors; one for fourteeners and one for regular mountains. Then use the icon parameter of Marker composable to set the icon. Also, set the anchor parameter to change the anchor location relative to the icon. Using the center works better for these circular icons.

@Composable
@GoogleMapComposable
fun BasicMarkersMapContent(
    // ...
) {
    // Create mountainIcon and fourteenerIcon
    val mountainIcon = vectorToBitmap(
        LocalContext.current,
        BitmapParameters(
            id = R.drawable.baseline_filter_hdr_24,
            iconColor = MaterialTheme.colorScheme.secondary.toArgb(),
            backgroundColor = MaterialTheme.colorScheme.secondaryContainer.toArgb(),
        )
    )

    val fourteenerIcon = vectorToBitmap(
        LocalContext.current,
        BitmapParameters(
            id = R.drawable.baseline_filter_hdr_24,
            iconColor = MaterialTheme.colorScheme.onPrimary.toArgb(),
            backgroundColor = MaterialTheme.colorScheme.primary.toArgb(),
        )
    )

    mountains.forEach { mountain ->
        val icon = if (mountain.is14er()) fourteenerIcon else mountainIcon
        Marker(
            // ...
            anchor = Offset(0.5f, 0.5f),
            icon = icon,
        )
    }
}

Run the app and marvel at the customized markers. Toggle the Show all switch to see the full set of mountains. The mountains will have different markers depending on the mountain is a fourteener.

10. Advanced markers

AdvancedMarkers add extra features to basic Markers. In this step, you will set the collision behavior and configure the pin style.

Add @GoogleMapComposable to the AdvancedMarkersMapContent function. Loop over the mountains adding an AdvancedMarker for each.

@Composable
@GoogleMapComposable
fun AdvancedMarkersMapContent(
    mountains: List<Mountain>,
    onMountainClick: (Marker) -> Boolean = { false },
) {
    mountains.forEach { mountain ->
        AdvancedMarker(
            state = rememberMarkerState(position = mountain.location),
            title = mountain.name,
            snippet = mountain.elevation.toElevationString(),
            collisionBehavior = AdvancedMarkerOptions.CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL,
            onClick = { marker ->
                onMountainClick(marker)
                false
            }
        )
    }
}

Notice the collisionBehavior parameter. By setting this parameter to REQUIRED_AND_HIDES_OPTIONAL, your marker will replace any lower priority marker. You can see this by zoom in on a basic marker compared to an advanced marker. The basic marker will likely have both your marker and marker placed in the same location in the base map. The advanced marker will cause the lower priority marker to be hidden.

Run the app to see the Advanced markers. Be sure to select the Advanced markers tab in the bottom navigation row.

Customized AdvancedMarkers

The icons use the primary and secondary color schemes to distinguish between the fourteeners and other mountains. Use the vectorToBitmap function to create two BitmapDescriptors; one for the fourteeners and one for the other mountains. Use those icons to create a custom pinConfig for each type. Finally, apply the pin to corresponding AdvancedMarker based on the is14er() function.

@Composable
@GoogleMapComposable
fun AdvancedMarkersMapContent(
    mountains: List<Mountain>,
    onMountainClick: (Marker) -> Boolean = { false },
) {
    val mountainIcon = vectorToBitmap(
        LocalContext.current,
        BitmapParameters(
            id = R.drawable.baseline_filter_hdr_24,
            iconColor = MaterialTheme.colorScheme.onSecondary.toArgb(),
        )
    )

    val mountainPin = with(PinConfig.builder()) {
        setGlyph(PinConfig.Glyph(mountainIcon))
        setBackgroundColor(MaterialTheme.colorScheme.secondary.toArgb())
        setBorderColor(MaterialTheme.colorScheme.onSecondary.toArgb())
        build()
    }

    val fourteenerIcon = vectorToBitmap(
        LocalContext.current,
        BitmapParameters(
            id = R.drawable.baseline_filter_hdr_24,
            iconColor = MaterialTheme.colorScheme.onPrimary.toArgb(),
        )
    )

    val fourteenerPin = with(PinConfig.builder()) {
        setGlyph(PinConfig.Glyph(fourteenerIcon))
        setBackgroundColor(MaterialTheme.colorScheme.primary.toArgb())
        setBorderColor(MaterialTheme.colorScheme.onPrimary.toArgb())
        build()
    }

    mountains.forEach { mountain ->
        val pin = if (mountain.is14er()) fourteenerPin else mountainPin
        AdvancedMarker(
            state = rememberMarkerState(position = mountain.location),
            title = mountain.name,
            snippet = mountain.elevation.toElevationString(),
            collisionBehavior = AdvancedMarkerOptions.CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL,
            pinConfig = pin,
            onClick = { marker ->
                onMountainClick(marker)
                false
            }
        )
    }
}

11. Clustered markers

In this step, you will use the Clustering composable to add zoom-based item grouping.

The Clustering composable requires a collection of ClusterItems. MountainClusterItem implements the ClusterItem interface. Add this class to the ClusteringMarkersMapContent.kt file.

data class MountainClusterItem(
    val mountain: Mountain,
    val snippetString: String
) : ClusterItem {
    override fun getPosition() = mountain.location
    override fun getTitle() = mountain.name
    override fun getSnippet() = snippetString
    override fun getZIndex() = 0f
}

Now add the code to create MountainClusterItems from the list of mountains. Note this code uses a UnitsConverter to convert to display units appropriate for the user based on their locale. This is set up in the MainActivity using a CompositionLocal

@OptIn(MapsComposeExperimentalApi::class)
@Composable
@GoogleMapComposable
fun ClusteringMarkersMapContent(
    mountains: List<Mountain>,
    // ...
) {
    val unitsConverter = LocalUnitsConverter.current
    val resources = LocalContext.current.resources

    val mountainClusterItems by remember(mountains) {
        mutableStateOf(
            mountains.map { mountain ->
                MountainClusterItem(
                    mountain = mountain,
                    snippetString = unitsConverter.toElevationString(resources, mountain.elevation)
                )
            }
        )
    }

    Clustering(
        items = mountainClusterItems,
    )
}

And with that code, the markers are clustered based on the zoom-level. Nice and tidy!

Customize clusters

As with the other marker types, clustered markers are customizable. The clusterItemContent parameter of the Clustering composable sets a custom composable block to render a non-clustered item. Implement a @Composable function to create the marker. The SingleMountain function renders a composable Material 3 Icon with a customized background color scheme.

In ClusteringMarkersMapContent.kt, create a data class defining the color scheme for a marker:

data class IconColor(val iconColor: Color, val backgroundColor: Color, val borderColor: Color)

Also, in ClusteringMarkersMapContent.kt create a composable function to render an icon for a given color scheme:

@Composable
private fun SingleMountain(
    colors: IconColor,
) {
    Icon(
        painterResource(id = R.drawable.baseline_filter_hdr_24),
        tint = colors.iconColor,
        contentDescription = "",
        modifier = Modifier
            .size(32.dp)
            .padding(1.dp)
            .drawBehind {
                drawCircle(color = colors.backgroundColor, style = Fill)
                drawCircle(color = colors.borderColor, style = Stroke(width = 3f))
            }
            .padding(4.dp)
    )
}

Now create a color scheme for fourteeners and another color scheme for other mountains. In the clusterItemContent block, select the color scheme based on whether or not the given mountain is a fourteener.

fun ClusteringMarkersMapContent(
    mountains: List<Mountain>,
    // ...
) {
  // ...

  val backgroundAlpha = 0.6f

  val fourteenerColors = IconColor(
      iconColor = MaterialTheme.colorScheme.onPrimary,
      backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = backgroundAlpha),
      borderColor = MaterialTheme.colorScheme.primary
  )

  val otherColors = IconColor(
      iconColor = MaterialTheme.colorScheme.secondary,
      backgroundColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = backgroundAlpha),
      borderColor = MaterialTheme.colorScheme.secondary
  )

  // ...
  Clustering(
      items = mountainClusterItems,
      clusterItemContent = { mountainItem ->
          val colors = if (mountainItem.mountain.is14er()) {
              fourteenerColors
          } else {
              otherColors
          }
          SingleMountain(colors)
      },
  )
}

Now, run the app to see customized versions of the individual items.

12. Draw on the map

While you have already explored one way to draw on the map (by adding markers), the Maps SDK for Android supports numerous other ways you can draw to display useful information on the map.

For example, if you wanted to represent routes and areas on the map, you can use Polylines and Polygons to display these on the map. Or, if you want to fix an image to the ground's surface, you can use a GroundOverlay.

In this task, you learn how to draw shapes, specifically an outline around the State of Colorado. The Colorado border is defined as between 37°N and 41°N latitude and 102°03'W and 109°03'W. This makes drawing the outline pretty straightforward.

The starter code includes a DMS class to convert from degrees-minutes-seconds notation to decimal degrees.

enum class Direction(val sign: Int) {
    NORTH(1),
    EAST(1),
    SOUTH(-1),
    WEST(-1)
}

/**
 * Degrees, minutes, seconds utility class
 */
data class DMS(
    val direction: Direction,
    val degrees: Double,
    val minutes: Double = 0.0,
    val seconds: Double = 0.0,
)

fun DMS.toDecimalDegrees(): Double =
    (degrees + (minutes / 60) + (seconds / 3600)) * direction.sign

With the DMS class, you can draw Colorado's border by defining the four corner LatLng locations and rendering those as a Polygons. Add the following code to MountainMap.kt

@Composable
@GoogleMapComposable
fun ColoradoPolygon() {
    val north = 41.0
    val south = 37.0
    val east = DMS(WEST, 102.0, 3.0).toDecimalDegrees()
    val west = DMS(WEST, 109.0, 3.0).toDecimalDegrees()

    val locations = listOf(
        LatLng(north, east),
        LatLng(south, east),
        LatLng(south, west),
        LatLng(north, west),
    )

    Polygon(
        points = locations,
        strokeColor = MaterialTheme.colorScheme.tertiary,
        strokeWidth = 3F,
        fillColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
    )
}

Now call ColoradoPolyon() inside the GoogleMap content block.

@Composable
fun MountainMap(
    // ...
) {
   Box(
    // ...
    ) {
        GoogleMap(
            // ...
        ) {
            ColoradoPolygon()
        }
    }
}

Now the app outlines the State of Colorado while giving it a subtle fill.

13. Add a KML layer and scale bar

In this final section you will roughly outline the different mountain ranges and add a scale bar to the map.

Outline the mountain ranges

Previously, you drew an outline around Colorado. Here you are going to add more complex shapes to the map. The starter code includes a Keyhole Markup Language, or KML, file which roughly outlines the important mountains ranges. The Maps SDK for Android Utility Library has a function to add a KML layer to the map. In MountainMap.kt add a MapEffect call in the GoogleMap content block after the when block. The MapEffect function is called with a GoogleMap object. It can serve as a useful bridge between non-composable APIs and libraries which require a GoogleMap object.

  fun MountainMap(
    // ...
) {
    var isMapLoaded by remember { mutableStateOf(false) }
    val context = LocalContext.current

    GoogleMap(
      // ...
    ) {
      // ...

      when (selectedMarkerType) {
        // ...
      }

      // This code belongs inside the GoogleMap content block, but outside of
      // the 'when' statement
      MapEffect(key1 = true) {map ->
          val layer = KmlLayer(map, R.raw.mountain_ranges, context)
          layer.addLayerToMap()
      }
    }

Add a map scale

As your final task, you will add a scale to the map. The ScaleBar implements a scale composable that can be added to the map. Note, that the ScaleBar is not a

@GoogleMapComposable and therefore cannot be added to the GoogleMap content. You instead add it to the Box that holds the map.

Box(
  // ...
) {
    GoogleMap(
      // ...
    ) {
        // ...
    }

    ScaleBar(
        modifier = Modifier
            .padding(top = 5.dp, end = 15.dp)
            .align(Alignment.TopEnd),
        cameraPositionState = cameraPositionState
    )
    // ...
}

Run the app to see the fully implemented codelab.

14. Get the solution code

To download the code for the finished codelab, you can use these commands:

  1. Clone the repository if you have git installed.
$ git clone https://github.com/googlemaps-samples/codelab-maps-platform-101-compose.git

Alternatively, you can click the following button to download the source code.

  1. Upon getting the code, go ahead and open the project found inside the solution directory in Android Studio.

15. Congratulations

Congratulations! You covered a lot of content and hopefully you have a better understanding of the core features offered in the Maps SDK for Android.

Learn more

  • Maps SDK for Android - Build dynamic, interactive, customized maps, location, and geospatial experiences for your Android apps.
  • Maps Compose Library - a set of open source composable functions and data types that you can use with Jetpack Compose to build your app.
  • android-maps-compose - sample code on GitHub demonstrating all the features covered in this codelab and more.
  • More Kotlin codelabs for building Android apps with Google Maps Platform