Google Map
Lat/Lon Bounds
We've seen how Google maps lets you animate panning and zooming to a location. You can also do this to a region defined by two points.
LatLngBounds
is an immutable type that you can use to expand a region as points are added to it.
Think about a real-estate application that has a list of houses for sale, and you'd like to show
all of the houses on the map. You would start with a single point
var bounds = LatLngBounds(point1, point1)
and then expand it by including other points:
bounds = bounds.including(nextPoint)
Note that including
returns a a new instance of LatLngBounds
; it does not update the
existing instance.
We can LatLngBounds
to include the car and current location if both are defined. Here we've
expanded the auto-location logic that was based on the currentLocation
to check if a car location
had been saved. If so, we animate to a LatLngBounds
; if not, we animate to just the current
location with a zoom level.
When moving the camera position to a LatLngBounds
, you must pass in a pixel value for how much
space to leave around the edges of the map. Pixel values aren't consistent across devices, as many
devices have different screen densities. We'd like to use density-independent pixels, but that
means we need to convert dp to px.
Using LocalDensity
, we can ask for the current screen density and use it access the toPx()
function. Now we can have a consistent 48.dp
map margin on all devices.
show in full file app/src/main/java/com/androidbyexample/compose/google/google/maps/GoogleMapDisplay.kt
// ...
@Composable
fun GoogleMapDisplay(
// ...
modifier: Modifier,
) {
with(LocalDensity.current) {
val boundsPadding = 48.dp.toPx()
var mapLoaded 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)
carLatLng?.let { car ->
val bounds =
LatLngBounds(current, current).including(car)
cameraPositionState.animate(
CameraUpdateFactory.newLatLngBounds(
bounds,
boundsPadding.toInt()
), 1000
)
} ?: run {
cameraPositionState.animate(
CameraUpdateFactory.newLatLngZoom(
current,
16f
), 1000
)
}
}
}
}
}
// ...
}
}
All code changes
CHANGED: app/src/main/java/com/androidbyexample/compose/google/google/maps/GoogleMapDisplay.kt
package com.androidbyexample.compose.google.google.maps
import android.content.Intent
import android.location.Location
import android.net.Uri
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.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
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.android.gms.maps.model.LatLngBounds
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,
) {
with(LocalDensity.current) {
val boundsPadding = 48.dp.toPx()
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)
carLatLng?.let { car ->
val bounds =
LatLngBounds(current, current).including(car)
cameraPositionState.animate(
CameraUpdateFactory.newLatLngBounds(
bounds,
boundsPadding.toInt()
), 1000
)
} ?: run {
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,
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()
)
}
}
}
}
)
}
}