From 71ab2414b197885267619864af05f77d3cba2952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 4 Aug 2026 18:35:33 +0200 Subject: [PATCH] fix: avoid re-parenting crash for MarkerInfoWindowContent/MarkerInfoWindowComposable Fixes #950 PR #931 fixed #913 (empty info window content on compose-ui 1.10+) by keeping the ComposeView permanently attached to the library's own container while the info window is shown. That works around compose-ui's attachment requirement for drawing, but it means the view handed back from getInfoContents()/getInfoWindow() already has a parent. The Maps SDK internally re-parents that view into its own default info window frame, so calling addView() on an already-parented view throws: java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first. This change renders the Compose content into a plain Bitmap up front (attach, measure, layout, draw, detach, the same technique already used for MarkerComposable icon bitmaps) and hands the Maps SDK a fresh, unattached ImageView wrapping that bitmap instead of the live ComposeView. This keeps the compose-ui 1.10+ fix from #931 (content is fully rendered before we let go of it) while avoiding the double parent crash, since the returned view was never attached anywhere. Verified on a physical device: reproduced the #950 crash on the previous build, confirmed it is gone after this change, and confirmed MarkerInfoWindowContent and MarkerInfoWindowComposable content still render correctly under compose-bom 2026.06.01 (compose-ui 1.10+), covering the original #913 scenario as well. --- .../compose/ComposeInfoWindowAdapter.kt | 59 ++++++++----------- .../google/maps/android/compose/MapApplier.kt | 18 +++--- .../android/compose/MapComposeViewRender.kt | 47 +++++++++++++++ 3 files changed, 81 insertions(+), 43 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 0f6ff4c33..43cc2755e 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 @@ -15,64 +15,55 @@ package com.google.maps.android.compose import android.view.View -import androidx.compose.ui.platform.ComposeView +import android.view.ViewGroup +import android.widget.ImageView +import androidx.compose.runtime.Composable import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.MapView import com.google.android.gms.maps.model.Marker /** - * An InfoWindowAdapter that returns a [ComposeView] for drawing a marker's + * An InfoWindowAdapter that returns a [View] for drawing a marker's * info window. * * Note: As of version 18.0.2 of the Maps SDK, info windows are drawn by * creating a bitmap of the [View]s returned in the [GoogleMap.InfoWindowAdapter] - * interface methods. The returned views are never attached to a window, - * instead, they are drawn to a bitmap canvas. This breaks the assumption - * [ComposeView] makes where it must eventually be attached to a window. As a - * workaround, the contained window is temporarily attached to the MapView so - * that the contents of the ComposeViews are rendered. + * interface methods. For [getInfoContents], the Maps SDK also re-parents the + * returned view into its own default info-window frame, so the view handed back + * must never already have a parent. * - * As of compose-ui 1.10, [ComposeView] skips drawing when detached (isShown == false). - * To work around this, the view is kept attached to the [MapView] until the info window - * is closed, at which point [disposeForMarker] must be called. + * [ComposeView][androidx.compose.ui.platform.ComposeView] content is therefore + * rendered into a plain [android.graphics.Bitmap] up front (while briefly attached + * to [mapView] to drive composition), and that bitmap is wrapped in a fresh, + * parent-less [ImageView] for the Maps SDK to use. */ internal class ComposeInfoWindowAdapter( private val mapView: MapView, private val markerNodeFinder: (Marker) -> MarkerNode? ) : GoogleMap.InfoWindowAdapter { - private val renderHandles = mutableMapOf() - override fun getInfoContents(marker: Marker): View? { val markerNode = markerNodeFinder(marker) ?: return null - val content = markerNode.infoContent - if (content == null) { - return null - } - val view = ComposeView(mapView.context).apply { - setContent { content(marker) } - } - renderHandles.remove(marker)?.dispose() - renderHandles[marker] = mapView.startRenderingComposeView(view, markerNode.compositionContext) - return view + val content = markerNode.infoContent ?: return null + return renderToImageView(markerNode) { content(marker) } } override fun getInfoWindow(marker: Marker): View? { val markerNode = markerNodeFinder(marker) ?: return null - val infoWindow = markerNode.infoWindow - if (infoWindow == null) { - return null - } - val view = ComposeView(mapView.context).apply { - setContent { infoWindow(marker) } - } - renderHandles.remove(marker)?.dispose() - renderHandles[marker] = mapView.startRenderingComposeView(view, markerNode.compositionContext) - return view + val infoWindow = markerNode.infoWindow ?: return null + return renderToImageView(markerNode) { infoWindow(marker) } } - fun disposeForMarker(marker: Marker) { - renderHandles.remove(marker)?.dispose() + private fun renderToImageView( + markerNode: MarkerNode, + content: @Composable () -> Unit, + ): View { + val bitmap = mapView.renderComposableToBitmap(markerNode.compositionContext, content) + return ImageView(mapView.context).apply { + layoutParams = ViewGroup.LayoutParams(bitmap.width, bitmap.height) + scaleType = ImageView.ScaleType.FIT_XY + setImageBitmap(bitmap) + } } } diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt index 00aef1f43..f1793069f 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt @@ -125,15 +125,7 @@ internal class MapApplier( inputHandlerCallback = { onInfoWindowClick } ) } - val composeInfoWindowAdapter = ComposeInfoWindowAdapter( - mapView, - markerNodeFinder = { marker -> - decorations.firstOrNull { it is MarkerNode && it.marker == marker } - as MarkerNode? - } - ) map.setOnInfoWindowCloseListener { marker -> - composeInfoWindowAdapter.disposeForMarker(marker) decorations.findInputCallback( nodeMatchPredicate = { it.marker == marker }, marker = marker, @@ -217,7 +209,15 @@ internal class MapApplier( ) } }) - map.setInfoWindowAdapter(composeInfoWindowAdapter) + map.setInfoWindowAdapter( + ComposeInfoWindowAdapter( + mapView, + markerNodeFinder = { marker -> + decorations.firstOrNull { it is MarkerNode && it.marker == marker } + as MarkerNode? + } + ) + ) } } 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 5cad06cac..f1b864a4a 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 @@ -17,6 +17,7 @@ package com.google.maps.android.compose import android.content.Context +import android.graphics.Bitmap import android.graphics.Canvas import android.view.View import android.view.ViewGroup @@ -27,6 +28,9 @@ import androidx.compose.runtime.currentComposer import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCompositionContext import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.platform.ComposeView +import androidx.core.graphics.applyCanvas +import androidx.core.graphics.createBitmap import com.google.android.gms.maps.MapView import java.io.Closeable @@ -47,6 +51,49 @@ internal fun MapView.renderComposeViewOnce( } } +private val unspecifiedMeasureSpec = + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + +/** + * Renders [content] into a standalone [Bitmap] by temporarily attaching a [ComposeView] as a + * descendant of this [MapView], measuring, laying out and drawing it, then detaching it again. + * + * Unlike [startRenderingComposeView], the returned bitmap has no ties back to this [MapView] or + * its composition once this function returns, so it is safe to hand off to APIs (such as + * [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. + */ +internal fun MapView.renderComposableToBitmap( + parentContext: CompositionContext, + content: @Composable () -> Unit, +): Bitmap { + val containerView = ensureContainerView() + val composeView = ComposeView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + setParentCompositionContext(parentContext) + setContent(content) + } + 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." + } + composeView.layout(0, 0, composeView.measuredWidth, composeView.measuredHeight) + + val bitmap = createBitmap(composeView.measuredWidth, composeView.measuredHeight) + bitmap.applyCanvas { composeView.draw(this) } + + containerView.removeView(composeView) + + return bitmap +} + /** * Prepares [view] for a rendering by attaching it as a descendant of this [MapView]. * This is a trick to enable [ComposeView] to start its composition, as it requires being attached