Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }
}
}
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class FeatureClusterRenderer(
context: Context,
map: GoogleMap,
clusterManager: ClusterManager<FeatureClusterItem>,
var zoom: Float,
private var zoom: Float,
) : DefaultClusterRenderer<FeatureClusterItem>(context, map, clusterManager) {
/**
* Called when the cluster balloon is shown so that implementations can unhide related map items.
Expand All @@ -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].
Expand Down Expand Up @@ -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<FeatureClusterItem>): 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<Cluster<FeatureClusterItem?>?>,
newClusters: Set<Cluster<FeatureClusterItem?>?>,
): 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FeatureClusterItem>
@Mock private lateinit var cluster: Cluster<FeatureClusterItem>

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<Cluster<FeatureClusterItem>>(),
emptySet<Cluster<FeatureClusterItem>>(),
)
)
}

@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<Cluster<FeatureClusterItem>>(),
emptySet<Cluster<FeatureClusterItem>>(),
)
)
}

@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<Cluster<FeatureClusterItem>>(),
emptySet<Cluster<FeatureClusterItem>>(),
)
)
}

@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<Cluster<FeatureClusterItem>>()
val newClusters =
setOf<Cluster<FeatureClusterItem>>(
createMockCluster(listOf(LOCATION_OF_INTEREST_CLUSTER_ITEM))
)

val result = featureClusterRenderer.invoke<Boolean>("shouldRender", oldClusters, newClusters)

assertTrue(result)
}

private fun createMockCluster(items: List<FeatureClusterItem>): Cluster<FeatureClusterItem> {
val cluster = mock<Cluster<FeatureClusterItem>>()
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 <T> 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
}
}