From b5c2d59a33b9144fdd61050d72db3b77aa1c3b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Fri, 14 Aug 2026 13:28:34 +0700 Subject: [PATCH] fix: avoid zero-size crash in rememberComposeBitmapDescriptor when used inside Clustering rememberComposeBitmapDescriptor hosted its throwaway rendering ComposeView on LocalView.current, which can itself be mid-attach with no Android layout pass performed on it yet -- notably when this is called from content composed inside Clustering's clusterItemContent, whose InvalidatingComposeView is still being attached to its own parent at that point. That caused measure() to return a 0x0 size and throw IllegalStateException. Host the throwaway view on the window's root view instead, which is already laid out by the time any marker/cluster content is composed. setParentCompositionContext keeps the composition correctly scoped to the caller regardless of which Android View it's physically parented under, so this is safe. Fixes #694 --- .../compose/GoogleMapViewClusteringTests.kt | 35 +++++++++++++++++++ .../RememberComposeBitmapDescriptor.kt | 21 +++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt index a5eb432f..ae5d02b7 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt @@ -167,4 +167,39 @@ class GoogleMapViewClusteringTests { assertThat(marker.rotation).isEqualTo(180f) } } + + @OptIn(MapsComposeExperimentalApi::class) + @Test + fun testClusterItemContentUsingRememberComposeBitmapDescriptorDoesNotCrash() { + val clusterManagerHolder = arrayOfNulls>(1) + val items = listOf(MyItem(startingPosition, "Item", "Snippet", 0f)) + + // Regression test for https://github.com/googlemaps/android-maps-compose/issues/694: + // rememberComposeBitmapDescriptor used to throw "measured to have a width or height of + // zero" when called from content composed inside Clustering's clusterItemContent, + // because its parent (InvalidatingComposeView) hadn't been through an Android layout + // pass yet at that point. + val marker = initMapAndGetMarker(clusterManagerHolder) { + Clustering( + items = items, + clusterItemContent = { + rememberComposeBitmapDescriptor { + Surface(modifier = Modifier.size(20.dp)) { + Text("X") + } + } + Surface(modifier = Modifier.size(20.dp)) { + Text("X") + } + }, + onClusterManager = { cm -> + clusterManagerHolder[0] = cm + } + ) + } + + composeTestRule.runOnUiThread { + assertThat(marker.isVisible).isTrue() + } + } } 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 98332b76..4f5b6d33 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 @@ -16,6 +16,7 @@ package com.google.maps.android.compose +import android.graphics.Canvas import android.view.View import android.view.ViewGroup import androidx.compose.runtime.Composable @@ -47,14 +48,24 @@ public fun rememberComposeBitmapDescriptor( } private val measureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) +private val fakeCanvas = Canvas() 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` + // 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 + // parent. `setParentCompositionContext` keeps this composition correctly scoped to the + // 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 composeView = - ComposeView(parent.context) + ComposeView(host.context) .apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, @@ -63,7 +74,11 @@ private fun renderComposableToBitmapDescriptor( setParentCompositionContext(compositionContext) setContent(content) } - .also(parent::addView) + .also(host::addView) + + // AndroidComposeView triggers LayoutNode's layout phase in the View draw phase, so trigger a + // draw to an empty canvas to force that. + composeView.draw(fakeCanvas) composeView.measure(measureSpec, measureSpec) @@ -79,7 +94,7 @@ private fun renderComposableToBitmapDescriptor( bitmap.applyCanvas { composeView.draw(this) } - parent.removeView(composeView) + host.removeView(composeView) return BitmapDescriptorFactory.fromBitmap(bitmap) }