Google Map
Dragging a Marker
Sometimes the location isn't as accurate as we'd like. For example, if you're driving in New York City, GPS signals can bounce off the tall buildings making it appear you're in a different location (this is called the "canyon effect"). Or perhaps the user selected "approximate" location.
If we allow the user to drag the marker, they can position it more accurately to where the car is located.
The first thing we need to do is tell Google Map that the icon should be draggable. The user can then long-press on the marker to activate drag mode and move it to another location. The MarkerState is updated with the new location.
show in full file app/src/main/java/com/androidbyexample/google/maps/GoogleMapDisplay.kt
// ...
@Composable
fun GoogleMapDisplay(
// ...
) {
// ...
Box(
// ...
) {
Column(
// ...
) {
// ...
GoogleMap(
// ...
) {
// ...
carLatLng?.let {
// ...
MarkerInfoWindowContent(
state = carState,
draggable = true,
icon = carIcon,
anchor = Offset(0.5f, 0.5f),
// ...
)
}
}
}
// ...
}
}
Note
When dragging a marker, it pops up a bit to clear your finger (so you can see it). If you're using an emulator with a mouse, this may seem odd, but think about visibility of the marker when you're using a finger on a real device.
We need to watch for changes to the marker position and report it via an onMoveCar event
parameter.
First, we need a new function in the view model to explicitly set the car's location.
show in full file app/src/main/java/com/androidbyexample/google/maps/CarViewModel.kt
// ...
class CarViewModel(application: Application) : AndroidViewModel(application) {
// ...
}
fun setCarLocation(latLng: LatLng) {
viewModelScope.launch {
getApplication<Application>().preferencesDataStore.edit { preferences ->
preferences[LAT_PREF] = latLng.latitude.toString()
preferences[LON_PREF] = latLng.longitude.toString()
}
}
}
}
We can pass it as an event parameter to the GoogleMapDisplay
show in full file app/src/main/java/com/androidbyexample/google/maps/GoogleMapDisplay.kt
// ...
@Composable
fun GoogleMapDisplay(
// ...
carLatLng: LatLng?,
cameraPositionState: CameraPositionState,
onMoveCar: (LatLng) -> Unit,
modifier: Modifier,
animateToCurrentLocation: Boolean,
// ...
) {
// ...
}
And let's wire it up to the view model
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
// ...
GooglemapsTheme {
Scaffold(
// ...
) { innerPadding ->
// ...
when (appState) {
// ...
ShowMap ->
ShowMap(
// ...
animateToCurrentLocation = false
},
// modifier = modifier
onMoveCar = viewModel::setCarLocation,
modifier = modifier,
currentLocation = currentLocation,
)
// ...
}
}
}
}
}
// ...
@Composable
fun ShowMap(
// ...
animateToCurrentLocation: Boolean,
onAnimatedToCurrentLocation: () -> Unit,
onMoveCar: (LatLng) -> Unit,
modifier: Modifier,
currentLocation: Location?,
) {
// ...
GoogleMapDisplay(
// ...
animateToCurrentLocation = animateToCurrentLocation,
onAnimatedToCurrentLocation = onAnimatedToCurrentLocation,
onMoveCar = onMoveCar,
modifier = modifier.fillMaxSize(),
)
}
// ...
}
The Google Maps Compose API doesn't directly expose an event function on MarkerState,
so we have to be a little more clever. 'MarkerState' is a Jetpack Compose state holder. Its
properties are delegated to Compose State (using by mutableStateOf(...)). From the outside,
they look like normal properties.
The cool trick is that the getter and setter for these properties inform the snapshot system of who is reading the state and when the state changes.
We can ask the snapshot system to tell up about changes using snapshotFlow.
show in full file app/src/main/java/com/androidbyexample/google/maps/GoogleMapDisplay.kt
// ...
@Composable
fun GoogleMapDisplay(
// ...
) {
// ...
Box(
// ...
) {
Column(
// ...
) {
// ...
val scope = rememberCoroutineScope()
LaunchedEffect(true) {
var draggedAtLeastOnce = false
snapshotFlow { carState.isDragging }
.collect { dragging ->
// Make sure we've seen at least one drag state before updating
// the view model. Otherwise, we'll see the initial (0.0, 0.0)
// value that was set when the MarkerState was created
if (dragging) {
draggedAtLeastOnce = true
} else if (draggedAtLeastOnce) {
draggedAtLeastOnce = false
onMoveCar(carState.position)
}
}
}
var initialBoundsSet by remember { mutableStateOf(false) }
// ...
}
// ...
}
}
The gist of this function is
snapshotFlowcreates a newFlowto report changes to the value of its lambda's value.- An observer is set up to be notified whenever a new snapshot is taken. This can happen for any Compose state change, but is often applied to a group of state changes.
- Whenever a new snapshot is taken, the lambda passed to
snapshotFlowis evaluated to get a value. - The value is compared to the lambda's previous value (if any). If it's changed, the new value
is emitted to the created
Flow
There are two properties that we're interested in here:
isDragging- whether the icon is being dragged.position- the current position of the marker.
When the MarkerState is initially set up, its is assigned a position of (0.0, 0.0). The
isDragging property will be false, meaning the marker has a position, but we do not want to
treat that as a location. We watch isDragging to see when the marker is being dragged.
Once we've seen it has been dragged at least once, we know that when we see isDragging == false
we have a new position that we should care about and move the car to it. We pass that position
to onCarMoved so the view model can be informed to update the data store with the new position.
After implementing this, when we press the star button to remember the car's location, the value is persisted to the datastore. When we quit the application and later return to it, the car location is loaded and displayed.
All code changes
$CHANGED: app/src/main/java/com/androidbyexample/google/maps/CarViewModel.kt
package com.androidbyexample.google.maps
import android.app.Application
import android.content.Context
import android.location.Location
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.gms.maps.model.LatLng
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
private val Context.preferencesDataStore:
DataStore<Preferences> by preferencesDataStore(name = "carfinder")
private val LAT_PREF = stringPreferencesKey("lat")
private val LON_PREF = stringPreferencesKey("lon")
class CarViewModel(application: Application) : AndroidViewModel(application) {
val appState: Flow<AppState>
field = MutableStateFlow<AppState>(Startup)
fun updateAppState(state: AppState) {
appState.value = state
}
val currentLocation: Flow<Location?>
field = MutableStateFlow<Location?>(null)
fun updateLocation(location: Location?) {
currentLocation.value = location
}
val carLatLng = application.preferencesDataStore.data.map { preferences ->
preferences[LAT_PREF]?.let { latString ->
preferences[LON_PREF]?.let { lonString ->
LatLng(latString.toDouble(), lonString.toDouble())
}
}
}
fun clearCarLocation() {
viewModelScope.launch {
getApplication<Application>().preferencesDataStore.edit { preferences ->
preferences.remove(LAT_PREF)
preferences.remove(LON_PREF)
}
}
}
fun setCarLocation() {
viewModelScope.launch {
currentLocation.value?.let { location ->
getApplication<Application>().preferencesDataStore.edit { preferences ->
preferences[LAT_PREF] = location.latitude.toString()
preferences[LON_PREF] = location.longitude.toString()
}
} ?: run {
clearCarLocation()
}
}
}
fun setCarLocation(latLng: LatLng) {
viewModelScope.launch {
getApplication<Application>().preferencesDataStore.edit { preferences ->
preferences[LAT_PREF] = latLng.latitude.toString()
preferences[LON_PREF] = latLng.longitude.toString()
}
}
}
}
$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.runtime.snapshotFlow
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.rememberUpdatedMarkerState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun GoogleMapDisplay(
currentLocation: Location?,
carLatLng: LatLng?,
cameraPositionState: CameraPositionState,
onMoveCar: (LatLng) -> Unit,
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 carState = rememberUpdatedMarkerState()
var carIcon by remember { mutableStateOf<BitmapDescriptor?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(true) {
var draggedAtLeastOnce = false
snapshotFlow { carState.isDragging }
.collect { dragging ->
// Make sure we've seen at least one drag state before updating
// the view model. Otherwise, we'll see the initial (0.0, 0.0)
// value that was set when the MarkerState was created
if (dragging) {
draggedAtLeastOnce = true
} else if (draggedAtLeastOnce) {
draggedAtLeastOnce = false
onMoveCar(carState.position)
}
}
}
var initialBoundsSet by remember { mutableStateOf(false) }
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
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
)
carIcon =
context.loadBitmapDescriptor(
R.drawable.ic_car
)
}
},
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),
)
}
carLatLng?.let {
carState.position = it
MarkerInfoWindowContent(
state = carState,
draggable = true,
icon = carIcon,
anchor = Offset(0.5f, 0.5f),
title = stringResource(R.string.car_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(
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
)
val carLatLng by viewModel.carLatLng.collectAsStateWithLifecycle(
initialValue = null
)
var animateToCurrentLocation by remember { mutableStateOf(false) }
GooglemapsTheme {
Scaffold(
topBar = {
CarTopBar(
currentLocation = currentLocation,
carLatLng = carLatLng,
onSetCarLocation = viewModel::setCarLocation,
onGoToCurrentLocation = {
animateToCurrentLocation = true
},
onClearCarLocation = viewModel::clearCarLocation,
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(
// currentLocation = currentLocation,
carLatLng = carLatLng,
animateToCurrentLocation = animateToCurrentLocation,
onAnimatedToCurrentLocation = {
animateToCurrentLocation = false
},
// modifier = modifier
onMoveCar = viewModel::setCarLocation,
modifier = modifier,
currentLocation = currentLocation,
)
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(
carLatLng: LatLng?,
animateToCurrentLocation: Boolean,
onAnimatedToCurrentLocation: () -> Unit,
onMoveCar: (LatLng) -> Unit,
modifier: Modifier,
currentLocation: Location?,
) {
val googleHQ = LatLng(37.42423291057923, -122.08811454627153)
val defaultCameraPosition = CameraPosition.fromLatLngZoom(googleHQ, 11f)
val cameraPositionState = rememberCameraPositionState {
position = defaultCameraPosition
}
GoogleMapDisplay(
carLatLng = carLatLng,
currentLocation = currentLocation,
cameraPositionState = cameraPositionState,
animateToCurrentLocation = animateToCurrentLocation,
onAnimatedToCurrentLocation = onAnimatedToCurrentLocation,
onMoveCar = onMoveCar,
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()
)
}
}
}