Google Map
Current Location
Next, let's add the user's current location. A little later we'll use this for the initial map position and to keep track of where the user parked their car.
The user's Location is determined by the "Fused Location Provider". This service uses technologies such as GPS, cell towers, and wi-fi to determine the current location. Some of these provide precise location (such as GPS), while others might only be able to approximate user location.
Because an application could send location information somewhere else (a server on the internet, for example), location is considered a "dangerous" permission, and we must ask the user if it's ok to use while the application is running.
The user has a choice: they can allow precise or approximate location information, for all runs of the application or just the current run, or deny the request. Ideally, your application should gracefully handle denied function. For our car-finder application, if the user denies current location tracking, we could, for example, allow the user to tap the location of their car on the map rather than automatically using the current location. (For this example application, we won't do that; we'll just tell the user the application cannot function without location.)
Note
Requesting permissions at runtime can be a bit tricky, and support for permissions when using Jetpack Compose is still experimental (and a bit difficult to use properly). For now, we'll be requesting permissions and listening for location using the Activity
and updating a view model with the reported locations.
Any needed permissions must be declared first in the AndroidManifest.xml
. Here we declare
- Coarse Location (for "approximate" location)
- Fine Location (for "precise" location)
Both of these are "dangerous" permissions and must be requested at runtime.
The Google Play Location Services allows us to set up a listener to receive location updates. We'll want to store the current location somewhere, and we'll need it when setting the car's location. The car's location will be persisted and we want to ensure it stays across configuration changes. This sounds like a job for a View Model.
We access Play Location Services by adding a new dependency (with definition in the version catalog)
We'll need to track the location. We set up a MutableStateFlow
as a private
property in the view model. By convention, we prefix it with an underscore, indicating it's the actual flow that we'll emit to. We want our view model to keep control of emitted values, so we only expose a read-only Flow
publicly.
But our MainActivity
will be the thing that actually talks with the Fused Location Provider; it'll need to update the current location.
In the MainActivity
, we define properties for the fused location provider client and the callback that we'll register to receive location updates.
We have to set up a new sequence of events to start our activity. This sequence looks like
graph TD
A{Check Google Play Services}
X[Cannot run - exit]
X2[Cannot run - exit]
A -->|Not Available|X
A -->|Available|B
B{Do we have location permission?}
B -->|Yes|C
B -->|No|E
C[Start location request]
C --> D
D[Setup Map]
E[Request Location Permissions]
E --> F
F{Were permissions granted?}
F -->|No|G
F -->|Yes|C
G[Show rationale]
G --> H
H{Does user agree?}
H -->|Yes|I
H -->|No|X2
I[Application Info Screen to change permissions - exits application]
We move the UI setup out of onCreate
(we'll see it again soon), and check Google Play Services Availability. If Play Services aren't available, we can't run and must exit.
If Play Services are available, we check if we have location permissions. If not, we request permission. If so, we just go ahead and start our location request and set up the UI.
Requesting permissions has gotten a bit easier over the years... We register a launcher that will invoke the system permission requester and return whether the user granted or denied permissions. If granted, we go ahead and start the location request and set up the UI. If not granted, we go into rationale mode.
We've decided that our car finder simply cannot run without knowing the current location, so we display this rationale to the user in a dialog. (Note that this is an old-style "views" dialog, not a Jetpack Compose dialog.) The user can agree to go change the permissions (and we'll send them to the system Application Info screen to do so), or they can say "nope!" and we'll quit the application by calling finish()
on the Activity
(which pops it off the back stack - because we only have one Activity
on the back stack, the application exits).
After all of the availability and permission checking, we can finally start the application in startLocationAndMap()
. The first thing we do is request location updates from the fused location provider, which will use our locationCallback
to send the results to the view model. This will emit the location, which is collected and made available to our composition.
We pass the location to the GoogleMapDisplay
and create a MarkerState
to give us a location for a new Marker
.
When we run the app, the location marker appears when it's available.
You can use the "Extended Controls" at the top of the emulator to set a fake location.
Code Changes
CHANGED: /app/build.gradle.kts
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.kotlinAndroid)alias(libs.plugins.secrets)} kotlin { jvmToolchain(17) } android { namespace = "com.androidbyexample.googlemap" compileSdk = 34 defaultConfig { applicationId = "com.androidbyexample.googlemap" minSdk = 24 targetSdk = 34 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } } buildTypes { release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.3" } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } } dependencies {implementation(libs.location.services) implementation(libs.runtime.compose)implementation(libs.maps.compose) implementation(libs.maps.compose.utils)implementation(libs.core.ktx) implementation(libs.lifecycle.runtime.ktx) implementation(libs.activity.compose) implementation(platform(libs.compose.bom)) implementation(libs.ui) implementation(libs.ui.graphics) implementation(libs.ui.tooling.preview) implementation(libs.material3) testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.ui.test.junit4) debugImplementation(libs.ui.tooling) debugImplementation(libs.ui.test.manifest) }
CHANGED: /app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"><uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /><application android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.GoogleMap" tools:targetApi="31"><meta-data android:name="com.google.android.geo.API_KEY" android:value="${MAPS_API_KEY}" /><activity android:name=".MainActivity" android:exported="true" android:label="@string/app_name" android:theme="@style/Theme.GoogleMap"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>
ADDED: /app/src/main/java/com/androidbyexample/googlemap/CarViewModel.kt
package com.androidbyexample.googlemapimport android.app.Applicationimport android.location.Locationimport androidx.lifecycle.AndroidViewModelimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.MutableStateFlowclass CarViewModel(application: Application) : AndroidViewModel(application) {private val _currentLocation = MutableStateFlow<Location?>(null) val currentLocation: Flow<Location?> get() = _currentLocationfun updateLocation(location: Location?) { _currentLocation.value = location }}
CHANGED: /app/src/main/java/com/androidbyexample/googlemap/MainActivity.kt
package com.androidbyexample.googlemap import android.Manifestimport android.annotation.SuppressLintimport android.app.AlertDialogimport android.content.Intentimport android.content.pm.PackageManagerimport android.location.Locationimport android.net.Uriimport android.os.Bundle import android.os.Looperimport android.provider.Settingsimport android.widget.Toastimport androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContractsimport androidx.activity.viewModelsimport 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.material3.Surface 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.compose.ui.geometry.Offsetimport androidx.compose.ui.res.stringResourceimport androidx.core.app.ActivityCompatimport androidx.lifecycle.compose.collectAsStateWithLifecycleimport com.androidbyexample.googlemap.ui.theme.GoogleMapTheme import com.google.android.gms.common.GoogleApiAvailabilityimport com.google.android.gms.location.FusedLocationProviderClientimport com.google.android.gms.location.LocationCallbackimport com.google.android.gms.location.LocationRequestimport com.google.android.gms.location.LocationResultimport com.google.android.gms.location.LocationServicesimport com.google.android.gms.location.Priorityimport com.google.android.gms.maps.model.CameraPosition 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.MarkerStateimport com.google.maps.android.compose.rememberCameraPositionState//import com.google.maps.android.compose.rememberMarkerStateprivate val googleHQ = LatLng(37.42423291057923, -122.08811454627153)private val defaultCameraPosition = CameraPosition.fromLatLngZoom(googleHQ, 11f)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() } }override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)GoogleApiAvailability.getInstance() .makeGooglePlayServicesAvailable(this) .addOnSuccessListener {} @SuppressLint("MissingPermission") fun startLocationAndMap() {if (ActivityCompat.checkSelfPermission( this, Manifest.permission.ACCESS_FINE_LOCATION ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission( this, Manifest.permission.ACCESS_COARSE_LOCATION ) != PackageManager.PERMISSION_GRANTED ) { getLocationPermission.launch( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) ) } else { startLocationAndMap() }}.addOnFailureListener(this) { Toast.makeText( this, "Google Play services required (or upgrade required)", Toast.LENGTH_SHORT ).show() finish() }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() )setContent { GoogleMapTheme { // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) {val cameraPositionState = rememberCameraPositionState { position = defaultCameraPosition }val currentLocation by viewModel.currentLocation.collectAsStateWithLifecycle( initialValue = null )GoogleMapDisplay( currentLocation = currentLocation, place = googleHQ, placeDescription = "Google HQ", cameraPositionState = cameraPositionState, modifier = Modifier.fillMaxSize(), )} } } } }@Composable fun GoogleMapDisplay( currentLocation: Location?, place: LatLng, placeDescription: String, 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)) }Box(// val placeState =// rememberMarkerState(key = place.toString(), position = place)val currentLocationState = remember(currentLocation) { currentLocation?.let { MarkerState( LatLng( it.latitude, it.longitude ) ) } }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 },properties = mapProperties,) {modifier = Modifier .fillMaxWidth() .weight(1f),currentLocationState?.let { MarkerInfoWindowContent(} } }// state = placeState,// title = placeDescription,// onClick = {// placeState.showInfoWindow()// true// }state = it,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/res/values/strings.xml
<resources> <string name="app_name">Google map</string> <string name="map_type">Map Type</string> <string name="current_location">Current Location</string></resources>
CHANGED: /gradle/libs.versions.toml
[versions] agp = "8.2.0-beta06" kotlin = "1.9.10" core-ktx = "1.12.0" junit = "4.13.2" androidx-test-ext-junit = "1.1.5" espresso-core = "3.5.1" lifecycle-runtime-ktx = "2.6.2" activity-compose = "1.8.0" compose-bom = "2023.10.00"secrets = "2.0.1"maps-compose = "3.0.0" # bug in 3.1.0 - map only takes up 50% of columnlocation-services = "21.0.1"runtime-compose = "2.6.2" [libraries] core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } ui = { group = "androidx.compose.ui", name = "ui" } ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } material3 = { group = "androidx.compose.material3", name = "material3" }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" }location-services = { group = "com.google.android.gms", name = "play-services-location", version.ref = "location-services" }runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "runtime-compose" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }