Google Map
Add Top Bar
Now we'll add a top bar that provides action buttons to
- Go to the current location
- Remember the car is parked at the current location
- Navigate using the Google Map application from the current position to the car (in walk mode)
- Forget the car location
We need some icons for these actions. Let's grab the following from Google Fonts like we did for the movies example. (See Flesh out the screens)
- My Location
- Star
- Directions Walk
- Delete
We Define the top bar with the above actions calling event parameters.
show in full file app/src/main/java/com/androidbyexample/google/maps/CarTopBar.kt
// ...
import com.google.android.gms.maps.model.LatLng
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CarTopBar(
currentLocation: Location?,
carLatLng: LatLng?,
onSetCarLocation: () -> Unit,
onGoToCurrentLocation: () -> Unit,
onClearCarLocation: () -> Unit,
onWalkToCar: () -> Unit,
) {
TopAppBar(
title = { Text(text = stringResource(id = R.string.app_name)) },
actions = {
currentLocation?.let {
IconButton(onClick = onGoToCurrentLocation) {
Icon(
painter = painterResource(R.drawable.my_location_24dp),
contentDescription =
stringResource(R.string.go_to_current_location),
)
}
IconButton(onClick = onSetCarLocation) {
Icon(
painter = painterResource(R.drawable.star_24dp),
contentDescription =
stringResource(R.string.remember_location),
)
}
}
carLatLng?.let {
IconButton(onClick = onWalkToCar) {
Icon(
painter = painterResource(R.drawable.directions_walk_24dp),
contentDescription =
stringResource(R.string.navigate),
)
}
IconButton(onClick = onClearCarLocation) {
Icon(
painter = painterResource(R.drawable.delete_24dp),
contentDescription =
stringResource(R.string.forget_location),
)
}
}
},
)
}
Our Scaffold is in the MainActivity, so we add our CarTopBar there.
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
val appState by viewModel.appState.collectAsStateWithLifecycle(Startup)
val currentLocation by viewModel.currentLocation.collectAsStateWithLifecycle(
initialValue = null
)
var animateToCurrentLocation by remember { mutableStateOf(false) }
GooglemapsTheme {
// ...
Scaffold(
topBar = {
CarTopBar(
currentLocation = currentLocation,
carLatLng = null,
onSetCarLocation = {},
onGoToCurrentLocation = {
animateToCurrentLocation = true
},
onClearCarLocation = {},
onWalkToCar = {},
)
},
modifier = Modifier.fillMaxSize()
) { innerPadding ->
// ...
}
}
}
}
// ...
}
The tricky part here is that when we press the "go to current location" button, we need to interact with the map, which we don't have access to!
So instead, we create a flag to tell the GoogleMapDisplay to do the animation and have it tell
us when it has done it so we can turn the flag off.
show in full file app/src/main/java/com/androidbyexample/google/maps/GoogleMapDisplay.kt
// ...
@Composable
fun GoogleMapDisplay(
// ...
cameraPositionState: CameraPositionState,
modifier: Modifier,
animateToCurrentLocation: Boolean,
onAnimatedToCurrentLocation: () -> Unit,
) {
var mapLoaded by remember { mutableStateOf(false) }
// ...
Box(
// ...
) {
Column(
// ...
) {
// ...
var initialBoundsSet by remember { mutableStateOf(false) }
// LaunchedEffect(key1 = mapLoaded, key2 = currentLocation) {
fun Location.toLatLng() = LatLng(latitude, longitude)
suspend fun goTo(latLng: LatLng) {
cameraPositionState.animate(
CameraUpdateFactory.newLatLngZoom(
latLng,
16f
), 1000
)
}
LaunchedEffect(key1 = mapLoaded, key2 = currentLocation, animateToCurrentLocation) {
if (mapLoaded) {
if (currentLocation != null) {
if (!initialBoundsSet) {
initialBoundsSet = true
// val current =
// LatLng(currentLocation.latitude, currentLocation.longitude)
// cameraPositionState.animate(
// CameraUpdateFactory.newLatLngZoom(
// current,
// 16f
// ), 1000
// )
goTo(currentLocation.toLatLng())
}
if (animateToCurrentLocation) {
goTo(currentLocation.toLatLng())
onAnimatedToCurrentLocation() // tell the caller we did it
}
}
}
}
// ...
}
// ...
}
}
Note
Note that we also need the current location with the top bar, as well as inside showMap().
We move its collection to where we set up the top bar and pass it to showMap().
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
@Composable
fun ShowMap(
animateToCurrentLocation: Boolean,
onAnimatedToCurrentLocation: () -> Unit,
modifier: Modifier,
currentLocation: Location?,
) {
val googleHQ = LatLng(37.42423291057923, -122.08811454627153)
val defaultCameraPosition = CameraPosition.fromLatLngZoom(googleHQ, 11f)
val cameraPositionState = rememberCameraPositionState {
position = defaultCameraPosition
}
// val currentLocation by viewModel.currentLocation.collectAsStateWithLifecycle(
// initialValue = null
// )
//
GoogleMapDisplay(
currentLocation = currentLocation,
cameraPositionState = cameraPositionState,
animateToCurrentLocation = animateToCurrentLocation,
onAnimatedToCurrentLocation = onAnimatedToCurrentLocation,
modifier = modifier.fillMaxSize(),
)
}
// ...
}
Our application now looks like

