From a84eb06344ff11d1e3d6241619305086d72eb088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Wed, 8 Jul 2026 15:51:48 +0200 Subject: [PATCH 1/4] feat: add rotation support to cluster item API Add rotation parameter to ClusteringMarkerProperties and Clustering functions, allowing per-item and default rotation customization for both cluster and cluster item markers. This follows the same pattern as the existing anchor and zIndex support added in v8.1.0. Closes #942 --- .../compose/clustering/ClusterRenderer.kt | 7 +++- .../android/compose/clustering/Clustering.kt | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt index 4dfb9548..fe8bc7a7 100644 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt @@ -77,6 +77,8 @@ internal class ComposeUiClusterRenderer( private val clusterItemContentAnchorState: State, private val clusterContentZIndexState: State, private val clusterItemContentZIndexState: State, + private val clusterContentRotationState: State, + private val clusterItemContentRotationState: State, ) : DefaultClusterRenderer( context, map, @@ -211,6 +213,7 @@ internal class ComposeUiClusterRenderer( setIcon(renderViewToBitmapDescriptor(view)) view.properties.anchor?.let { setAnchor(it.x, it.y) } view.properties.zIndex?.let { zIndex = it } + view.properties.rotation?.let { rotation = it } } } @@ -223,6 +226,7 @@ internal class ComposeUiClusterRenderer( val anchor = clusterContentAnchorState.value markerOptions.anchor(anchor.x, anchor.y) markerOptions.zIndex(clusterContentZIndexState.value) + markerOptions.rotation(clusterContentRotationState.value) } } @@ -260,6 +264,7 @@ internal class ComposeUiClusterRenderer( val anchor = clusterItemContentAnchorState.value markerOptions.anchor(anchor.x, anchor.y) markerOptions.zIndex(clusterItemContentZIndexState.value) + markerOptions.rotation(clusterItemContentRotationState.value) } } @@ -315,7 +320,7 @@ internal class ComposeUiClusterRenderer( @Composable override fun Content() { - androidx.compose.runtime.LaunchedEffect(properties.anchor, properties.zIndex) { + androidx.compose.runtime.LaunchedEffect(properties.anchor, properties.zIndex, properties.rotation) { invalidate() } androidx.compose.runtime.CompositionLocalProvider( diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt index 1d893fa5..f65dbdf1 100644 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt @@ -58,6 +58,8 @@ public class ClusteringMarkerProperties { internal set public var zIndex: Float? by mutableStateOf(null) internal set + public var rotation: Float? by mutableStateOf(null) + internal set } /** @@ -74,16 +76,20 @@ public val LocalClusteringMarkerProperties: androidx.compose.runtime.ProvidableC * [Clustering] will be used. * @param zIndex the z-index of the marker. If null, the default z-index specified in [Clustering] * will be used. + * @param rotation the rotation of the marker in degrees clockwise about the marker's anchor point. + * If null, the default rotation specified in [Clustering] will be used. */ @Composable public fun ClusteringMarkerProperties( anchor: Offset? = null, zIndex: Float? = null, + rotation: Float? = null, ) { val properties = LocalClusteringMarkerProperties.current SideEffect { properties.anchor = anchor properties.zIndex = zIndex + properties.rotation = rotation } } @@ -103,6 +109,8 @@ public fun ClusteringMarkerProperties( * @param clusterItemContentAnchor the anchor for the non-clustered item image * @param clusterContentZIndex the z-index of the cluster * @param clusterItemContentZIndex the z-index of the non-clustered item + * @param clusterContentRotation the rotation of the cluster in degrees clockwise about the marker's anchor point + * @param clusterItemContentRotation the rotation of the non-clustered item in degrees clockwise about the marker's anchor point * @param clusterRenderer an optional ClusterRenderer that can be used to specify the algorithm used by the rendering. */ @Composable @@ -150,6 +158,8 @@ public fun Clustering( clusterItemContentAnchor: Offset = Offset(0.5f, 1.0f), clusterContentZIndex: Float = 0.0f, clusterItemContentZIndex: Float = 0.0f, + clusterContentRotation: Float = 0.0f, + clusterItemContentRotation: Float = 0.0f, clusterRenderer: ClusterRenderer? = null, clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, ) { @@ -160,6 +170,8 @@ public fun Clustering( clusterItemContentAnchor, clusterContentZIndex, clusterItemContentZIndex, + clusterContentRotation, + clusterItemContentRotation, clusterRenderer ) ?: return @@ -193,6 +205,8 @@ public fun Clustering( * @param clusterItemContentAnchor the anchor for the non-clustered item image * @param clusterContentZIndex the z-index of the cluster * @param clusterItemContentZIndex the z-index of the non-clustered item + * @param clusterContentRotation the rotation of the cluster in degrees clockwise about the marker's anchor point + * @param clusterItemContentRotation the rotation of the non-clustered item in degrees clockwise about the marker's anchor point */ @Composable @GoogleMapComposable @@ -209,6 +223,8 @@ public fun Clustering( clusterItemContentAnchor: Offset = Offset(0.5f, 1.0f), clusterContentZIndex: Float = 0.0f, clusterItemContentZIndex: Float = 0.0f, + clusterContentRotation: Float = 0.0f, + clusterItemContentRotation: Float = 0.0f, clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, ) { Clustering( @@ -223,6 +239,8 @@ public fun Clustering( clusterItemContentAnchor = clusterItemContentAnchor, clusterContentZIndex = clusterContentZIndex, clusterItemContentZIndex = clusterItemContentZIndex, + clusterContentRotation = clusterContentRotation, + clusterItemContentRotation = clusterItemContentRotation, clusterItemDecoration = clusterItemDecoration, onClusterManager = null, ) @@ -244,6 +262,8 @@ public fun Clustering( * @param clusterItemContentAnchor the anchor for the non-clustered item image * @param clusterContentZIndex the z-index of the cluster * @param clusterItemContentZIndex the z-index of the non-clustered item + * @param clusterContentRotation the rotation of the cluster in degrees clockwise about the marker's anchor point + * @param clusterItemContentRotation the rotation of the non-clustered item in degrees clockwise about the marker's anchor point * @param onClusterManager an optional lambda invoked with the clusterManager as a param when both * the clusterManager and renderer are set up, allowing callers a customization hook. */ @@ -262,6 +282,8 @@ public fun Clustering( clusterItemContentAnchor: Offset = Offset(0.5f, 1.0f), clusterContentZIndex: Float = 0.0f, clusterItemContentZIndex: Float = 0.0f, + clusterContentRotation: Float = 0.0f, + clusterItemContentRotation: Float = 0.0f, clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, onClusterManager: ((ClusterManager) -> Unit)? = null, ) { @@ -273,6 +295,8 @@ public fun Clustering( clusterItemContentAnchor, clusterContentZIndex, clusterItemContentZIndex, + clusterContentRotation, + clusterItemContentRotation, clusterManager ) @@ -404,6 +428,8 @@ public fun rememberClusterRenderer( * @param clusterItemContentAnchor the anchor for the non-clustered item image * @param clusterContentZIndex the z-index of the cluster * @param clusterItemContentZIndex the z-index of the non-clustered item + * @param clusterContentRotation the rotation of the cluster in degrees clockwise about the marker's anchor point + * @param clusterItemContentRotation the rotation of the non-clustered item in degrees clockwise about the marker's anchor point */ @Composable @GoogleMapComposable @@ -415,6 +441,8 @@ public fun rememberClusterRenderer( clusterItemContentAnchor: Offset = Offset(0.5f, 1.0f), clusterContentZIndex: Float = 0.0f, clusterItemContentZIndex: Float = 0.0f, + clusterContentRotation: Float = 0.0f, + clusterItemContentRotation: Float = 0.0f, clusterManager: ClusterManager?, ): ClusterRenderer? { val clusterContentState = rememberUpdatedState(clusterContent) @@ -423,6 +451,8 @@ public fun rememberClusterRenderer( val clusterItemContentAnchorState = rememberUpdatedState(clusterItemContentAnchor) val clusterContentZIndexState = rememberUpdatedState(clusterContentZIndex) val clusterItemContentZIndexState = rememberUpdatedState(clusterItemContentZIndex) + val clusterContentRotationState = rememberUpdatedState(clusterContentRotation) + val clusterItemContentRotationState = rememberUpdatedState(clusterItemContentRotation) val context = LocalContext.current val viewRendererState = rememberUpdatedState(rememberComposeUiViewRenderer()) val clusterRendererState: MutableState?> = remember { mutableStateOf(null) } @@ -441,6 +471,8 @@ public fun rememberClusterRenderer( clusterItemContentAnchorState, clusterContentZIndexState, clusterItemContentZIndexState, + clusterContentRotationState, + clusterItemContentRotationState, ) clusterRendererState.value = renderer awaitCancellation() @@ -469,6 +501,8 @@ private fun rememberClusterManager( clusterItemContentAnchor: Offset = Offset(0.5f, 1.0f), clusterContentZIndex: Float = 0.0f, clusterItemContentZIndex: Float = 0.0f, + clusterContentRotation: Float = 0.0f, + clusterItemContentRotation: Float = 0.0f, clusterRenderer: ClusterRenderer? = null, ): ClusterManager? { val clusterContentState = rememberUpdatedState(clusterContent) @@ -477,6 +511,8 @@ private fun rememberClusterManager( val clusterItemContentAnchorState = rememberUpdatedState(clusterItemContentAnchor) val clusterContentZIndexState = rememberUpdatedState(clusterContentZIndex) val clusterItemContentZIndexState = rememberUpdatedState(clusterItemContentZIndex) + val clusterContentRotationState = rememberUpdatedState(clusterContentRotation) + val clusterItemContentRotationState = rememberUpdatedState(clusterItemContentRotation) val context = LocalContext.current val viewRendererState = rememberUpdatedState(rememberComposeUiViewRenderer()) val clusterManagerState: MutableState?> = remember { mutableStateOf(null) } @@ -502,6 +538,8 @@ private fun rememberClusterManager( clusterItemContentAnchorState, clusterContentZIndexState, clusterItemContentZIndexState, + clusterContentRotationState, + clusterItemContentRotationState, ) } else { ReportingDefaultClusterRenderer(context, map, clusterManager) From e115409b981b1cd8cec73e97f028d21fa097cba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Wed, 8 Jul 2026 16:07:12 +0200 Subject: [PATCH 2/4] cleanup: remove FQN from LaunchedEffect and CompositionLocalProvider --- .../maps/android/compose/clustering/ClusterRenderer.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt index fe8bc7a7..1c55e0be 100644 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt @@ -16,6 +16,8 @@ package com.google.maps.android.compose.clustering +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf @@ -320,10 +322,10 @@ internal class ComposeUiClusterRenderer( @Composable override fun Content() { - androidx.compose.runtime.LaunchedEffect(properties.anchor, properties.zIndex, properties.rotation) { + LaunchedEffect(properties.anchor, properties.zIndex, properties.rotation) { invalidate() } - androidx.compose.runtime.CompositionLocalProvider( + CompositionLocalProvider( LocalClusteringMarkerProperties provides properties ) { content() From 507ef20a39bf1cf0afd682388d3015aaec8f919b Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:17:51 -0600 Subject: [PATCH 3/4] feat(maps-compose-utils): make cluster rotation and properties reactive in ComposeUiClusterRenderer - Add snapshotFlow observer in ComposeUiClusterRenderer to dynamically update active Marker rotation, anchor, and zIndex across parent recompositions. - Override invalidate in InvalidatingComposeView to ensure ClusteringMarkerProperties updates trigger re-rendering. - Add MarkerClusteringRotationActivity sample demonstrating top-level vs property rotation with dead-center Offset(0.5f, 0.5f) anchoring. - Add GoogleMapViewClusteringTests instrumentation test suite. --- .../compose/GoogleMapViewClusteringTests.kt | 170 +++++++++++ maps-app/src/main/AndroidManifest.xml | 3 + .../com/google/maps/android/compose/Demo.kt | 6 + .../MarkerClusteringRotationActivity.kt | 276 ++++++++++++++++++ maps-app/src/main/res/values/strings.xml | 3 + .../compose/clustering/ClusterRenderer.kt | 107 ++++++- 6 files changed, 551 insertions(+), 14 deletions(-) create mode 100644 maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt create mode 100644 maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringRotationActivity.kt 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 new file mode 100644 index 00000000..a5eb432f --- /dev/null +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt @@ -0,0 +1,170 @@ +/* + * 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 + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Marker +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.compose.clustering.Clustering +import com.google.maps.android.compose.clustering.ClusteringMarkerProperties +import com.google.maps.android.compose.markerexamples.MyItem +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class GoogleMapViewClusteringTests { + @get:Rule + val composeTestRule = createComposeRule() + + private val startingPosition = LatLng(1.23, 4.56) + private lateinit var cameraPositionState: CameraPositionState + + @Before + fun setUp() { + cameraPositionState = CameraPositionState( + position = CameraPosition.fromLatLngZoom(startingPosition, 12f) + ) + } + + private fun initMapAndGetMarker( + clusterManagerHolder: Array?>, + content: @Composable () -> Unit + ): Marker { + check(hasValidApiKey) { "Maps API key not specified" } + val countDownLatch = CountDownLatch(1) + + composeTestRule.setContent { + GoogleMapView( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + onMapLoaded = { + countDownLatch.countDown() + } + ) { + content() + } + } + + val mapLoaded = countDownLatch.await(MAP_LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS) + assertThat(mapLoaded).isTrue() + + composeTestRule.waitUntil(timeoutMillis = 10_000) { + composeTestRule.runOnUiThread { + val cm = clusterManagerHolder[0] + cm != null && cm.markerCollection.markers.isNotEmpty() + } + } + + return composeTestRule.runOnUiThread { + clusterManagerHolder[0]!!.markerCollection.markers.first() + } + } + + @OptIn(MapsComposeExperimentalApi::class) + @Test + fun testClusteringParametersRotationUpdatesMarker() { + val rotationState = mutableFloatStateOf(45f) + val clusterManagerHolder = arrayOfNulls>(1) + val items = listOf(MyItem(startingPosition, "Item", "Snippet", 0f)) + + val marker = initMapAndGetMarker(clusterManagerHolder) { + Clustering( + items = items, + clusterItemContentRotation = rotationState.floatValue, + clusterItemContentAnchor = Offset(0.5f, 0.5f), + clusterItemContent = { + Surface(modifier = Modifier.size(20.dp)) { + Text("X") + } + }, + onClusterManager = { cm -> + clusterManagerHolder[0] = cm + } + ) + } + + composeTestRule.runOnUiThread { + assertThat(marker.rotation).isEqualTo(45f) + } + + rotationState.floatValue = 180f + composeTestRule.waitForIdle() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.runOnUiThread { marker.rotation == 180f } + } + + composeTestRule.runOnUiThread { + assertThat(marker.rotation).isEqualTo(180f) + } + } + + @OptIn(MapsComposeExperimentalApi::class) + @Test + fun testClusteringMarkerPropertiesRotationUpdatesMarker() { + val rotationState = mutableFloatStateOf(45f) + val clusterManagerHolder = arrayOfNulls>(1) + val items = listOf(MyItem(startingPosition, "Item", "Snippet", 0f)) + + val marker = initMapAndGetMarker(clusterManagerHolder) { + Clustering( + items = items, + clusterItemContentAnchor = Offset(0.5f, 0.5f), + clusterItemContent = { + ClusteringMarkerProperties( + rotation = rotationState.floatValue, + anchor = Offset(0.5f, 0.5f) + ) + Surface(modifier = Modifier.size(20.dp)) { + Text("X") + } + }, + onClusterManager = { cm -> + clusterManagerHolder[0] = cm + } + ) + } + + composeTestRule.runOnUiThread { + assertThat(marker.rotation).isEqualTo(45f) + } + + rotationState.floatValue = 180f + composeTestRule.waitForIdle() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.runOnUiThread { marker.rotation == 180f } + } + + composeTestRule.runOnUiThread { + assertThat(marker.rotation).isEqualTo(180f) + } + } +} diff --git a/maps-app/src/main/AndroidManifest.xml b/maps-app/src/main/AndroidManifest.xml index d312b735..d95e8c0a 100644 --- a/maps-app/src/main/AndroidManifest.xml +++ b/maps-app/src/main/AndroidManifest.xml @@ -70,6 +70,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 2931e60f..6f1bef82 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 @@ -44,6 +44,7 @@ import androidx.compose.ui.text.font.FontWeight 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.draggablemarkerscollectionwithpolygon.DraggableMarkersCollectionWithPolygonActivity import com.google.maps.android.compose.markerexamples.markerdragevents.MarkerDragEventsActivity import com.google.maps.android.compose.markerexamples.markerscollection.MarkersCollectionActivity @@ -142,6 +143,11 @@ sealed class ActivityGroup( R.string.marker_clustering_activity_description, MarkerClusteringActivity::class ), + Activity( + R.string.marker_clustering_rotation_activity, + R.string.marker_clustering_rotation_activity_description, + MarkerClusteringRotationActivity::class + ), Activity( R.string.marker_drag_events_activity, R.string.marker_drag_events_activity_description, diff --git a/maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringRotationActivity.kt b/maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringRotationActivity.kt new file mode 100644 index 00000000..14abe031 --- /dev/null +++ b/maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringRotationActivity.kt @@ -0,0 +1,276 @@ +/* + * 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.foundation.BorderStroke +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateListOf +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.intl.Locale +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.MapsComposeExperimentalApi +import com.google.maps.android.compose.clustering.Clustering +import com.google.maps.android.compose.clustering.ClusteringMarkerProperties +import com.google.maps.android.compose.rememberCameraPositionState +import com.google.maps.android.compose.singapore2 +import kotlin.random.Random + +/** + * A dedicated sample activity demonstrating dynamic marker rotation across clustered and + * non-clustered items using the [Clustering] API in `android-maps-compose`. + * + * ### Key Concepts Demonstrated + * 1. **Two Approaches for Applying Marker Properties (`rotation`, `anchor`, `zIndex`):** + * * **Top-Level `Clustering(...)` Parameters:** Passing `clusterContentRotation` and + * `clusterItemContentRotation` directly to the [Clustering] composable. This is the recommended + * approach when all clusters or items share a uniform dynamic property state. + * * **`ClusteringMarkerProperties(...)` inside Custom Composables:** Calling [ClusteringMarkerProperties] + * directly inside your `clusterContent` or `clusterItemContent` composables. This is the ideal + * pattern when individual items require data-driven properties (for example, rotating a directional + * navigation arrow or vehicle icon according to each specific item's heading or state). + * + * 2. **Best Practice: Aligning the Anchor Point (`Offset(0.5f, 0.5f)`):** + * * By default across the [Clustering] API, marker anchors (`clusterContentAnchor` and + * `clusterItemContentAnchor`) default to `Offset(0.5f, 1.0f)`, which corresponds to **bottom-center** + * (the bottom tip of a traditional pin-drop marker). + * * When rotating symmetrical icons (like circles, directional triangles, or avatars) around a + * bottom-center anchor, the icons will orbit around their bottom edge like a pendulum. + * * **Rule of Thumb:** To spin symmetrical icons smoothly in place around their exact center point, + * always explicitly set the anchor to dead center (`Offset(0.5f, 0.5f)`), as demonstrated in this sample. + */ +class MarkerClusteringRotationActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + GoogleMapClusteringRotation() + } + } +} + +/** + * Main composable containing the interactive map, clustered markers, and rotation control panel. + */ +@OptIn(MapsComposeExperimentalApi::class) +@Composable +fun GoogleMapClusteringRotation() { + // Interactive state driven by the bottom UI controls + var rotationAngle by remember { mutableFloatStateOf(0f) } + var rotateClusters by remember { mutableStateOf(true) } + var rotateItems by remember { mutableStateOf(true) } + var useMarkerProperties by remember { mutableStateOf(false) } + + // Generate a set of sample items clustered around Singapore + val items = remember { mutableStateListOf() } + LaunchedEffect(Unit) { + for (i in 1..15) { + val position = LatLng( + singapore2.latitude + Random.nextFloat() * 1.5, + singapore2.longitude + Random.nextFloat() * 1.5, + ) + items.add(MyItem(position, "Marker #$i", "Snippet #$i", 0f)) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(singapore2, 6f) + } + ) { + // Determine rotation angles when using top-level parameters + val currentClusterRotation = if (rotateClusters && !useMarkerProperties) rotationAngle else 0f + val currentItemRotation = if (rotateItems && !useMarkerProperties) rotationAngle else 0f + + Clustering( + items = items, + // Explicitly anchor both clusters and items at dead center (0.5f, 0.5f) so that + // rotating them spins them smoothly in place rather than orbiting their bottom edge. + clusterContentAnchor = Offset(0.5f, 0.5f), + clusterItemContentAnchor = Offset(0.5f, 0.5f), + clusterContentRotation = currentClusterRotation, + clusterItemContentRotation = currentItemRotation, + clusterContent = { cluster -> + // Approach 2: Applying properties dynamically inside the content composable + if (rotateClusters && useMarkerProperties) { + ClusteringMarkerProperties( + rotation = rotationAngle, + anchor = Offset(0.5f, 0.5f) + ) + } + CircleContent( + modifier = Modifier.size(44.dp), + text = "%,d".format(Locale.current.platformLocale, cluster.size), + color = Color.Blue, + ) + }, + clusterItemContent = { + if (rotateItems && useMarkerProperties) { + ClusteringMarkerProperties( + rotation = rotationAngle, + anchor = Offset(0.5f, 0.5f) + ) + } + CircleContent( + modifier = Modifier.size(24.dp), + text = "▲", + color = Color.Green, + ) + } + ) + } + + // Bottom control panel for interactive experimentation + RotationControlPanel( + rotationAngle = rotationAngle, + onRotationAngleChange = { rotationAngle = it }, + rotateClusters = rotateClusters, + onRotateClustersChange = { rotateClusters = it }, + rotateItems = rotateItems, + onRotateItemsChange = { rotateItems = it }, + useMarkerProperties = useMarkerProperties, + onUseMarkerPropertiesChange = { useMarkerProperties = it }, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(16.dp) + ) + } +} + +/** + * A reusable circular/symmetrical badge composable used to represent clusters and items on the map. + */ +@Composable +private fun CircleContent( + color: Color, + text: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = CircleShape, + color = color, + contentColor = Color.White, + border = BorderStroke(2.dp, Color.White) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = text, + fontSize = 14.sp, + fontWeight = FontWeight.Black, + textAlign = TextAlign.Center + ) + } + } +} + +/** + * Interactive control panel allowing developers to experiment with angles and property approaches. + */ +@Composable +private fun RotationControlPanel( + rotationAngle: Float, + onRotationAngleChange: (Float) -> Unit, + rotateClusters: Boolean, + onRotateClustersChange: (Boolean) -> Unit, + rotateItems: Boolean, + onRotateItemsChange: (Boolean) -> Unit, + useMarkerProperties: Boolean, + onUseMarkerPropertiesChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Rotation Angle: ${rotationAngle.toInt()}°", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Slider( + value = rotationAngle, + onValueChange = onRotationAngleChange, + valueRange = 0f..360f + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = rotateClusters, onCheckedChange = onRotateClustersChange) + Text("Rotate Clusters", style = MaterialTheme.typography.bodyMedium) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = rotateItems, onCheckedChange = onRotateItemsChange) + Text("Rotate Items", style = MaterialTheme.typography.bodyMedium) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = useMarkerProperties, onCheckedChange = onUseMarkerPropertiesChange) + Text( + text = "Apply via ClusteringMarkerProperties", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} diff --git a/maps-app/src/main/res/values/strings.xml b/maps-app/src/main/res/values/strings.xml index 1147de14..e0065b85 100644 --- a/maps-app/src/main/res/values/strings.xml +++ b/maps-app/src/main/res/values/strings.xml @@ -45,6 +45,9 @@ Marker Clustering Clustering markers on your map. + Marker Clustering Rotation + Customizing rotation angle of clustered and non-clustered items. + Marker Drag Events Listening to marker drag events. diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt index 1c55e0be..e8273483 100644 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt @@ -93,9 +93,7 @@ internal class ComposeUiClusterRenderer( private val keysToViews = mutableMapOf, ViewInfo>() private val fakeLifecycleOwner = object : LifecycleOwner { - private val lifecycleRegistry = LifecycleRegistry(this).apply { - currentState = Lifecycle.State.RESUMED - } + val lifecycleRegistry = LifecycleRegistry(this) override val lifecycle: Lifecycle get() = lifecycleRegistry } @@ -106,6 +104,50 @@ internal class ComposeUiClusterRenderer( } override val savedStateRegistry: SavedStateRegistry get() = controller.savedStateRegistry override val lifecycle: Lifecycle get() = fakeLifecycleOwner.lifecycle + init { + fakeLifecycleOwner.lifecycleRegistry.currentState = Lifecycle.State.RESUMED + } + } + + init { + // Observe top-level Clustering property state updates across rotation, anchor, and zIndex. + // When these states update from a parent recomposition, actively push the updated values + // to any existing Marker objects currently on the map. + scope.launch { + androidx.compose.runtime.snapshotFlow { + listOf( + clusterContentRotationState.value, + clusterItemContentRotationState.value, + clusterContentAnchorState.value, + clusterItemContentAnchorState.value, + clusterContentZIndexState.value, + clusterItemContentZIndexState.value, + ) + }.collect { + keysToViews.forEach { (key, viewInfo) -> + when (key) { + is ViewKey.Cluster -> { + getMarker(key.cluster)?.apply { + val props = (viewInfo.view as? InvalidatingComposeView)?.properties + val anchor = props?.anchor ?: clusterContentAnchorState.value + setAnchor(anchor.x, anchor.y) + zIndex = props?.zIndex ?: clusterContentZIndexState.value + rotation = props?.rotation ?: clusterContentRotationState.value + } + } + is ViewKey.Item -> { + getMarker(key.item)?.apply { + val props = (viewInfo.view as? InvalidatingComposeView)?.properties + val anchor = props?.anchor ?: clusterItemContentAnchorState.value + setAnchor(anchor.x, anchor.y) + zIndex = props?.zIndex ?: clusterItemContentZIndexState.value + rotation = props?.rotation ?: clusterItemContentRotationState.value + } + } + } + } + } + } } override fun onClustersChanged(clusters: Set>) { @@ -156,6 +198,24 @@ internal class ComposeUiClusterRenderer( private fun createAndAddView(key: ViewKey): ViewInfo { val view = InvalidatingComposeView( context, + getRotation = { + when (key) { + is ViewKey.Cluster -> clusterContentRotationState.value + is ViewKey.Item -> clusterItemContentRotationState.value + } + }, + getAnchor = { + when (key) { + is ViewKey.Cluster -> clusterContentAnchorState.value + is ViewKey.Item -> clusterItemContentAnchorState.value + } + }, + getZIndex = { + when (key) { + is ViewKey.Cluster -> clusterContentZIndexState.value + is ViewKey.Item -> clusterItemContentZIndexState.value + } + }, content = when (key) { is ViewKey.Cluster -> { { clusterContentState.value?.invoke(key.cluster) } @@ -209,21 +269,30 @@ internal class ComposeUiClusterRenderer( } .collectLatest { when (key) { - is ViewKey.Cluster -> getMarker(key.cluster) - is ViewKey.Item -> getMarker(key.item) - }?.apply { - setIcon(renderViewToBitmapDescriptor(view)) - view.properties.anchor?.let { setAnchor(it.x, it.y) } - view.properties.zIndex?.let { zIndex = it } - view.properties.rotation?.let { rotation = it } + is ViewKey.Cluster -> { + getMarker(key.cluster)?.apply { + setIcon(renderViewToBitmapDescriptor(view)) + val anchor = view.properties.anchor ?: clusterContentAnchorState.value + setAnchor(anchor.x, anchor.y) + zIndex = view.properties.zIndex ?: clusterContentZIndexState.value + rotation = view.properties.rotation ?: clusterContentRotationState.value + } + } + is ViewKey.Item -> { + getMarker(key.item)?.apply { + setIcon(renderViewToBitmapDescriptor(view)) + val anchor = view.properties.anchor ?: clusterItemContentAnchorState.value + setAnchor(anchor.x, anchor.y) + zIndex = view.properties.zIndex ?: clusterItemContentZIndexState.value + rotation = view.properties.rotation ?: clusterItemContentRotationState.value + } + } } } - } override fun onBeforeClusterRendered(cluster: Cluster, markerOptions: MarkerOptions) { super.onBeforeClusterRendered(cluster, markerOptions) - if (clusterContentState.value != null) { val anchor = clusterContentAnchorState.value markerOptions.anchor(anchor.x, anchor.y) @@ -253,7 +322,6 @@ internal class ComposeUiClusterRenderer( override fun onBeforeClusterItemRendered(item: T, markerOptions: MarkerOptions) { super.onBeforeClusterItemRendered(item, markerOptions) - if (!scope.isActive) return if (clusterItemContentState.value != null) { @@ -314,6 +382,9 @@ internal class ComposeUiClusterRenderer( */ private class InvalidatingComposeView( context: Context, + private val getRotation: () -> Float, + private val getAnchor: () -> Offset, + private val getZIndex: () -> Float, private val content: @Composable () -> Unit, ) : AbstractComposeView(context) { @@ -322,7 +393,10 @@ internal class ComposeUiClusterRenderer( @Composable override fun Content() { - LaunchedEffect(properties.anchor, properties.zIndex, properties.rotation) { + val rotation = getRotation() + val anchor = getAnchor() + val zIndex = getZIndex() + LaunchedEffect(properties.anchor, properties.zIndex, properties.rotation, rotation, anchor, zIndex) { invalidate() } CompositionLocalProvider( @@ -332,6 +406,11 @@ internal class ComposeUiClusterRenderer( } } + override fun invalidate() { + super.invalidate() + onInvalidate?.invoke() + } + override fun onDescendantInvalidated(child: View, target: View) { super.onDescendantInvalidated(child, target) onInvalidate?.invoke() From 113bc303d34bf18056dc4bdea296192d3222f0b0 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:14:12 -0600 Subject: [PATCH 4/4] chore(maps-compose-utils): apply code review improvements to ComposeUiClusterRenderer (#943) - Inspect child properties in onBeforeClusterRendered and onBeforeClusterItemRendered to set anchor, zIndex, and rotation upon initial marker creation. - Replace linear ViewKey scans in keysToViews with direct O(1) hash map lookups. - Change ViewInfo.view type to InvalidatingComposeView for type safety. - Import snapshotFlow directly to clean up fully-qualified names in init block. TAG=agy CONV=8ec3e54a-53d3-485c-b062-b6f1a534729e --- .../compose/clustering/ClusterRenderer.kt | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt index e8273483..e7165961 100644 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow import android.content.Context import android.graphics.Bitmap @@ -114,7 +115,7 @@ internal class ComposeUiClusterRenderer( // When these states update from a parent recomposition, actively push the updated values // to any existing Marker objects currently on the map. scope.launch { - androidx.compose.runtime.snapshotFlow { + snapshotFlow { listOf( clusterContentRotationState.value, clusterItemContentRotationState.value, @@ -128,20 +129,20 @@ internal class ComposeUiClusterRenderer( when (key) { is ViewKey.Cluster -> { getMarker(key.cluster)?.apply { - val props = (viewInfo.view as? InvalidatingComposeView)?.properties - val anchor = props?.anchor ?: clusterContentAnchorState.value + val props = viewInfo.view.properties + val anchor = props.anchor ?: clusterContentAnchorState.value setAnchor(anchor.x, anchor.y) - zIndex = props?.zIndex ?: clusterContentZIndexState.value - rotation = props?.rotation ?: clusterContentRotationState.value + zIndex = props.zIndex ?: clusterContentZIndexState.value + rotation = props.rotation ?: clusterContentRotationState.value } } is ViewKey.Item -> { getMarker(key.item)?.apply { - val props = (viewInfo.view as? InvalidatingComposeView)?.properties - val anchor = props?.anchor ?: clusterItemContentAnchorState.value + val props = viewInfo.view.properties + val anchor = props.anchor ?: clusterItemContentAnchorState.value setAnchor(anchor.x, anchor.y) - zIndex = props?.zIndex ?: clusterItemContentZIndexState.value - rotation = props?.rotation ?: clusterItemContentRotationState.value + zIndex = props.zIndex ?: clusterItemContentZIndexState.value + rotation = props.rotation ?: clusterItemContentRotationState.value } } } @@ -294,19 +295,19 @@ internal class ComposeUiClusterRenderer( override fun onBeforeClusterRendered(cluster: Cluster, markerOptions: MarkerOptions) { super.onBeforeClusterRendered(cluster, markerOptions) if (clusterContentState.value != null) { - val anchor = clusterContentAnchorState.value + val viewInfo = keysToViews[ViewKey.Cluster(cluster)] + val props = viewInfo?.view?.properties + val anchor = props?.anchor ?: clusterContentAnchorState.value markerOptions.anchor(anchor.x, anchor.y) - markerOptions.zIndex(clusterContentZIndexState.value) - markerOptions.rotation(clusterContentRotationState.value) + markerOptions.zIndex(props?.zIndex ?: clusterContentZIndexState.value) + markerOptions.rotation(props?.rotation ?: clusterContentRotationState.value) } } override fun getDescriptorForCluster(cluster: Cluster): BitmapDescriptor { if (!scope.isActive) return super.getDescriptorForCluster(cluster) return if (clusterContentState.value != null) { - val viewInfo = keysToViews.entries - .firstOrNull { (key, _) -> (key as? ViewKey.Cluster)?.cluster == cluster } - ?.value + val viewInfo = keysToViews[ViewKey.Cluster(cluster)] if (viewInfo != null) { renderViewToBitmapDescriptor(viewInfo.view) @@ -325,16 +326,14 @@ internal class ComposeUiClusterRenderer( if (!scope.isActive) return if (clusterItemContentState.value != null) { - val viewInfo = keysToViews.entries - .firstOrNull { (key, _) -> (key as? ViewKey.Item)?.item == item } - ?.value - ?: createAndAddView(ViewKey.Item(item)) + val viewInfo = keysToViews[ViewKey.Item(item)] ?: createAndAddView(ViewKey.Item(item)) markerOptions.icon(renderViewToBitmapDescriptor(viewInfo.view)) - val anchor = clusterItemContentAnchorState.value + val props = viewInfo.view.properties + val anchor = props.anchor ?: clusterItemContentAnchorState.value markerOptions.anchor(anchor.x, anchor.y) - markerOptions.zIndex(clusterItemContentZIndexState.value) - markerOptions.rotation(clusterItemContentRotationState.value) + markerOptions.zIndex(props.zIndex ?: clusterItemContentZIndexState.value) + markerOptions.rotation(props.rotation ?: clusterItemContentRotationState.value) } } @@ -372,7 +371,7 @@ internal class ComposeUiClusterRenderer( } private class ViewInfo( - val view: AbstractComposeView, + val view: InvalidatingComposeView, val onRemove: () -> Unit, )