Google Map
Jump to Current Location
Let's jump to the current location when it's first available and use a custom icon instead of the default marker.
First, our custom icon. We need to get a BitmapDescriptor
. There's no way to do that yet from
Compose, so we'll define a drawable vector image resource for this.
You can create icons using File->New->Vector Asset, and select an icon or pull in an SVG. But for this task, the icon is just a blue circle with a white border.
The icon will use a custom color, which must be defined in a resource file, rather than through Compose for now.
show in full file app/src/main/res/values/colors.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="my_location">#FF0000FF</color>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
// ...
</resources>
To create the shape, we define an Android <shape>
resource. This defines the <shape>
as an
oval
, but because we use the same android:width
and android:height
, we get a circle.
The <stroke>
defines the border color of the circle, and <solid>
defines the fill color.
show in full file app/src/main/res/drawable/ic_current_location.xml
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="oval"
xmlns:android="http://schemas.android.com/apk/res/android">
<size android:height="24dp" android:width="24dp" />
<stroke android:color="@android:color/white" android:width="3dp"/>
<solid android:color="@color/my_location" />
</shape>
To simplify loading the icon, I've defined some helper functions. These are used to load the
Drawable
resource, convert it to a Bitmap
, and get a BitmapDescriptor
for the map to use.
show in full file app/src/main/java/com/androidbyexample/compose/google/google/maps/DrawableHelpers.kt
// ...
// markdown and use another tool to generate something prettier...
/**
* Returns a [Bitmap] for the intrinsic size of any [Drawable]. If the
* drawable is a [BitmapDrawable], we just return it. Otherwise, we create a
* new [Bitmap] of the intrinsic size of the [Drawable], draw the [Drawable]
* to it, and return the [Bitmap].
*
* @sample com.androidbyexample.compose.google.google.maps.loadBitmap
* @receiver Any [Drawable]
* @return a [Bitmap] representation of the [Drawable]
*/
fun Drawable.toBitmap(): Bitmap =
when {
// if it's already a bitmap; just return it
this is BitmapDrawable -> bitmap
// otherwise, create a bitmap and draw the drawable to it
intrinsicWidth == 0 || intrinsicHeight == 0 ->
throw IllegalArgumentException(
"Drawable cannot be converted to a Bitmap; it must have " +
"non-zero intrinsic width and height")
else ->
Bitmap.createBitmap(
intrinsicWidth,
intrinsicHeight,
Bitmap.Config.ARGB_8888
).apply {
val canvas = Canvas(this)
setBounds(0, 0, canvas.width, canvas.height)
draw(canvas)
}
}
/**
* Loads a [Bitmap] by the resource id of a [Drawable].
*
* @sample com.androidbyexample.compose.google.google.maps.loadBitmapDescriptor
* @receiver [Context] to load the [Drawable]
* @param id [Int] the resource id of the [Drawable]
* @return a [Bitmap] representation of the [Drawable]
*/
fun Context.loadBitmap(@DrawableRes id: Int): Bitmap =
ContextCompat.getDrawable(this, id)?.toBitmap()
?: throw Resources.NotFoundException(resources.getResourceName(id))
/**
* Loads a [BitmapDescriptor] by the resource id of a [Drawable].
*
* @sample com.androidbyexample.compose.google.google.maps.MainActivity.onCreate
* @receiver [Context] to load the [Drawable]
* @param id [Int] the resource id of the [Drawable]
* @return a [Bitmap] representation of the [Drawable]
*/
// they actually are resolved by dokka 'samples' config
fun Context.loadBitmapDescriptor(@DrawableRes id: Int): BitmapDescriptor =
loadBitmap(id).toBitmapDescriptor()
/**
* Converts a [Bitmap] to a [BitmapDescriptor]
*
* @sample com.androidbyexample.compose.google.google.maps.loadBitmapDescriptor
*/
fun Bitmap.toBitmapDescriptor(): BitmapDescriptor =
BitmapDescriptorFactory.fromBitmap(this)
I've added KDoc comments to these helper functions to demonstrate how you can generate API documentation. I've set up Dokka
show in full file gradle/libs.versions.toml
[versions]
// ...
location-services = "21.3.0"
lifecycle-runtime-compose = "2.8.7"
dokka = "1.9.20"
[libraries]
// ...
[plugins]
// ...
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
show in full file app/build.gradle.kts
plugins {
// ...
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.secrets)
alias(libs.plugins.dokka)
}
// ...
which you can use to generate documentation using tasks like
./gradlew dokkaHtml
./gradlew dokkaGfm
(Gfm is "Github-flavored Markdown")
We can use these helpers to load the icon in a coroutine once the map has loaded.
To load the icon resource, we need
- An Android
Context
, which in this case will be theActivity
. TheContext
provides access to the current device configuration and resources. - A bucket to store the icon once it's loaded,
currentLocationItem
- A coroutine
scope
to load the icon asynchronously
Finally, we set the icon on the Marker
.
show in full file app/src/main/java/com/androidbyexample/compose/google/google/maps/GoogleMapDisplay.kt
// ...
@Composable
fun GoogleMapDisplay(
// ...
) {
// ...
}
val context = LocalContext.current
var currentLocationIcon by
remember { mutableStateOf<BitmapDescriptor?>(null) }
val scope = rememberCoroutineScope()
var initialBoundsSet by remember { mutableStateOf(false) }
// ...
Box(
// ...
) {
Column(
// ...
) {
// ...
GoogleMap(
// ...
onMapLoaded = {
mapLoaded = true
scope.launch(Dispatchers.IO) {
currentLocationIcon =
context.loadBitmapDescriptor(
R.drawable.ic_current_location
)
}
},
properties = mapProperties,
// ...
) {
currentLocationState?.let {
MarkerInfoWindowContent(
state = it,
icon = currentLocationIcon,
anchor = Offset(0.5f, 0.5f),
title = stringResource(
// ...
)
}
}
}
// ...
}
}
Note
The factory for the BitmapDescriptor
sometimes isn't available until the map has loaded.
It's best to wait until the map is successfully loaded before trying to load the icons. We do
this by placing the call inside the onMapReady
lambda parameter to GoogleMap
The anchor
parameter defines how the location matches up with the icon.
Using an Offset(0.5, 0.5)
means the center of the icon is pinned to the location. For
the default icons, the bottom center (the pointy tip) marks the location, so we'd use
Offset(0.5, 1.0)
.
Mea culpa: I accidentally copied the Offset(0.5, 0.5)
(as seen above) when I first created
the location marker in a previous step. It was using the default marker, so it should have
used Offset(0.5, 1.0)
. Offset(0.5, 0.5)
is appropriate for this circle marker, so we leave
it as is.
Running the application now shows (but you have to manually pan to it)
Jumping to the current location on startup is done with a LaunchedEffect
keyed off mapLoaded
and currentLocation
. The first time this LaunchedEffect
is run, mapLoaded
is false
and
the currentLocation
is null, so it doesn't do anything.
show in full file app/src/main/java/com/androidbyexample/compose/google/google/maps/GoogleMapDisplay.kt
// ...
@Composable
fun GoogleMapDisplay(
// ...
) {
// ...
val scope = rememberCoroutineScope()
var initialBoundsSet by remember { mutableStateOf(false) }
LaunchedEffect(key1 = mapLoaded, key2 = currentLocation) {
if (mapLoaded) {
if (currentLocation != null) {
if (!initialBoundsSet) {
initialBoundsSet = true
val current =
LatLng(currentLocation.latitude, currentLocation.longitude)
cameraPositionState.animate(
CameraUpdateFactory.newLatLngZoom(
current,
16f
), 1000
)
}
}
}
}
Box(
// ...
}
When either mapLoaded
or currentLocation
changes, the LaunchedEffect
is restarted.
It's important that we use both of these keys, as we have no idea which of the map or the current
location will be available first. If we only used currentLocation
, and the location were
available before the map was ready, we'd be stuck in our original location.
Once both are available, the LaunchedEffect
asks the cameraPositionState
to change via a
CameraUpdate
command. We set initialBoundsSet
to true
so we won't move again.
Note
I'm using the name initialBoundsSet
because later we'll be setting a bounding box based on
the current location and set car location.
All code changes
CHANGED: app/build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.secrets)
alias(libs.plugins.dokka)
}
android {
namespace = "com.androidbyexample.compose.google.google.maps"
compileSdk = 35
defaultConfig {
applicationId = "com.androidbyexample.compose.google.google.maps"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.location.services)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.maps.compose)
implementation(libs.maps.compose.utils)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
secrets {
propertiesFileName = "secrets.properties"
}
ADDED: app/src/main/java/com/androidbyexample/compose/google/google/maps/DrawableHelpers.kt
package com.androidbyexample.compose.google.google.maps
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import android.content.Context
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory
// NOTE: I wanted to show an example of a set of extensions nicely-documented
// for KDoc generation using the Dokka tool. There's an example of the setup
// in the build scripts. Note that Dokka is still pretty young and doesn't
// generate the prettiest documentation... But you can use it to generate
// markdown and use another tool to generate something prettier...
/**
* Returns a [Bitmap] for the intrinsic size of any [Drawable]. If the
* drawable is a [BitmapDrawable], we just return it. Otherwise, we create a
* new [Bitmap] of the intrinsic size of the [Drawable], draw the [Drawable]
* to it, and return the [Bitmap].
*
* @sample com.androidbyexample.compose.google.google.maps.loadBitmap
* @receiver Any [Drawable]
* @return a [Bitmap] representation of the [Drawable]
*/
fun Drawable.toBitmap(): Bitmap =
when {
// if it's already a bitmap; just return it
this is BitmapDrawable -> bitmap
// otherwise, create a bitmap and draw the drawable to it
intrinsicWidth == 0 || intrinsicHeight == 0 ->
throw IllegalArgumentException(
"Drawable cannot be converted to a Bitmap; it must have " +
"non-zero intrinsic width and height")
else ->
Bitmap.createBitmap(
intrinsicWidth,
intrinsicHeight,
Bitmap.Config.ARGB_8888
).apply {
val canvas = Canvas(this)
setBounds(0, 0, canvas.width, canvas.height)
draw(canvas)
}
}
/**
* Loads a [Bitmap] by the resource id of a [Drawable].
*
* @sample com.androidbyexample.compose.google.google.maps.loadBitmapDescriptor
* @receiver [Context] to load the [Drawable]
* @param id [Int] the resource id of the [Drawable]
* @return a [Bitmap] representation of the [Drawable]
*/
fun Context.loadBitmap(@DrawableRes id: Int): Bitmap =
ContextCompat.getDrawable(this, id)?.toBitmap()
?: throw Resources.NotFoundException(resources.getResourceName(id))
/**
* Loads a [BitmapDescriptor] by the resource id of a [Drawable].
*
* @sample com.androidbyexample.compose.google.google.maps.MainActivity.onCreate
* @receiver [Context] to load the [Drawable]
* @param id [Int] the resource id of the [Drawable]
* @return a [Bitmap] representation of the [Drawable]
*/
// they actually are resolved by dokka 'samples' config
fun Context.loadBitmapDescriptor(@DrawableRes id: Int): BitmapDescriptor =
loadBitmap(id).toBitmapDescriptor()
/**
* Converts a [Bitmap] to a [BitmapDescriptor]
*
* @sample com.androidbyexample.compose.google.google.maps.loadBitmapDescriptor
*/
fun Bitmap.toBitmapDescriptor(): BitmapDescriptor =
BitmapDescriptorFactory.fromBitmap(this)
CHANGED: app/src/main/java/com/androidbyexample/compose/google/google/maps/GoogleMapDisplay.kt
package com.androidbyexample.compose.google.google.maps
import android.location.Location
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapProperties
import com.google.maps.android.compose.MapType
import com.google.maps.android.compose.MarkerInfoWindowContent
import com.google.maps.android.compose.MarkerState
import com.google.maps.android.compose.rememberMarkerState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun GoogleMapDisplay(
currentLocation: Location?,
cameraPositionState: CameraPositionState,
modifier: Modifier,
) {
var mapLoaded by remember { mutableStateOf(false) }
var currentMapType by remember { mutableStateOf(MapType.NORMAL) }
var mapProperties by remember {
mutableStateOf(MapProperties(mapType = MapType.NORMAL))
}
val currentLocationState = remember(currentLocation) {
currentLocation?.let {
MarkerState(
LatLng(
it.latitude,
it.longitude
)
)
}
}
val context = LocalContext.current
var currentLocationIcon by
remember { mutableStateOf<BitmapDescriptor?>(null) }
val scope = rememberCoroutineScope()
var initialBoundsSet by remember { mutableStateOf(false) }
LaunchedEffect(key1 = mapLoaded, key2 = currentLocation) {
if (mapLoaded) {
if (currentLocation != null) {
if (!initialBoundsSet) {
initialBoundsSet = true
val current =
LatLng(currentLocation.latitude, currentLocation.longitude)
cameraPositionState.animate(
CameraUpdateFactory.newLatLngZoom(
current,
16f
), 1000
)
}
}
}
}
Box(
modifier = modifier,
) {
Column(
modifier = Modifier.fillMaxSize()
) {
MapTypeSelector(
currentValue = currentMapType,
modifier = Modifier.fillMaxWidth(),
) {
mapProperties = mapProperties.copy(mapType = it)
currentMapType = it
}
GoogleMap(
cameraPositionState = cameraPositionState,
onMapLoaded = {
mapLoaded = true
scope.launch(Dispatchers.IO) {
currentLocationIcon =
context.loadBitmapDescriptor(
R.drawable.ic_current_location
)
}
},
properties = mapProperties,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
currentLocationState?.let {
MarkerInfoWindowContent(
state = it,
icon = currentLocationIcon,
anchor = Offset(0.5f, 0.5f),
title = stringResource(
id = R.string.current_location
),
)
}
}
}
if (!mapLoaded) {
AnimatedVisibility(
visible = true,
modifier = Modifier.fillMaxSize(),
enter = EnterTransition.None,
exit = fadeOut()
) {
CircularProgressIndicator(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.wrapContentSize()
)
}
}
}
}
ADDED: app/src/main/res/drawable/ic_current_location.xml
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="oval"
xmlns:android="http://schemas.android.com/apk/res/android">
<size android:height="24dp" android:width="24dp" />
<stroke android:color="@android:color/white" android:width="3dp"/>
<solid android:color="@color/my_location" />
</shape>
CHANGED: app/src/main/res/values/colors.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="my_location">#FF0000FF</color>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
CHANGED: gradle/libs.versions.toml
[versions]
agp = "8.7.3"
kotlin = "2.0.21"
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
secrets = "2.0.1"
maps-compose = "6.2.1"
location-services = "21.3.0"
lifecycle-runtime-compose = "2.8.7"
dokka = "1.9.20"
[libraries]
location-services = { group = "com.google.android.gms", name = "play-services-location", version.ref = "location-services" }
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-runtime-compose" }
maps-compose = { group = "com.google.maps.android", name = "maps-compose", version.ref = "maps-compose" }
maps-compose-utils = { group = "com.google.maps.android", name = "maps-compose-utils", version.ref = "maps-compose" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }