diff --git a/maps-app/src/main/AndroidManifest.xml b/maps-app/src/main/AndroidManifest.xml index d95e8c0a..2ae33d7c 100644 --- a/maps-app/src/main/AndroidManifest.xml +++ b/maps-app/src/main/AndroidManifest.xml @@ -58,6 +58,9 @@ + diff --git a/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt b/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt index 6f1bef82..a5d2dc37 100644 --- a/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt +++ b/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp import com.google.maps.android.compose.markerexamples.AdvancedMarkersActivity import com.google.maps.android.compose.markerexamples.MarkerClusteringActivity import com.google.maps.android.compose.markerexamples.MarkerClusteringRotationActivity +import com.google.maps.android.compose.markerexamples.MarkerComposablePerformanceActivity import com.google.maps.android.compose.markerexamples.draggablemarkerscollectionwithpolygon.DraggableMarkersCollectionWithPolygonActivity import com.google.maps.android.compose.markerexamples.markerdragevents.MarkerDragEventsActivity import com.google.maps.android.compose.markerexamples.markerscollection.MarkersCollectionActivity @@ -173,6 +174,11 @@ sealed class ActivityGroup( R.string.draggable_markers_collection_with_polygon_activity_description, DraggableMarkersCollectionWithPolygonActivity::class ), + Activity( + R.string.marker_composable_performance_activity, + R.string.marker_composable_performance_activity_description, + MarkerComposablePerformanceActivity::class + ), ) ) diff --git a/maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerComposablePerformanceActivity.kt b/maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerComposablePerformanceActivity.kt new file mode 100644 index 00000000..6940b53a --- /dev/null +++ b/maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerComposablePerformanceActivity.kt @@ -0,0 +1,469 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.markerexamples + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.MapProperties +import com.google.maps.android.compose.MapType +import com.google.maps.android.compose.MapUiSettings +import com.google.maps.android.compose.MapsComposeExperimentalApi +import com.google.maps.android.compose.MarkerComposable +import com.google.maps.android.compose.rememberCameraPositionState +import com.google.maps.android.compose.rememberUpdatedMarkerState +import com.google.maps.android.compose.singapore +import com.google.maps.android.compose.theme.MapsComposeSampleTheme +import kotlinx.coroutines.delay +import kotlin.math.cos +import kotlin.math.sin +import kotlin.random.Random + +/** + * Data model for a dynamic marker rendered using [MarkerComposable]. + */ +@Immutable +data class PerformanceMarkerItem( + val id: Int, + val position: LatLng, + val counter: Int, + val colorIndex: Int +) + +private val badgeColors = listOf( + Color(0xFF1E88E5), // Blue + Color(0xFF43A047), // Green + Color(0xFFE53935), // Red + Color(0xFFFB8C00), // Orange + Color(0xFF8E24AA), // Purple + Color(0xFF00ACC1), // Cyan +) + +/** + * Demonstrates high-performance rendering and rapid state updates with [MarkerComposable]. + * Shows how ComposeView reuse and non-blocking bitmap creation eliminate UI jank when + * displaying 25-100 dynamically updating markers. + */ +class MarkerComposablePerformanceActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MapsComposeSampleTheme { + Surface(modifier = Modifier.fillMaxSize()) { + PerformanceScreen() + } + } + } + } +} + +@OptIn(MapsComposeExperimentalApi::class) +@Composable +private fun PerformanceScreen() { + var markerCount by remember { mutableIntStateOf(30) } + var updateTick by remember { mutableIntStateOf(0) } + var isAutoUpdating by remember { mutableStateOf(false) } + var isFastUpdating by remember { mutableStateOf(false) } + var showInfoDialog by remember { mutableStateOf(false) } + + // Generate initial grid/cluster of markers around Singapore center + val markers = remember(markerCount, updateTick) { + val center = singapore + (0 until markerCount).map { i -> + val angle = (i * 2.0 * Math.PI) / markerCount + val radius = 0.02 + (i % 4) * 0.015 + val lat = center.latitude + radius * sin(angle) + val lng = center.longitude + (radius * 1.3) * cos(angle) + PerformanceMarkerItem( + id = i + 1, + position = LatLng(lat, lng), + counter = updateTick + i, + colorIndex = (i + updateTick) % badgeColors.size + ) + } + } + + // Auto-update ticker effect + LaunchedEffect(isAutoUpdating, isFastUpdating) { + while (isAutoUpdating || isFastUpdating) { + val interval = if (isFastUpdating) 250L else 1000L + delay(interval) + updateTick++ + } + } + + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(singapore, 12f) + } + + Box(modifier = Modifier.fillMaxSize().systemBarsPadding()) { + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + properties = remember { MapProperties(mapType = MapType.NORMAL) }, + uiSettings = remember { MapUiSettings(zoomControlsEnabled = false) } + ) { + markers.forEach { item -> + val markerState = rememberUpdatedMarkerState(position = item.position) + MarkerComposable( + keys = arrayOf(item.id, item.counter, item.colorIndex), + state = markerState, + title = "Marker #${item.id}", + snippet = "Count: ${item.counter}" + ) { + MarkerBadgeComposable(item = item) + } + } + } + + // Top Header Info Card with Info Action + Card( + onClick = { showInfoDialog = true }, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column(modifier = Modifier.weight(1f, fill = false)) { + Text( + text = "MarkerComposable Performance", + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "$markerCount markers • Ticks: $updateTick • Tap for info", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton( + onClick = { showInfoDialog = true }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = "Demo Info", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + + // Explanation Dialog + if (showInfoDialog) { + AlertDialog( + onDismissRequest = { showInfoDialog = false }, + icon = { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { + Text( + text = "About MarkerComposable Demo", + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "This screen demonstrates the rendering performance of MarkerComposable when rendering multiple custom Compose-based markers with high-frequency dynamic updates.", + fontSize = 13.sp, + lineHeight = 18.sp + ) + + HorizontalDivider() + + Text( + text = "📍 What the Numbers Mean", + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + Text( + text = "• Left Circle (1–75): The Marker ID, uniquely identifying each marker in the collection.\n" + + "• Right Value (⚡ Bolt): The Live State Counter. Each time the state updates, this increments to prove real-time offscreen re-rasterization.\n" + + "• Badge Color: Cycles dynamically with state updates.", + fontSize = 13.sp, + lineHeight = 18.sp + ) + + HorizontalDivider() + + Text( + text = "🎛️ Interactive Controls", + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + Text( + text = "• Markers (10 / 25 / 50 / 75): Select how many markers are rendered concurrently.\n" + + "• Auto (1Hz): Automatically triggers state updates once every second.\n" + + "• Stress (4Hz): Stress-tests the UI thread with 4 full multi-marker state updates per second.\n" + + "• +1 Button: Manually steps all marker states forward by 1.", + fontSize = 13.sp, + lineHeight = 18.sp + ) + + HorizontalDivider() + + Text( + text = "⚡ Under the Hood Optimization", + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + Text( + text = "Instead of creating and destroying ComposeView instances for every render, each marker retains a single ComposeView across its lifecycle. Rasterization is deferred off the synchronous composition pass into LaunchedEffect, eliminating UI jank.", + fontSize = 13.sp, + lineHeight = 18.sp + ) + } + }, + confirmButton = { + TextButton(onClick = { showInfoDialog = false }) { + Text("Got it") + } + } + ) + } + + // Bottom Control Panel + Card( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + // Marker Count Selector + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Markers:", fontWeight = FontWeight.Medium) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + listOf(10, 25, 50, 75).forEach { count -> + FilterChip( + selected = markerCount == count, + onClick = { markerCount = count }, + label = { Text("$count") } + ) + } + } + } + + // Action Buttons & Auto-update switches + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Switch( + checked = isAutoUpdating, + onCheckedChange = { + isAutoUpdating = it + if (it) isFastUpdating = false + } + ) + Text(text = "Auto (1Hz)", fontSize = 13.sp) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Switch( + checked = isFastUpdating, + onCheckedChange = { + isFastUpdating = it + if (it) isAutoUpdating = false + } + ) + Text(text = "Stress (4Hz)", fontSize = 13.sp) + } + + Button( + onClick = { updateTick++ }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Update", + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = "+1", fontSize = 13.sp) + } + } + } + } + } +} + +/** + * Custom Composable rendered inside [MarkerComposable] offscreen. + */ +@Composable +private fun MarkerBadgeComposable(item: PerformanceMarkerItem) { + val targetColor = badgeColors[item.colorIndex] + val backgroundColor by animateColorAsState(targetValue = targetColor, label = "badgeColor") + + Box( + modifier = Modifier + .shadow(4.dp, RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(16.dp)) + .background(backgroundColor) + .border(2.dp, Color.White, RoundedCornerShape(16.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Box( + modifier = Modifier + .size(22.dp) + .clip(CircleShape) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Text( + text = "${item.id}", + color = Color.Black, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + textAlign = TextAlign.Center, + style = TextStyle( + platformStyle = PlatformTextStyle(includeFontPadding = false) + ) + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(1.dp) + ) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + Text( + text = "${item.counter}", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + textAlign = TextAlign.Center, + style = TextStyle( + platformStyle = PlatformTextStyle(includeFontPadding = false) + ) + ) + } + } + } +} diff --git a/maps-app/src/main/res/values/strings.xml b/maps-app/src/main/res/values/strings.xml index e0065b85..896d0441 100644 --- a/maps-app/src/main/res/values/strings.xml +++ b/maps-app/src/main/res/values/strings.xml @@ -84,6 +84,9 @@ Ground Overlay Adding a ground overlay to the map. + MarkerComposable Performance + High-performance rendering and rapid dynamic updates across 50+ MarkerComposable instances. + Map Types Map Features diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/Marker.kt b/maps-compose/src/main/java/com/google/maps/android/compose/Marker.kt index 40d329b8..a5c77241 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/Marker.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/Marker.kt @@ -341,27 +341,28 @@ public fun MarkerComposable( content: @UiComposable @Composable () -> Unit, ) { val icon = rememberComposeBitmapDescriptor(*keys) { content() } - - MarkerImpl( - state = state, - contentDescription = contentDescription, - alpha = alpha, - anchor = anchor, - draggable = draggable, - flat = flat, - icon = icon, - infoWindowAnchor = infoWindowAnchor, - rotation = rotation, - snippet = snippet, - tag = tag, - title = title, - visible = visible, - zIndex = zIndex, - onClick = onClick, - onInfoWindowClick = onInfoWindowClick, - onInfoWindowClose = onInfoWindowClose, - onInfoWindowLongClick = onInfoWindowLongClick, - ) + if (icon != null) { + MarkerImpl( + state = state, + contentDescription = contentDescription, + alpha = alpha, + anchor = anchor, + draggable = draggable, + flat = flat, + icon = icon, + infoWindowAnchor = infoWindowAnchor, + rotation = rotation, + snippet = snippet, + tag = tag, + title = title, + visible = visible, + zIndex = zIndex, + onClick = onClick, + onInfoWindowClick = onInfoWindowClick, + onInfoWindowClose = onInfoWindowClose, + onInfoWindowLongClick = onInfoWindowLongClick, + ) + } } /** @@ -489,27 +490,28 @@ public fun MarkerInfoWindowComposable( content: @UiComposable @Composable () -> Unit, ) { val icon = rememberComposeBitmapDescriptor(*keys) { content() } - - MarkerImpl( - state = state, - alpha = alpha, - anchor = anchor, - draggable = draggable, - flat = flat, - icon = icon, - infoWindowAnchor = infoWindowAnchor, - rotation = rotation, - snippet = snippet, - tag = tag, - title = title, - visible = visible, - zIndex = zIndex, - onClick = onClick, - onInfoWindowClick = onInfoWindowClick, - onInfoWindowClose = onInfoWindowClose, - onInfoWindowLongClick = onInfoWindowLongClick, - infoWindow = infoContent, - ) + if (icon != null) { + MarkerImpl( + state = state, + alpha = alpha, + anchor = anchor, + draggable = draggable, + flat = flat, + icon = icon, + infoWindowAnchor = infoWindowAnchor, + rotation = rotation, + snippet = snippet, + tag = tag, + title = title, + visible = visible, + zIndex = zIndex, + onClick = onClick, + onInfoWindowClick = onInfoWindowClick, + onInfoWindowClose = onInfoWindowClose, + onInfoWindowLongClick = onInfoWindowLongClick, + infoWindow = infoContent, + ) + } } /** diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/RememberComposeBitmapDescriptor.kt b/maps-compose/src/main/java/com/google/maps/android/compose/RememberComposeBitmapDescriptor.kt index 9fbc05e5..910e0979 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/RememberComposeBitmapDescriptor.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/RememberComposeBitmapDescriptor.kt @@ -20,41 +20,30 @@ import android.graphics.Canvas import android.view.View import android.view.ViewGroup import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCompositionContext import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalView import androidx.core.graphics.applyCanvas +import androidx.core.graphics.createBitmap import com.google.android.gms.maps.model.BitmapDescriptor import com.google.android.gms.maps.model.BitmapDescriptorFactory -import androidx.core.graphics.createBitmap @MapsComposeExperimentalApi @Composable public fun rememberComposeBitmapDescriptor( vararg keys: Any, content: @Composable () -> Unit, -): BitmapDescriptor { +): BitmapDescriptor? { + var bitmapDescriptor by remember { mutableStateOf(null) } val parent = LocalView.current as ViewGroup - val compositionContext = rememberCompositionContext() - val currentContent by rememberUpdatedState(content) - - return remember(parent, compositionContext, currentContent, *keys) { - renderComposableToBitmapDescriptor(parent, compositionContext, currentContent) - } -} - -private val measureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) - -private fun renderComposableToBitmapDescriptor( - parent: ViewGroup, - compositionContext: CompositionContext, - content: @Composable () -> Unit, -): BitmapDescriptor { - // Host the throwaway rendering ComposeView on the window's root view rather than on `parent` + // Host the rendering ComposeView on the window's root view rather than on `parent` // directly. `parent` (LocalView.current) can itself be mid-attach with no Android layout pass // performed on it yet -- e.g. when this is called from content composed inside a Clustering // item, which is first composed while its own hosting view is still being attached to its @@ -62,43 +51,59 @@ private fun renderComposableToBitmapDescriptor( // surrounding composition regardless of which Android View it's physically parented under, so // it's safe to use a different, already-laid-out ViewGroup as the Android host. val host = parent.rootView as? ViewGroup ?: parent - val canvasOfHolding = Canvas() + val compositionContext = rememberCompositionContext() + val currentContent by rememberUpdatedState(content) + val composeView = remember { ComposeView(host.context) } - val composeView = - ComposeView(host.context) + DisposableEffect(host) { + composeView .apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, ) + alpha = 0f setParentCompositionContext(compositionContext) - setContent(content) + setContent { currentContent() } } .also(host::addView) + onDispose { + host.removeView(composeView) + } + } - try { - // AndroidComposeView triggers LayoutNode's layout phase in the View draw phase, so trigger a - // draw to an empty canvas to force that. - composeView.draw(canvasOfHolding) - - composeView.measure(measureSpec, measureSpec) + LaunchedEffect(*keys) { + bitmapDescriptor = renderComposableToBitmapDescriptor(composeView) + } - if (composeView.measuredWidth == 0 || composeView.measuredHeight == 0) { - throw IllegalStateException( - "The ComposeView was measured to have a width or height of zero. " + - "Make sure that the content has a non-zero size." - ) - } + return bitmapDescriptor +} - composeView.layout(0, 0, composeView.measuredWidth, composeView.measuredHeight) +private val measureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) +private val canvasOfHolding = Canvas() - val bitmap = - createBitmap(composeView.measuredWidth, composeView.measuredHeight) +private fun renderComposableToBitmapDescriptor( + composeView: ComposeView, +): BitmapDescriptor { + // AndroidComposeView triggers LayoutNode's layout phase in the View draw phase, so trigger a + // draw to an empty canvas to force that. + composeView.draw(canvasOfHolding) - bitmap.applyCanvas { composeView.draw(this) } + composeView.measure(measureSpec, measureSpec) - return BitmapDescriptorFactory.fromBitmap(bitmap) - } finally { - host.removeView(composeView) + if (composeView.measuredWidth == 0 || composeView.measuredHeight == 0) { + throw IllegalStateException( + "The ComposeView was measured to have a width or height of zero. " + + "Make sure that the content has a non-zero size." + ) } + + composeView.layout(0, 0, composeView.measuredWidth, composeView.measuredHeight) + + val bitmap = + createBitmap(composeView.measuredWidth, composeView.measuredHeight) + + bitmap.applyCanvas { composeView.draw(this) } + + return BitmapDescriptorFactory.fromBitmap(bitmap) }