Skip to content

Commit 11a2430

Browse files
kikosodkhawk
andauthored
fix: avoid zero-size crash in rememberComposeBitmapDescriptor when used inside Clustering (#963)
* 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 * refactor(maps-compose): improve Canvas lifecycle and view cleanup in rememberComposeBitmapDescriptor - Replace the file-level static fakeCanvas singleton with a locally scoped canvasOfHolding to prevent global memory retention and eliminate any risk of cross-call Canvas state/matrix contamination. - Wrap the measurement, layout, and bitmap drawing phases in a try/finally block to guarantee that the throwaway ComposeView is always detached from the root view hierarchy even if measurement throws an IllegalStateException or rendering fails. - Rename the Canvas instance to reflect its production role as an unbacked draw target rather than a test double. --------- Co-authored-by: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
1 parent fc37801 commit 11a2430

2 files changed

Lines changed: 67 additions & 13 deletions

File tree

maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,39 @@ class GoogleMapViewClusteringTests {
167167
assertThat(marker.rotation).isEqualTo(180f)
168168
}
169169
}
170+
171+
@OptIn(MapsComposeExperimentalApi::class)
172+
@Test
173+
fun testClusterItemContentUsingRememberComposeBitmapDescriptorDoesNotCrash() {
174+
val clusterManagerHolder = arrayOfNulls<ClusterManager<MyItem>>(1)
175+
val items = listOf(MyItem(startingPosition, "Item", "Snippet", 0f))
176+
177+
// Regression test for https://github.com/googlemaps/android-maps-compose/issues/694:
178+
// rememberComposeBitmapDescriptor used to throw "measured to have a width or height of
179+
// zero" when called from content composed inside Clustering's clusterItemContent,
180+
// because its parent (InvalidatingComposeView) hadn't been through an Android layout
181+
// pass yet at that point.
182+
val marker = initMapAndGetMarker(clusterManagerHolder) {
183+
Clustering(
184+
items = items,
185+
clusterItemContent = {
186+
rememberComposeBitmapDescriptor {
187+
Surface(modifier = Modifier.size(20.dp)) {
188+
Text("X")
189+
}
190+
}
191+
Surface(modifier = Modifier.size(20.dp)) {
192+
Text("X")
193+
}
194+
},
195+
onClusterManager = { cm ->
196+
clusterManagerHolder[0] = cm
197+
}
198+
)
199+
}
200+
201+
composeTestRule.runOnUiThread {
202+
assertThat(marker.isVisible).isTrue()
203+
}
204+
}
170205
}

maps-compose/src/main/java/com/google/maps/android/compose/RememberComposeBitmapDescriptor.kt

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.google.maps.android.compose
1818

19+
import android.graphics.Canvas
1920
import android.view.View
2021
import android.view.ViewGroup
2122
import androidx.compose.runtime.Composable
@@ -53,8 +54,18 @@ private fun renderComposableToBitmapDescriptor(
5354
compositionContext: CompositionContext,
5455
content: @Composable () -> Unit,
5556
): BitmapDescriptor {
57+
// Host the throwaway rendering ComposeView on the window's root view rather than on `parent`
58+
// directly. `parent` (LocalView.current) can itself be mid-attach with no Android layout pass
59+
// performed on it yet -- e.g. when this is called from content composed inside a Clustering
60+
// item, which is first composed while its own hosting view is still being attached to its
61+
// parent. `setParentCompositionContext` keeps this composition correctly scoped to the
62+
// surrounding composition regardless of which Android View it's physically parented under, so
63+
// it's safe to use a different, already-laid-out ViewGroup as the Android host.
64+
val host = parent.rootView as? ViewGroup ?: parent
65+
val canvasOfHolding = Canvas()
66+
5667
val composeView =
57-
ComposeView(parent.context)
68+
ComposeView(host.context)
5869
.apply {
5970
layoutParams = ViewGroup.LayoutParams(
6071
ViewGroup.LayoutParams.WRAP_CONTENT,
@@ -63,23 +74,31 @@ private fun renderComposableToBitmapDescriptor(
6374
setParentCompositionContext(compositionContext)
6475
setContent(content)
6576
}
66-
.also(parent::addView)
77+
.also(host::addView)
6778

68-
composeView.measure(measureSpec, measureSpec)
79+
try {
80+
// AndroidComposeView triggers LayoutNode's layout phase in the View draw phase, so trigger a
81+
// draw to an empty canvas to force that.
82+
composeView.draw(canvasOfHolding)
6983

70-
if (composeView.measuredWidth == 0 || composeView.measuredHeight == 0) {
71-
throw IllegalStateException("The ComposeView was measured to have a width or height of " +
72-
"zero. Make sure that the content has a non-zero size.")
73-
}
84+
composeView.measure(measureSpec, measureSpec)
7485

75-
composeView.layout(0, 0, composeView.measuredWidth, composeView.measuredHeight)
86+
if (composeView.measuredWidth == 0 || composeView.measuredHeight == 0) {
87+
throw IllegalStateException(
88+
"The ComposeView was measured to have a width or height of zero. " +
89+
"Make sure that the content has a non-zero size."
90+
)
91+
}
7692

77-
val bitmap =
78-
createBitmap(composeView.measuredWidth, composeView.measuredHeight)
93+
composeView.layout(0, 0, composeView.measuredWidth, composeView.measuredHeight)
7994

80-
bitmap.applyCanvas { composeView.draw(this) }
95+
val bitmap =
96+
createBitmap(composeView.measuredWidth, composeView.measuredHeight)
8197

82-
parent.removeView(composeView)
98+
bitmap.applyCanvas { composeView.draw(this) }
8399

84-
return BitmapDescriptorFactory.fromBitmap(bitmap)
100+
return BitmapDescriptorFactory.fromBitmap(bitmap)
101+
} finally {
102+
host.removeView(composeView)
103+
}
85104
}

0 commit comments

Comments
 (0)