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
These actions will use icons from the Extended Icon Set, so we add its dependency and sync the project. (We'll copy the icons in the next step so we can remove the huge dependency.)
show in full file gradle/libs.versions.toml
[versions]
// ...
lifecycle-runtime-compose = "2.8.7"
dokka = "1.9.20"
icons-extended = "1.7.5"
[libraries]
icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "icons-extended" }
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" }
// ...
[plugins]
// ...
show in full file app/build.gradle.kts
// ...
dependencies {
implementation(libs.icons.extended)
implementation(libs.location.services)
implementation(libs.lifecycle.runtime.compose)
// ...
}
// ...
We Define the top bar with the above actions calling event parameters.
show in full file app/src/main/java/com/androidbyexample/compose/google/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(
imageVector = Icons.Filled.GpsFixed,
contentDescription =
stringResource(id = R.string.go_to_current_location),
)
}
IconButton(onClick = onSetCarLocation) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription =
stringResource(id = R.string.remember_location),
)
}
}
carLatLng?.let {
IconButton(onClick = onWalkToCar) {
Icon(
imageVector = Icons.AutoMirrored.Filled.DirectionsWalk,
contentDescription =
stringResource(id = R.string.navigate),
)
}
IconButton(onClick = onClearCarLocation) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription =
stringResource(id = R.string.forget_location),
)
}
}
},
)
}
We're going to set up a Scaffold
for the GoogleMapDisplay
itself, so we'll remove the one
in MainActivity
(or we'll get extra padding at the top)
show in full file app/src/main/java/com/androidbyexample/compose/google/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
@SuppressLint("MissingPermission")
fun startLocationAndMap() {
// ...
setContent {
GoogleMapsTheme {
// Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
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,
// modifier = Modifier.padding(innerPadding).fillMaxSize(),
modifier = Modifier.fillMaxSize(),
)
}
}
// }
// }
// }
//
}
// ...
}
Set up a Scaffold
, wrapping our Box
, that will move the camera to the current location,
and placeholders for the functions we're not implementing yet.
show in full file app/src/main/java/com/androidbyexample/compose/google/google/maps/GoogleMapDisplay.kt
// ...
@Composable
fun GoogleMapDisplay(
// ...
) {
// ...
}
Scaffold(
topBar = {
CarTopBar(
currentLocation = currentLocation,
carLatLng = null,
onSetCarLocation = { TODO() },
onClearCarLocation = { TODO() },
onWalkToCar = { TODO() },
onGoToCurrentLocation = {
currentLocation?.let { curr ->
scope.launch {
cameraPositionState.animate(
CameraUpdateFactory.newLatLngZoom(
LatLng(curr.latitude, curr.longitude),
16f
), 1000
)
}
} ?: Toast.makeText(
context,
"No current location available",
Toast.LENGTH_LONG
).show()
},
)
},
content = { paddingValues ->
Box(
// modifier = modifier,
modifier = modifier.padding(paddingValues),
) {
Column(
// ...
}
}
)
}
Note
Kotlin's TODO()
function throws an exception. Much nicer than having a TODO comment that
just does nothing silently. In this case, if we tap any of the buttons that call TODO()
,
the application will crash.
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
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.icons.extended)
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/CarTopBar.kt
package com.androidbyexample.compose.google.google.maps
import android.location.Location
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.GpsFixed
import androidx.compose.material.icons.filled.Star
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.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(
imageVector = Icons.Filled.GpsFixed,
contentDescription =
stringResource(id = R.string.go_to_current_location),
)
}
IconButton(onClick = onSetCarLocation) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription =
stringResource(id = R.string.remember_location),
)
}
}
carLatLng?.let {
IconButton(onClick = onWalkToCar) {
Icon(
imageVector = Icons.AutoMirrored.Filled.DirectionsWalk,
contentDescription =
stringResource(id = R.string.navigate),
)
}
IconButton(onClick = onClearCarLocation) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription =
stringResource(id = R.string.forget_location),
)
}
}
},
)
}
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 android.widget.Toast
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.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.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
)
}
}
}
}
Scaffold(
topBar = {
CarTopBar(
currentLocation = currentLocation,
carLatLng = null,
onSetCarLocation = { TODO() },
onClearCarLocation = { TODO() },
onWalkToCar = { TODO() },
onGoToCurrentLocation = {
currentLocation?.let { curr ->
scope.launch {
cameraPositionState.animate(
CameraUpdateFactory.newLatLngZoom(
LatLng(curr.latitude, curr.longitude),
16f
), 1000
)
}
} ?: Toast.makeText(
context,
"No current location available",
Toast.LENGTH_LONG
).show()
},
)
},
content = { paddingValues ->
Box(
// modifier = modifier,
modifier = modifier.padding(paddingValues),
) {
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()
)
}
}
}
}
)
}
CHANGED: app/src/main/java/com/androidbyexample/compose/google/google/maps/MainActivity.kt
package com.androidbyexample.compose.google.google.maps
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.os.Looper
import android.provider.Settings
import android.widget.Toast
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.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.app.ActivityCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.androidbyexample.compose.google.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.GoogleMap
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 getLocationPermission =
registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { isGranted ->
if (isGranted.values.any { it }) {
startLocationAndMap()
} else {
// if the user denied permissions, tell them they
// cannot use the app without them. In general,
// you should try to just reduce function and let the
// user continue, but location is a key part of this
// application.
// (Note that a real version of this application
// might allow the user to manually click on the map
// to set their current location, and we wouldn't
// show this dialog, or perhaps only show it once)
// NOTE: This is a normal Android-View-based dialog, not a compose one!
AlertDialog.Builder(this)
.setTitle("Permissions Needed")
.setMessage(
"We need coarse-location or fine-location permission " +
"to locate a car (fine location is highly " +
"recommended for accurate car locating). " +
"Please allow these permissions via App Info " +
"settings")
.setCancelable(false)
.setNegativeButton("Quit") { _, _ -> finish() }
.setPositiveButton("App Info") { _, _ ->
startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS
).apply {
data = Uri.parse("package:$packageName")
}
)
finish()
}
.show()
}
}
@SuppressLint("MissingPermission")
fun startLocationAndMap() {
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()
)
enableEdgeToEdge()
setContent {
GoogleMapsTheme {
// Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
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,
// modifier = Modifier.padding(innerPadding).fillMaxSize(),
modifier = Modifier.fillMaxSize(),
)
}
}
// }
// }
// }
//
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GoogleApiAvailability.getInstance()
.makeGooglePlayServicesAvailable(this)
.addOnSuccessListener {
if (ActivityCompat.checkSelfPermission(
this,
android.Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED &&
ActivityCompat.checkSelfPermission(
this,
android.Manifest.permission.ACCESS_COARSE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
getLocationPermission.launch(
arrayOf(
android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.ACCESS_COARSE_LOCATION,
)
)
} else {
startLocationAndMap()
}
}.addOnFailureListener(this) {
Toast.makeText(
this,
"Google Play services required (or upgrade required)",
Toast.LENGTH_SHORT
).show()
finish()
}
//
}
}
CHANGED: app/src/main/res/values/strings.xml
<resources>
<string name="app_name">Google Maps</string>
<string name="map_type">Map Type</string>
<string name="current_location">Current Location</string>
<string name="go_to_current_location">Go to current location</string>
<string name="remember_location">Remember Location</string>
<string name="navigate">Walk to Car</string>
<string name="forget_location">Forget Location</string>
</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"
icons-extended = "1.7.5"
[libraries]
icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "icons-extended" }
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" }