Google Map
Map Types
Google Map provides several types of tiles:
- Normal (Streets)
- Satellite Images
- Hybrid of Streets/Satellite
- Terrain
- No visible map
By adding a dropdown list at the top of the screen, the user can select from these types.
We create a new file to host the map-type selector. Not that because we're using the experimental
ExposedDropdownMenuBox
, so we need to declare we're ok with using experimental API.
show in full file app/src/main/java/com/androidbyexample/compose/google/google/maps/MapTypeSelector.kt
// ...
import com.google.maps.android.compose.MapType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapTypeSelector(
currentValue: MapType,
modifier: Modifier,
onMapTypeClick: (MapType) -> Unit,
) {
var expanded by remember {
mutableStateOf(false)
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = modifier,
) {
TextField(
value = currentValue.name,
label = {
Text(text = stringResource(id = R.string.map_type))
},
readOnly = true, // don't allow user to type
onValueChange = {}, // unused
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxWidth(),
) {
MapType.entries.forEach {
DropdownMenuItem(
text = { Text(text = it.name) },
onClick = {
onMapTypeClick(it)
expanded = false
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding
)
}
}
}
}
Caution
Using experimental APIs can get you access to cool new features, but that API is not yet stable. If you use these in your application, there's a chance the API will change and you'll have to rewrite parts of your application. Use with care (if at all)!
The standard unidirectional data flow and externalized state patterns of Composable functions is
again visible, as we need to declare expansion state and manage it in the call to
ExposedDropdownMenuBox
. The expansion state is not needed outside of MapTypeSelector
so we
keep it defined locally.
show in full file app/src/main/java/com/androidbyexample/compose/google/google/maps/MapTypeSelector.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapTypeSelector(
// ...
onMapTypeClick: (MapType) -> Unit,
) {
var expanded by remember {
mutableStateOf(false)
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = modifier,
) {
// ...
}
}
We're using a read-only TextField
to display the current map type. This gives us the nice little
label at the top while showing the current value in a larger font. Note the use of the
trailingIcon
parameter to add the expansion indicator for the drop-down list.
show in full file app/src/main/java/com/androidbyexample/compose/google/google/maps/MapTypeSelector.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapTypeSelector(
// ...
) {
// ...
ExposedDropdownMenuBox(
// ...
modifier = modifier,
) {
TextField(
value = currentValue.name,
label = {
Text(text = stringResource(id = R.string.map_type))
},
readOnly = true, // don't allow user to type
onValueChange = {}, // unused
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
// ...
}
}
Setting the up the expansion menu should start to look similar to other controls. We tell it if
it's expanded
or not, and it tells us if the user wanted to change the expansion state.
Inside, we loop through the possible values of MapType
(it's an enumeration), creating each
drop-down item.
show in full file app/src/main/java/com/androidbyexample/compose/google/google/maps/MapTypeSelector.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapTypeSelector(
// ...
) {
// ...
ExposedDropdownMenuBox(
// ...
) {
// ...
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxWidth(),
) {
MapType.entries.forEach {
DropdownMenuItem(
text = { Text(text = it.name) },
onClick = {
onMapTypeClick(it)
expanded = false
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding
)
}
}
}
}
When the user clicks an item, we tell the caller which MapType
was clicked and close the
drop-down menu.
Back in our GoogleMapDisplay
, we keep track of the MapType
. We need to share that MapType
between the GoogleMap
and the MapTypeSelector
; defining it here makes it available to both.
show in full file app/src/main/java/com/androidbyexample/compose/google/google/maps/GoogleMapDisplay.kt
// ...
@Composable
fun GoogleMapDisplay(
// ...
) {
var mapLoaded by remember { mutableStateOf(false) }
var currentMapType by remember { mutableStateOf(MapType.NORMAL) }
var mapProperties by remember {
mutableStateOf(MapProperties(mapType = MapType.NORMAL))
}
val placeState =
// ...
}
For our layout, we'll wrap the GoogleMap
and MapTypeSelector
in a Column
. The
MapTypeSelector
stays a fixed size,
show in full file app/src/main/java/com/androidbyexample/compose/google/google/maps/GoogleMapDisplay.kt
// ...
@Composable
fun GoogleMapDisplay(
// ...
) {
// ...
Box(
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.fillMaxSize(),
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
MarkerInfoWindowContent(
state = placeState,
title = placeDescription,
onClick = {
placeState.showInfoWindow()
true
}
)
}
}
if (!mapLoaded) {
AnimatedVisibility(
visible = true,
modifier = Modifier.fillMaxSize(),
enter = EnterTransition.None,
exit = fadeOut()
) {
CircularProgressIndicator(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.wrapContentSize()
)
}
}
}
}
while we set the GoogleMap
weight to fill the remaining height (and tell it to fill the
available width).
show in full file app/src/main/java/com/androidbyexample/compose/google/google/maps/GoogleMapDisplay.kt
// ...
@Composable
fun GoogleMapDisplay(
// ...
) {
// ...
Box(
// ...
) {
Column(
// ...
) {
// ...
GoogleMap(
// ...
},
properties = mapProperties,
// modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
MarkerInfoWindowContent(
// ...
}
}
// ...
}
}
The GoogleMap
uses a MapProperties
state holder to track the map type (among other options).
We'll initialize it with the "normal" (streets-only) map type, and pass it to the GoogleMap
.
show in full file app/src/main/java/com/androidbyexample/compose/google/google/maps/GoogleMapDisplay.kt
// ...
@Composable
fun GoogleMapDisplay(
// ...
) {
// ...
Box(
// ...
) {
Column(
// ...
) {
// ...
GoogleMap(
cameraPositionState = cameraPositionState,
onMapLoaded = {
mapLoaded = true
},
properties = mapProperties,
// modifier = Modifier.fillMaxSize(),
modifier = Modifier
// ...
) {
// ...
}
}
// ...
}
}
The resulting application now looks like this (when "Normal" map type is selected)
and this (when "Satellite" map type is selected)
All code changes
CHANGED: app/src/main/java/com/androidbyexample/compose/google/google/maps/GoogleMapDisplay.kt
package com.androidbyexample.compose.google.google.maps
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
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.rememberMarkerState
@Composable
fun GoogleMapDisplay(
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))
}
val placeState =
rememberMarkerState(key = place.toString(), position = place)
Box(
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.fillMaxSize(),
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
MarkerInfoWindowContent(
state = placeState,
title = placeDescription,
onClick = {
placeState.showInfoWindow()
true
}
)
}
}
if (!mapLoaded) {
AnimatedVisibility(
visible = true,
modifier = Modifier.fillMaxSize(),
enter = EnterTransition.None,
exit = fadeOut()
) {
CircularProgressIndicator(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.wrapContentSize()
)
}
}
}
}
ADDED: app/src/main/java/com/androidbyexample/compose/google/google/maps/MapTypeSelector.kt
package com.androidbyexample.compose.google.google.maps
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
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.res.stringResource
import com.google.maps.android.compose.MapType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapTypeSelector(
currentValue: MapType,
modifier: Modifier,
onMapTypeClick: (MapType) -> Unit,
) {
var expanded by remember {
mutableStateOf(false)
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = modifier,
) {
TextField(
value = currentValue.name,
label = {
Text(text = stringResource(id = R.string.map_type))
},
readOnly = true, // don't allow user to type
onValueChange = {}, // unused
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
modifier = Modifier.menuAnchor().fillMaxWidth()
)
}
}
CHANGED: app/src/main/res/values/strings.xml
<resources>
<string name="app_name">Google Maps</string>
<string name="map_type">Map Type</string>
</resources>