Jetpack Compose is gaining popularity and I personally think it is the future of Android or even multiplatform development. The library is now stable (version 1.0.1 now), however, there are still some of the view components that are not fully compatible with Compose. For those views, we can use @Composable
AndroidView component and manage the updates in a composable. This blog post will share how I made an Image with a google map snapshot that updates properly when the state changes.
Google Maps snapshot
The Google maps API provide a callback to prepare the snapshot bitmap from the currently visible map. To apply this, we need a view with Google Maps. To make it work with the Compose, we need to initialize the map in AndroidView. You can check how to use Map in Compose, investigating Crane app code from official google samples.
@Composable | |
fun GoogleMapSnapshot(location: LatLng) { | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(200.dp) | |
) { | |
val mapView = rememberMapViewWithLifecycle() | |
MapViewContainer( | |
map = mapView, | |
location = location | |
) | |
} | |
} | |
@Composable | |
private fun MapViewContainer( | |
map: MapView, | |
location: LatLng | |
) { | |
val coroutineScope = rememberCoroutineScope() | |
AndroidView({ map }) { mapView -> | |
coroutineScope.launch { | |
val googleMap = mapView.awaitMap() | |
val zoom = calculateZoom(cameraPosition) | |
googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(location, zoom)) | |
googleMap.addMarker { position(cameraPosition) } | |
} | |
} | |
} |
The above code will display an interactive map with a screen width and height of 200dp. Now we need to take a snapshot and put it into Image. GoogleMap has a public snapshot lambda function that returns android.graphics.Bitmap
. It uses SnapshotReadyCallback.
Since we are modifying the googleMap object in the update
callback of @Composable AndroidView()
, we cannot create an Image
there(Composable invocations can only happen from the context of a Composable function). Therefore Image will be put in parent composable function, Box. In this scenario, the bitmap will be assigned to the function field as follows:
val mapBitmap: MutableState<Bitmap?> = remember { mutableStateOf(null) }
The @Composable Image
cannot take bitmap as a parameter, but ImageBitmap. Luckily there is a converter function in compose graphics package.
Image( bitmap = mapBitmap.value.asImageBitmap(), contentDescription = "Map snapshot" )
Connecting all the blocks:
@Composable | |
fun MapSnapshot(map: MapView, location: LatLng) { | |
val mapBitmap: MutableState<Bitmap?> = remember { mutableStateOf(null) } | |
val coroutineScope = rememberCoroutineScope() | |
if (mapBitmap.value != null) { | |
Image( | |
bitmap = mapBitmap.value!!.asImageBitmap(), | |
contentDescription = "Map snapshot", | |
) | |
} else { | |
AndroidView({ map }) { mapView -> | |
coroutineScope.launch { | |
val googleMap = mapView.awaitMap() | |
val zoom = calculateZoom() | |
googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(location, zoom)) | |
googleMap.addMarker { position(location) } | |
googleMap.snapshot { | |
mapBitmap.value = it | |
} | |
} | |
} | |
} | |
} |
This code works nicely most of the time, but few things may cause some trouble. Firstly, the map sometimes is not rendered yet to take a snapshot. This really surprised me, cause we are configuring our map view in coroutine scope, calling moveCamera
, addMarker
and snapshot {}
and yet map can be displayed blank:
This occurs because only awaitMap
is suspend function. The rest of our config is simple java void methods, so we cannot be sure that the map is already positioned and rendered. We can fix that, adding another callback to the block, which is onMapLoaded
. Putting the snapshot lambda inside this callback will result in a proper map view rendered into our snapshot image.
googleMap.setOnMapLoadedCallback { googleMap.snapshot { mapBitmap.value = it } }
Job Offers
The second thing I encountered is the snapshot refreshing. Since the bitmap is held in remember{}
, the AndroidView, even if it would be updated will not change the Image. We need to trigger the update on variables change. To handle such side effects, the compose API provides a few functions for extra effects. Here is the official doc.
Since the map needs a cleanup, the best choice here would be DisposableEffect
. This effect runs every time a key parameter changes or if the DisposableEffect leaves the composition.
DisposableEffect(location) { mapBitmap.value = null onDispose { mapBitmap.value = null } }
Now, whenever the location changes, the mapBitmap is set to null and the Composable function recompose with new values. The AndroidView with map renders a new map and provides a new bitmap for Image.
@Composable | |
fun MapSnapshot(map: MapView, location: LatLng) { | |
val mapBitmap: MutableState<Bitmap?> = remember { mutableStateOf(null) } | |
val coroutineScope = rememberCoroutineScope() | |
if (mapBitmap.value != null) { | |
Image( | |
bitmap = mapBitmap.value!!.asImageBitmap(), | |
contentDescription = "Map snapshot", | |
) | |
} else { | |
AndroidView({ map }) { mapView -> | |
coroutineScope.launch { | |
val googleMap = mapView.awaitMap() | |
val zoom = calculateZoom() | |
googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(location, zoom)) | |
googleMap.addMarker { position(location) } | |
googleMap.snapshot { | |
mapBitmap.value = it | |
} | |
} | |
} | |
} | |
DisposableEffect(location) { | |
mapBitmap.value = null | |
onDispose { mapBitmap.value = null } | |
} | |
} |
Conclusion
In my opinion, the current support for views like Google Map is not ideal, but it works. The need for hacking
is low. My only concern is that every time we create a bitmap we have to display the map. It would be great if we could prepare this image on the background thread and display the bitmap when it’s ready. If you know how to do that, write me on Twitter or in response to this post. I will be happy to chat and learn.
Thanks, Damian Petla for the early review. 😉
Happy composing 💻