diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt index c53b90a7f1..278ad8e0c5 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt @@ -179,8 +179,8 @@ class GoogleMapsFragment : SupportMapFragment(), MapFragment { isIndoorLevelPickerEnabled = false } - if (isAdded && view != null) { - viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { featureManager.markerClicks.collect { featureClicks.emit(setOf(it)) } } } @@ -249,7 +249,7 @@ class GoogleMapsFragment : SupportMapFragment(), MapFragment { val cameraPosition = map.cameraPosition val projection = map.projection - featureManager.zoom = map.cameraPosition.zoom + featureManager.setZoom(map.cameraPosition.zoom) featureManager.onCameraIdle() lifecycleScope.launch { diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRenderer.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRenderer.kt index 9e48e26663..719cbc5284 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRenderer.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRenderer.kt @@ -41,7 +41,7 @@ class FeatureClusterRenderer( context: Context, map: GoogleMap, clusterManager: ClusterManager, - var zoom: Float, + private var zoom: Float, ) : DefaultClusterRenderer(context, map, clusterManager) { /** * Called when the cluster balloon is shown so that implementations can unhide related map items. @@ -54,6 +54,13 @@ class FeatureClusterRenderer( private val markerIconFactory: IconFactory = IconFactory(context) + private var oldZoom = zoom + + fun setZoom(newZoom: Float) { + oldZoom = zoom + zoom = newZoom + } + /** * Hides the marker provided by [DefaultClusterRenderer] and instead triggers the callback * provided in [onClusterItemRendered]. Called when zooming out past [CLUSTERING_ZOOM_THRESHOLD]. @@ -101,8 +108,33 @@ class FeatureClusterRenderer( /** * Indicates whether or not a cluster should be rendered as a cluster or individual map items. * - * Returns true iff the current zoom level is less than [CLUSTERING_ZOOM_THRESHOLD]. + * Returns true if the current zoom level is less than [CLUSTERING_ZOOM_THRESHOLD]. */ override fun shouldRenderAsCluster(cluster: Cluster): Boolean = - zoom < CLUSTERING_ZOOM_THRESHOLD + isClusterMode(zoom) + + /** + * Determines whether the cluster should be re-rendered. + * + * The default implementation skips rendering when the clusters haven't changed, as an + * optimization. This override also considers the current zoom level so that a re-render is forced + * when the rendering mode changes (cluster vs individual items) even if the clusters are the + * same. + * + * @return true if the rendering mode changed due to crossing [CLUSTERING_ZOOM_THRESHOLD], or if + * the clusters differ; false otherwise. + */ + override fun shouldRender( + oldClusters: Set?>, + newClusters: Set?>, + ): Boolean = + if (hasRenderingModeChanged()) { + true + } else { + super.shouldRender(oldClusters, newClusters) + } + + private fun hasRenderingModeChanged(): Boolean = isClusterMode(oldZoom) != isClusterMode(zoom) + + private fun isClusterMode(zoom: Float): Boolean = zoom < CLUSTERING_ZOOM_THRESHOLD } diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt index a5e9101f78..403c812890 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt @@ -59,11 +59,9 @@ constructor( * The camera's current zoom level. This must be set here since this impl can't access * `map.cameraPosition` from off the main UI thread. */ - var zoom: Float - get() = clusterRenderer.zoom - set(value) { - clusterRenderer.zoom = value - } + fun setZoom(newValue: Float) { + clusterRenderer.setZoom(newValue) + } /** Clears all managed state an binds to the provided [GoogleMap]. */ fun onMapReady(map: GoogleMap) { diff --git a/app/src/test/java/org/groundplatform/android/ui/map/gms/FeatureClusterManagerTest.kt b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterManagerTest.kt similarity index 94% rename from app/src/test/java/org/groundplatform/android/ui/map/gms/FeatureClusterManagerTest.kt rename to app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterManagerTest.kt index 18818046c0..77b40d38dd 100644 --- a/app/src/test/java/org/groundplatform/android/ui/map/gms/FeatureClusterManagerTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterManagerTest.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.android.ui.map.gms +package org.groundplatform.android.ui.map.gms.features import android.os.Looper.getMainLooper import androidx.test.core.app.ApplicationProvider @@ -23,7 +23,6 @@ import com.google.maps.android.collections.MarkerManager import dagger.hilt.android.testing.HiltAndroidTest import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData -import org.groundplatform.android.ui.map.gms.features.FeatureClusterManager import org.junit.Before import org.junit.Test import org.junit.runner.RunWith diff --git a/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRendererTest.kt b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRendererTest.kt new file mode 100644 index 0000000000..adb55bb28a --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterRendererTest.kt @@ -0,0 +1,145 @@ +/* + * 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 + * + * https://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 org.groundplatform.android.ui.map.gms.features + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterManager +import dagger.hilt.android.testing.HiltAndroidTest +import org.groundplatform.android.BaseHiltTest +import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST_CLUSTER_ITEM +import org.groundplatform.android.common.Constants.CLUSTERING_ZOOM_THRESHOLD +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class FeatureClusterRendererTest : BaseHiltTest() { + + @Mock private lateinit var map: GoogleMap + @Mock private lateinit var clusterManager: ClusterManager + @Mock private lateinit var cluster: Cluster + + private lateinit var context: Context + private lateinit var featureClusterRenderer: FeatureClusterRenderer + + @Before + override fun setUp() { + super.setUp() + context = ApplicationProvider.getApplicationContext() + com.google.android.gms.maps.MapsInitializer.initialize(context) + featureClusterRenderer = FeatureClusterRenderer(context, map, clusterManager, 10f) + } + + @Test + fun `Should render as a cluster if the zoom is below the threshold`() { + featureClusterRenderer.setZoom(CLUSTERING_ZOOM_THRESHOLD - 1f) + assertTrue(featureClusterRenderer.invoke("shouldRenderAsCluster", cluster)) + } + + @Test + fun `Should not render as a cluster when the zoom is above the threshold`() { + featureClusterRenderer.setZoom(CLUSTERING_ZOOM_THRESHOLD + 1f) + assertFalse(featureClusterRenderer.invoke("shouldRenderAsCluster", cluster)) + } + + @Test + fun `Should force render if the zoom threshold was crossed from below to above`() { + featureClusterRenderer.setZoom(CLUSTERING_ZOOM_THRESHOLD - 1f) + featureClusterRenderer.setZoom(CLUSTERING_ZOOM_THRESHOLD + 1f) + + assertTrue( + featureClusterRenderer.invoke( + "shouldRender", + emptySet>(), + emptySet>(), + ) + ) + } + + @Test + fun `Should force render if the zoom threshold was crossed from above to below`() { + featureClusterRenderer.setZoom(CLUSTERING_ZOOM_THRESHOLD + 1f) + featureClusterRenderer.setZoom(CLUSTERING_ZOOM_THRESHOLD - 1f) + + assertTrue( + featureClusterRenderer.invoke( + "shouldRender", + emptySet>(), + emptySet>(), + ) + ) + } + + @Test + fun `Should not force render if the clusters haven't changed and the zoom doesn't cross the threshold`() { + featureClusterRenderer.setZoom(10f) + featureClusterRenderer.setZoom(11f) + + // Assuming clusters are same (empty vs empty), super implementation would return false + assertFalse( + featureClusterRenderer.invoke( + "shouldRender", + emptySet>(), + emptySet>(), + ) + ) + } + + @Test + fun `Should force render when clusters change even if the zoom doesn't cross the threshold`() { + featureClusterRenderer.setZoom(10f) + featureClusterRenderer.setZoom(11f) + + val oldClusters = emptySet>() + val newClusters = + setOf>( + createMockCluster(listOf(LOCATION_OF_INTEREST_CLUSTER_ITEM)) + ) + + val result = featureClusterRenderer.invoke("shouldRender", oldClusters, newClusters) + + assertTrue(result) + } + + private fun createMockCluster(items: List): Cluster { + val cluster = mock>() + whenever(cluster.items).thenReturn(items) + whenever(cluster.size).thenReturn(items.size) + whenever(cluster.position).thenReturn(LatLng(0.0, 0.0)) + return cluster + } + + // Helper to invoke any protected method on FeatureClusterRenderer + @Suppress("UNCHECKED_CAST") + private fun FeatureClusterRenderer.invoke(methodName: String, vararg args: Any?): T { + val method = + this::class.java.declaredMethods.firstOrNull { it.name == methodName } + ?: throw IllegalArgumentException("Method $methodName not found") + method.isAccessible = true + return method.invoke(this, *args) as T + } +}