If we pan away from the current location and tap the location button on the Top Bar, Google Maps will animate back to the current location over one second.
All code changes
$ADDED: app/src/main/java/com/androidbyexample/google/maps/CarTopBar.kt
package com.androidbyexample.google.maps
import android.location.Location
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.google.android.gms.maps.model.LatLng
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CarTopBar(
currentLocation: Location?,
carLatLng: LatLng?,
onSetCarLocation: () -> Unit,
onGoToCurrentLocation: () -> Unit,
onClearCarLocation: () -> Unit,
onWalkToCar: () -> Unit,
) {
TopAppBar(
title = { Text(text = stringResource(id = R.string.app_name)) },
actions = {
currentLocation?.let {
IconButton(onClick = onGoToCurrentLocation) {
Icon(
painter = painterResource(R.drawable.my_location_24dp),
contentDescription =
stringResource(R.string.go_to_current_location),
)
}
IconButton(onClick = onSetCarLocation) {
Icon(
painter = painterResource(R.drawable.star_24dp),
contentDescription =
stringResource(R.string.remember_location),
)
}
}
carLatLng?.let {
IconButton(onClick = onWalkToCar) {
Icon(
painter = painterResource(R.drawable.directions_walk_24dp),
contentDescription =
stringResource(R.string.navigate),
)
}
IconButton(onClick = onClearCarLocation) {
Icon(
painter = painterResource(R.drawable.delete_24dp),
contentDescription =
stringResource(R.string.forget_location),
)
}
}
},
)
}
$CHANGED: app/src/main/java/com/androidbyexample/google/maps/GoogleMapDisplay.kt
package com.androidbyexample.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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun GoogleMapDisplay(
currentLocation: Location?,
cameraPositionState: CameraPositionState,
modifier: Modifier,
animateToCurrentLocation: Boolean,
onAnimatedToCurrentLocation: () -> Unit,
) {
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
)
)
}
}
Box(
modifier = modifier,
) {
Column(
modifier = Modifier.fillMaxSize()
) {
MapTypeSelector(
currentValue = currentMapType,
modifier = Modifier.fillMaxWidth(),
) {
mapProperties = mapProperties.copy(mapType = it)
currentMapType = it
}
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) {
fun Location.toLatLng() = LatLng(latitude, longitude)
suspend fun goTo(latLng: LatLng) {
cameraPositionState.animate(
CameraUpdateFactory.newLatLngZoom(
latLng,
16f
), 1000
)
}
LaunchedEffect(key1 = mapLoaded, key2 = currentLocation, animateToCurrentLocation) {
if (mapLoaded) {
if (currentLocation != null) {
if (!initialBoundsSet) {
initialBoundsSet = true
// val current =
// LatLng(currentLocation.latitude, currentLocation.longitude)
// cameraPositionState.animate(
// CameraUpdateFactory.newLatLngZoom(
// current,
// 16f
// ), 1000
// )
goTo(currentLocation.toLatLng())
}
if (animateToCurrentLocation) {
goTo(currentLocation.toLatLng())
onAnimatedToCurrentLocation() // tell the caller we did it
}
}
}
}
GoogleMap(
cameraPositionState = cameraPositionState,
onMapLoaded = {
mapLoaded = true
scope.launch(Dispatchers.IO) {
currentLocationIcon =
context.loadBitmapDescriptor(
R.drawable.ic_current_location
)
}
},
properties = mapProperties,
modifier = Modifier
.fillMaxSize()
.weight(1f),
) {
currentLocationState?.let {
MarkerInfoWindowContent(
state = it,
icon = currentLocationIcon,
anchor = Offset(0.5f, 0.5f),
// the actual location is at the center of the icon
title = stringResource(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()
)
}
}
}
}
$CHANGED: app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
package com.androidbyexample.google.maps
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.os.Bundle
import android.os.Looper
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.app.ActivityCompat
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.androidbyexample.google.maps.ui.theme.GooglemapsTheme
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.rememberCameraPositionState
class MainActivity : ComponentActivity() {
private val viewModel: CarViewModel by viewModels()
private lateinit var fusedLocationProviderClient: FusedLocationProviderClient
private val locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
viewModel.updateLocation(locationResult.lastLocation)
}
}
private val locationPermissions = arrayOf(
// android.Manifest.permission.ACCESS_FINE_LOCATION,
// android.Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
)
private val quitOption = DialogOption(R.string.quit) { finish() }
private val getLocationPermission =
registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { isGranted ->
viewModel.updateAppState(
when {
isGranted.values.any { it } -> PermissionsGranted
else -> RequestPermission // ask again
}
)
}
// helper to make the checks more readable in onCreate
private fun locationPermissionsGranted() =
locationPermissions.any { permission ->
ActivityCompat.checkSelfPermission(this, permission) ==
PackageManager.PERMISSION_GRANTED
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
checkPlayServices()
enableEdgeToEdge()
setContent {
val appState by viewModel.appState.collectAsStateWithLifecycle(Startup)
val currentLocation by viewModel.currentLocation.collectAsStateWithLifecycle(
initialValue = null
)
var animateToCurrentLocation by remember { mutableStateOf(false) }
GooglemapsTheme {
// Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Scaffold(
topBar = {
CarTopBar(
currentLocation = currentLocation,
carLatLng = null,
onSetCarLocation = {},
onGoToCurrentLocation = {
animateToCurrentLocation = true
},
onClearCarLocation = {},
onWalkToCar = {},
)
},
modifier = Modifier.fillMaxSize()
) { innerPadding ->
val modifier = Modifier.padding(innerPadding)
when (appState) {
Startup -> Loading(modifier)
PlayServicesOk -> {
viewModel.updateAppState(
when {
locationPermissionsGranted() -> PermissionsGranted
else -> RequestPermission
}
)
}
PermissionsGranted -> {
startLocation()
viewModel.updateAppState(ShowMap)
}
// ShowMap -> ShowMap(modifier)
ShowMap ->
ShowMap(
currentLocation = currentLocation,
animateToCurrentLocation = animateToCurrentLocation,
onAnimatedToCurrentLocation = {
animateToCurrentLocation = false
},
modifier = modifier
)
RequestPermission -> ExplainPermission()
PlayServicesRequired -> PlayServicesRequired()
}
}
}
}
}
private fun checkPlayServices() {
GoogleApiAvailability.getInstance()
.makeGooglePlayServicesAvailable(this)
.addOnSuccessListener {
viewModel.updateAppState(PlayServicesOk)
}.addOnFailureListener(this) {
viewModel.updateAppState(PlayServicesRequired)
}
}
@Composable
fun ExplainPermission() {
ThreeButtonDialog(
titleId = R.string.permissions_needed,
text =
// Note - I'm including the text as a literal so it's easier to understand
// the example on the web page. Text like this should always be defined
// in strings.xml
"This application requires location permissions to save and locate " +
"the position of your car. Please grant " +
"'Precise Location' permission.\n\n" +
"Press Ok for permission request.\n\n" +
"If Ok does nothing, this means you " +
"have previously permanently denied the request. In this case, press " +
"'App Info' to change the permissions and restart the app.",
DialogOption(R.string.app_info) {
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:$packageName".toUri()
}
)
finish()
},
DialogOption(R.string.quit) { finish() },
DialogOption(R.string.ok) {
getLocationPermission.launch(locationPermissions)
}
)
}
@Composable
fun PlayServicesRequired() {
ThreeButtonDialog(
titleId = R.string.play_services_required,
text =
// Note - I'm including the text as a literal so it's easier to understand
// the example on the web page. Text like this should always be defined
// in strings.xml
"Google Play services required (or upgrade required)",
quitOption,
)
}
@SuppressLint("MissingPermission")
fun startLocation() {
val locationRequest =
LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000)
.setWaitForAccurateLocation(false)
.setMinUpdateIntervalMillis(0)
.setMaxUpdateDelayMillis(5000)
.build()
fusedLocationProviderClient =
LocationServices.getFusedLocationProviderClient(this)
fusedLocationProviderClient.requestLocationUpdates(
locationRequest,
locationCallback,
Looper.getMainLooper()
)
}
@Composable
fun ShowMap(
animateToCurrentLocation: Boolean,
onAnimatedToCurrentLocation: () -> Unit,
modifier: Modifier,
currentLocation: Location?,
) {
val googleHQ = LatLng(37.42423291057923, -122.08811454627153)
val defaultCameraPosition = CameraPosition.fromLatLngZoom(googleHQ, 11f)
val cameraPositionState = rememberCameraPositionState {
position = defaultCameraPosition
}
// val currentLocation by viewModel.currentLocation.collectAsStateWithLifecycle(
// initialValue = null
// )
//
GoogleMapDisplay(
currentLocation = currentLocation,
cameraPositionState = cameraPositionState,
animateToCurrentLocation = animateToCurrentLocation,
onAnimatedToCurrentLocation = onAnimatedToCurrentLocation,
modifier = modifier.fillMaxSize(),
)
}
@Composable
fun Loading(
modifier: Modifier,
) {
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/delete_24dp.xml
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M280,840q-33,0 -56.5,-23.5T200,760v-520h-40v-80h200v-40h240v40h200v80h-40v520q0,33 -23.5,56.5T680,840L280,840ZM680,240L280,240v520h400v-520ZM360,680h80v-360h-80v360ZM520,680h80v-360h-80v360ZM280,240v520,-520Z"
android:fillColor="#e3e3e3"/>
</vector>
$ADDED: app/src/main/res/drawable/directions_walk_24dp.xml
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="m280,920 l112,-564 -72,28v136h-80v-188l202,-86q14,-6 29.5,-7t29.5,4q14,5 26.5,14t20.5,23l40,64q26,42 70.5,69T760,440v80q-70,0 -125,-29t-94,-74l-25,123 84,80v300h-80v-260l-84,-64 -72,324h-84ZM483.5,196.5Q460,173 460,140t23.5,-56.5Q507,60 540,60t56.5,23.5Q620,107 620,140t-23.5,56.5Q573,220 540,220t-56.5,-23.5Z"
android:fillColor="#e3e3e3"/>
</vector>
$ADDED: app/src/main/res/drawable/my_location_24dp.xml
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M440,918v-80q-125,-14 -214.5,-103.5T122,520L42,520v-80h80q14,-125 103.5,-214.5T440,122v-80h80v80q125,14 214.5,103.5T838,440h80v80h-80q-14,125 -103.5,214.5T520,838v80h-80ZM678,678q82,-82 82,-198t-82,-198q-82,-82 -198,-82t-198,82q-82,82 -82,198t82,198q82,82 198,82t198,-82ZM367,593q-47,-47 -47,-113t47,-113q47,-47 113,-47t113,47q47,47 47,113t-47,113q-47,47 -113,47t-113,-47ZM536.5,536.5Q560,513 560,480t-23.5,-56.5Q513,400 480,400t-56.5,23.5Q400,447 400,480t23.5,56.5Q447,560 480,560t56.5,-23.5ZM480,480Z"
android:fillColor="#e3e3e3"/>
</vector>
$ADDED: app/src/main/res/drawable/star_24dp.xml
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="m354,673 l126,-76 126,77 -33,-144 111,-96 -146,-13 -58,-136 -58,135 -146,13 111,97 -33,143ZM233,840l65,-281L80,370l288,-25 112,-265 112,265 288,25 -218,189 65,281 -247,-149 -247,149ZM480,490Z"
android:fillColor="#e3e3e3"/>
</vector>
$CHANGED: app/src/main/res/values/strings.xml
<resources>
// <string name="app_name">google-maps</string>
<string name="app_name">Google Map</string>
<string name="map_type">Map Type</string>
<string name="current_location">Current Location</string>
<string name="permissions_needed">Permissions Needed</string>
<string name="app_info">App Info</string>
<string name="quit">Quit</string>
<string name="ok">Ok</string>
<string name="play_services_required">Play Services Required</string>
<string name="go_to_current_location">Go to current location</string>
<string name="remember_location">Remember location</string>
<string name="navigate">Navigate</string>
<string name="forget_location">Forget location</string>
</resources>