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/compose/google/google/maps/GoogleMapDisplay.kt
// ...

@Composable
fun GoogleMapDisplay(
    // ...
) {
    // ...
    Scaffold(
        // ...
        content = { paddingValues ->
            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/compose/google/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/compose/google/google/maps/GoogleMapDisplay.kt
// ...

@Composable
fun GoogleMapDisplay(
    // ...
    onSetCarLocation: () -> Unit,
    onClearCarLocation: () -> Unit,
    onMoveCar: (LatLng) -> Unit,
    modifier: Modifier,
) {
    // ...
}
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 {
                // ...
                GoogleMapDisplay(
                    // ...
                    onSetCarLocation = viewModel::setCarLocation,
                    onClearCarLocation = viewModel::clearCarLocation,
                    onMoveCar = viewModel::setCarLocation,
                    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/compose/google/google/maps/GoogleMapDisplay.kt
// ...

@Composable
fun GoogleMapDisplay(
    // ...
) {
    // ...
    }

    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)
                }
            }
    }

    Scaffold(
        // ...
}

The gist of this function is

  1. snapshotFlow creates a new Flow to report changes to the value of its lambda's value.
  2. 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.
  3. Whenever a new snapshot is taken, the lambda passed to snapshotFlow is evaluated to get a value.
  4. 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/compose/google/google/maps/CarViewModel.kt
package com.androidbyexample.compose.google.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

class CarViewModel(application: Application) : AndroidViewModel(application) { private val LAT_PREF = stringPreferencesKey("lat") private val LON_PREF = stringPreferencesKey("lon") private val _currentLocation = MutableStateFlow<Location?>(null) val currentLocation: Flow<Location?> get() = _currentLocation fun 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/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.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.rememberMarkerState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

@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 context = LocalContext.current var currentLocationIcon by remember { mutableStateOf<BitmapDescriptor?>(null) }
val carState = rememberMarkerState("car") var carIcon 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 ) } } } }
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) } } }
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 = true scope.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, icon = currentLocationIcon, anchor = Offset(0.5f, 0.5f), title = stringResource( id = R.string.current_location ), ) }
carLatLng?.let { carState.position = it MarkerInfoWindowContent( state = carState,
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() ) } } } } ) }
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 { 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 ) 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(), )
} } } 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() } } }