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.
Note
When dragging a marker, it pops up 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 maker when you're using a finger on a real device.
We need to watch for changes to the marker position and report it via onMoveCar. 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
. The gist of this function is
snapshotFlow
creates a newFlow
to 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
snapshotFlow
is 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:
dragState
- whether the icon is being started dragging, being actively dragged, or finished dragging.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 dragState
will be "END", meaning the marker has a position, but we do not want to treat that as a location. We watch the dragState
to see when the marker is being dragged. Once we've seen it dragged, we know that the next "END" represents a position
that we should care about. 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.
Code Changes
CHANGED: /app/src/main/java/com/androidbyexample/googlemap/CarViewModel.kt
package com.androidbyexample.googlemap 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 LAT_PREF = stringPreferencesKey("lat") private val LON_PREF = stringPreferencesKey("lon") class CarViewModel(application: Application) : AndroidViewModel(application) {private val _currentLocation = MutableStateFlow<Location?>(null) val currentLocation: Flow<Location?> get() = _currentLocationfun updateLocation(location: Location?) { _currentLocation.value = location }private val Context.preferencesDataStore: DataStore<Preferences> by preferencesDataStore(name = "carfinder") 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/googlemap/MainActivity.kt
package com.androidbyexample.googlemap import android.Manifest import android.annotation.SuppressLint import android.app.AlertDialog import android.content.Intent import android.content.pm.PackageManager import android.location.Location 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.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.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.material3.Surface 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.snapshotFlowimport androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.core.app.ActivityCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.androidbyexample.googlemap.ui.theme.GoogleMapTheme 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.CameraUpdateFactory import com.google.android.gms.maps.model.BitmapDescriptor import 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.DragStateimport 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.rememberCameraPositionState import com.google.maps.android.compose.rememberMarkerState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch 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()val currentLocation by viewModel.currentLocation.collectAsStateWithLifecycle( initialValue = null )val carLatLng by viewModel.carLatLng.collectAsStateWithLifecycle(initialValue = null)GoogleMapDisplay( currentLocation = currentLocation, carLatLng = carLatLng, cameraPositionState = cameraPositionState, onSetCarLocation = viewModel::setCarLocation, onClearCarLocation = viewModel::clearCarLocation, onMoveCar = viewModel::setCarLocation, modifier = Modifier.fillMaxSize(), )} } } } }@Composable fun GoogleMapDisplay( currentLocation: Location?, carLatLng: LatLng?, cameraPositionState: CameraPositionState, onSetCarLocation: () -> Unit, onClearCarLocation: () -> Unit,onMoveCar: (LatLng) -> Unit,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 carState = rememberMarkerState("car")val context = LocalContext.current var currentLocationIcon by remember { mutableStateOf<BitmapDescriptor?>(null) } var carIcon by remember { mutableStateOf<BitmapDescriptor?>(null) } val scope = rememberCoroutineScope()var initialBoundsSet by remember { mutableStateOf(false) } LaunchedEffect(key1 = currentLocation) { if (currentLocation != null) { if (!initialBoundsSet) { initialBoundsSet = true val current = LatLng(currentLocation.latitude, currentLocation.longitude) cameraPositionState.animate( CameraUpdateFactory.newLatLngZoom( current, 16f ), 1000 ) } } }LaunchedEffect(true) { var dragged = false snapshotFlow { carState.dragState } .collect { // 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 (it == DragState.DRAG) { dragged = true } else if (it == DragState.END && dragged) { dragged = false onMoveCar(carState.position) } } }Scaffold( topBar = { CarTopBar( currentLocation = currentLocation, carLatLng = carLatLng, onSetCarLocation = onSetCarLocation, onClearCarLocation = onClearCarLocation, 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.padding(paddingValues),) {Column( modifier = Modifier.fillMaxSize() ) { MapTypeSelector( currentValue = currentMapType, modifier = Modifier.fillMaxWidth(), ) { mapProperties = mapProperties.copy(mapType = it) currentMapType = it }GoogleMap(cameraPositionState = cameraPositionState,onMapLoaded = { mapLoaded = truescope.launch(Dispatchers.IO) { currentLocationIcon = context.loadBitmapDescriptor( R.drawable.ic_current_location ) carIcon = context.loadBitmapDescriptor( R.drawable.ic_car ) }},properties = mapProperties,) {modifier = Modifier .fillMaxWidth() .weight(1f),currentLocationState?.let { MarkerInfoWindowContent( state = it,carLatLng?.let { carState.position = it MarkerInfoWindowContent( state = carState,icon = currentLocationIcon, anchor = Offset(0.5f, 0.5f),title = stringResource( id = R.string.current_location ), ) }draggable = true,icon = carIcon, anchor = Offset(0.5f, 0.5f), title = stringResource( id = 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() ) } }} ) }