From a56962eb25b384e7d5abb994a139f3dbddd3bb2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 20 Aug 2026 21:33:33 +0700 Subject: [PATCH 1/2] fix: pin ViewTreeLifecycleOwner/SavedStateRegistryOwner on MapView to prevent info window crash On compose-ui 1.11+, MarkerInfoWindow/MarkerInfoWindowContent can crash with "Composed into the View which doesn't propagate ViewTreeLifecycleOwner!" when the Maps SDK measures the info window's ComposeView from its own Handler after Compose has already unparented the MapView (e.g. LazyColumn recycling/detach). The ViewTreeLifecycleOwner/ViewTreeSavedStateRegistryOwner tags live on the AndroidView holder that is the MapView's parent, so once that parent link is severed the info window's ComposeView can no longer resolve an owner and AbstractComposeView.onMeasure throws (fatal starting with compose-ui 1.11, where owner resolution moved into onMeasure). Pin both owners directly onto the MapView itself so they stay resolvable from its own subtree regardless of where Compose has parented it. Fixes #971 --- .../google/maps/android/compose/GoogleMap.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt index 8b463fa1..4042c8eb 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt @@ -43,7 +43,11 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.compose.LocalSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.google.android.gms.maps.GoogleMapOptions import com.google.android.gms.maps.LocationSource import com.google.android.gms.maps.MapView @@ -117,6 +121,15 @@ public fun GoogleMap( return } + // The Maps SDK measures Compose info-window content from its own Handler, asynchronously. + // If that measure lands after Compose has unparented the MapView (e.g. on LazyColumn + // recycling), the info window's ComposeView can no longer resolve a ViewTreeLifecycleOwner + // via its ancestors, since that tag lives on this AndroidView's holder rather than on the + // MapView itself. Pinning the owners directly onto the MapView keeps them resolvable from + // its own subtree regardless of where Compose has parented it. + val lifecycleOwner = LocalLifecycleOwner.current + val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current + // rememberUpdatedState and friends are used here to make these values observable to // the subcomposition without providing a new content function each recomposition val mapClickListeners = remember { MapClickListeners() }.also { @@ -166,6 +179,8 @@ public fun GoogleMap( cameraPositionState.isLiteMode = options.liteMode == true mapViewFactory(context, options).also { mapView -> mapView.applyFocusability(focusable) + mapView.setViewTreeLifecycleOwner(lifecycleOwner) + mapView.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) val componentCallbacks = object : ComponentCallbacks2 { override fun onConfigurationChanged(newConfig: Configuration) {} @@ -217,6 +232,8 @@ public fun GoogleMap( }, update = { mapView -> mapView.applyFocusability(focusable) + mapView.setViewTreeLifecycleOwner(lifecycleOwner) + mapView.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) if (subcompositionJob == null) { subcompositionJob = parentCompositionScope.launchSubcomposition( mapUpdaterState, From 7f1347baf90265f3cf48cbd5df50e5bf8f3859dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 20 Aug 2026 21:51:18 +0700 Subject: [PATCH 2/2] fix: don't crash rendering an info window for a MapView mid-teardown The pin added in the previous commit stops the Maps SDK's async info-window render from throwing "Composed into the View which doesn't propagate ViewTreeLifecycleOwner!" once Compose has unparented the MapView. But on this codebase's bitmap-based info window rendering (#953), that same detached-render path now hits a different crash: renderComposableToBitmap's own check() rejects the zero-size measurement that naturally results from compositing a ComposeView that never got a real window attachment. A MapView that's mid-teardown has nothing worth rendering anyway, so treat that specific case (zero-size AND not attached to window) as "nothing to show" instead of a hard failure: renderComposableToBitmap now returns null, and ComposeInfoWindowAdapter propagates that through to the Maps SDK's already-nullable getInfoContents()/getInfoWindow(). Zero-size content on an attached MapView still throws with the original message, since that is a genuine content-authoring bug rather than a teardown race. Verified on a physical device (Pixel 4, Android 13, GMS 26.32.62, phoenix renderer) using the exact LazyColumn-recycling repro from #971: crashes within the first ~20 cycles without both fixes, survives 420+ cycles (60s, process alive throughout) with both applied. --- .../compose/ComposeInfoWindowAdapter.kt | 3 ++- .../android/compose/MapComposeViewRender.kt | 27 ++++++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/ComposeInfoWindowAdapter.kt b/maps-compose/src/main/java/com/google/maps/android/compose/ComposeInfoWindowAdapter.kt index 43cc2755..367bfd48 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/ComposeInfoWindowAdapter.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/ComposeInfoWindowAdapter.kt @@ -57,8 +57,9 @@ internal class ComposeInfoWindowAdapter( private fun renderToImageView( markerNode: MarkerNode, content: @Composable () -> Unit, - ): View { + ): View? { val bitmap = mapView.renderComposableToBitmap(markerNode.compositionContext, content) + ?: return null return ImageView(mapView.context).apply { layoutParams = ViewGroup.LayoutParams(bitmap.width, bitmap.height) scaleType = ImageView.ScaleType.FIT_XY diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapComposeViewRender.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapComposeViewRender.kt index f1b864a4..8ee56fa0 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/MapComposeViewRender.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapComposeViewRender.kt @@ -63,11 +63,18 @@ private val unspecifiedMeasureSpec = * [com.google.android.gms.maps.GoogleMap.InfoWindowAdapter]) that take ownership of the view they * receive and re-parent it into their own hierarchy — which would otherwise crash with * "The specified child already has a parent" if handed a view still attached elsewhere. + * + * Returns `null` if this [MapView] is no longer attached to a window by the time [content] is + * measured. That happens when the Maps SDK's own async render request (which drives this call) + * lands after Compose has already unparented the [MapView] (e.g. `LazyColumn` recycling/detach): + * the composition never gets a real layout pass, so it measures to zero size. There's no info + * window worth rendering for a map that's already being torn down, so this is treated as "nothing + * to show" rather than an error. */ internal fun MapView.renderComposableToBitmap( parentContext: CompositionContext, content: @Composable () -> Unit, -): Bitmap { +): Bitmap? { val containerView = ensureContainerView() val composeView = ComposeView(context).apply { layoutParams = ViewGroup.LayoutParams( @@ -80,13 +87,21 @@ internal fun MapView.renderComposableToBitmap( containerView.addView(composeView) composeView.measure(unspecifiedMeasureSpec, unspecifiedMeasureSpec) - check(composeView.measuredWidth > 0 && composeView.measuredHeight > 0) { - "The info window content was measured to have a width or height of zero. " + - "Make sure that the content has a non-zero size." + val width = composeView.measuredWidth + val height = composeView.measuredHeight + + if (width <= 0 || height <= 0) { + containerView.removeView(composeView) + check(!isAttachedToWindow) { + "The info window content was measured to have a width or height of zero. " + + "Make sure that the content has a non-zero size." + } + return null } - composeView.layout(0, 0, composeView.measuredWidth, composeView.measuredHeight) - val bitmap = createBitmap(composeView.measuredWidth, composeView.measuredHeight) + composeView.layout(0, 0, width, height) + + val bitmap = createBitmap(width, height) bitmap.applyCanvas { composeView.draw(this) } containerView.removeView(composeView)