mapbox / mapbox-maps-android

Interactive, thoroughly customizable maps in native Android powered by vector tiles and OpenGL.
https://www.mapbox.com/mobile-maps-sdk
Other
430 stars 126 forks source link

Icon blinks zooming in/out with PointAnnotationGroup/Compose #2332

Open kevinMoonware opened 1 month ago

kevinMoonware commented 1 month ago

Environment

Observed behavior and steps to reproduce

Icon blinks when zoom in/out. The blinking intensifies when zooming out and the icons are getting closer to each other. Blinking appears much less often when there are few than 3 icons visible within the map region (but it does still happen sometimes).

Please see video capture here: https://youtube.com/shorts/es0iWngndEo?feature=share

Expected behavior

Icon(s) do not blink, or only blink when clustering ops are happening. Blinking should not happen during typical zoom in/out operation when zoom level is large enough that clustering is not a factor.

Notes / preliminary analysis

        MapboxMap(
            modifier = Modifier.fillMaxSize(),
            mapViewportState = mapViewportState,
            mapInitOptionsFactory = { context ->
                MapInitOptions(
                    context = context,
                    styleUri = "mapbox://styles/moonmapper/cloxllb7700q601qj462k2q76",
                )
            },
            gesturesSettings = GesturesSettings {
                quickZoomEnabled = true
                doubleTapToZoomInEnabled = true
                pitchEnabled = false
                this.build()
            },
            onMapClickListener = { point ->
            ....
            }
        ) {
            MapEffect(mapViewportState.cameraState.center) { mapView ->
                mapView.location.updateSettings {
                    enabled = true
                }
                val mapboxMap = mapView.mapboxMap
                mapView.scalebar.enabled = false
                .....
            }
            ...

            PointAnnotationGroup(
                iconOptional = true,
                iconAllowOverlap = true,
                iconIgnorePlacement = true,
                iconPitchAlignment = IconPitchAlignment.MAP,
                annotations = listOf(
                    flightWithPositions.toFlightAnnotationOptions(),
                    vehicleWithPositions.toVehicleAnnotationOptions(),
                    userWithPositions.toUserAnnotationOptions()
                ).flatten()
                    .filter {
                        it.getPoint()?.let { point ->
                            ComposeMapboxManager.bounds.value?.contains(point, true) == true
                        } ?: false },
                annotationConfig = AnnotationConfig(
                    annotationSourceOptions = AnnotationSourceOptions(
                        clusterOptions = ClusterOptions(
                            clusterMaxZoom = 16,
                            textColor = Color(0xFF000000).toArgb(),
                            textSize = 12.0,
                            circleRadiusExpression = literal(15.0),
                            colorLevels = listOf(
                                Pair(100, Color.Red.toArgb()),
                                Pair(50, Color.Blue.toArgb()),
                                Pair(0, Color(0xFFB0B7C3).toArgb())
                            )
                        )
                    )
                ),
                onClick = { annotation ->
                    ....
                }
            )
        }

Here is how I typically handle the creation of PointAnnotationOptions

@Composable
fun List<VehicleWithPosition>.toVehicleAnnotationOptions(): List<PointAnnotationOptions> {
    return this.map { vehicle ->
        val vehiclePoint = Point.fromLngLat(
            vehicle.positions[0].longitude,
            vehicle.positions[0].latitude
        )
        var options by remember {
            mutableStateOf(
                PointAnnotationOptions()
                    .withPoint(vehiclePoint)
                    .withData(
                        gson.toJsonTree(
                            AnnotationItem(
                                vehicleLasId = vehicle.vehicle.lasId
                            ), AnnotationItem::class.java
                        )
                    )
            )
        }

        LaunchedEffect(
            vehicle.positions[0].heading,
            vehicle.positions[0].latitude,
            vehicle.positions[0].longitude,
            vehicle.vehicle.getGseStatus(),
            vehicle.matchSelectedVehicle(),
            if (!ComposeMapboxManager.mapMoving.value) ComposeMapboxManager.cameraOptions.value?.bearing else null
        ) {
            val selected = vehicle.matchSelectedVehicle()
            val relativeHeading = vehicle.positions[0].heading - (ComposeMapboxManager.cameraOptions.value?.bearing?.toInt() ?: 0)
            val key = VehicleKey(
                vehicle.vehicle.name,
                vehicle.vehicle.getGseStatus(),
                relativeHeading,
                selected
            )
            withContext(Dispatchers.IO) {
                val iconImage = if (vehicleIconMap.get(key) == null) {
                    vehicleMarkerView(
                        heading = relativeHeading,
                        name = vehicle.vehicle.name,
                        type = vehicle.vehicle.type,
                        status = vehicle.vehicle.getGseStatus(),
                        batteryLevel = vehicle.positions[0].battery,
                        selected = selected
                    ).also {
                        vehicleIconMap.put(key, it)
                    }
                } else {
                    vehicleIconMap[key]!!
                }
                val updatedVehicleMarker = PointAnnotationOptions()
                    .withData(
                        gson.toJsonTree(
                            AnnotationItem(
                                vehicleLasId = vehicle.vehicle.lasId
                            ), AnnotationItem::class.java
                        )
                    )
                    .withIconImage(iconImage)
                    .withPoint(vehiclePoint)
                options = updatedVehicleMarker
            }
        }
        options.symbolSortKey = 3.0
        options.iconAnchor = IconAnchor.CENTER
        options
    }
}`

Additional links and references

olle-cpac commented 1 month ago

I have the same issue on 11.3.0. The Icons even blink sometimes when not zooming, but less frequently.

olle-cpac commented 1 month ago

The blinking with no zooming ongoing can be reproduced by applying this diff

index 2b4d49016..1633def9e 100644
--- a/compose-app/src/main/java/com/mapbox/maps/compose/testapp/examples/annotation/PointAnnotationClusterActivity.kt
+++ b/compose-app/src/main/java/com/mapbox/maps/compose/testapp/examples/annotation/PointAnnotationClusterActivity.kt
@@ -2,6 +2,7 @@ package com.mapbox.maps.compose.testapp.examples.annotation

 import android.graphics.Color
 import android.os.Bundle
+import android.util.Log
 import android.widget.Toast
 import androidx.activity.ComponentActivity
 import androidx.activity.compose.setContent
@@ -35,6 +36,7 @@ import com.mapbox.maps.plugin.annotation.ClusterOptions
 import com.mapbox.maps.plugin.annotation.generated.PointAnnotationOptions
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.suspendCancellableCoroutine
 import kotlinx.coroutines.withContext

@@ -54,6 +56,8 @@ public class PointAnnotationClusterActivity : ComponentActivity() {
         mutableStateOf<List<Point>>(listOf())
       }

+      var counter by remember { mutableStateOf(0) }
+
       MapboxMapComposeTheme {
         ExampleScaffold {
           MapboxMap(
@@ -68,6 +72,7 @@ public class PointAnnotationClusterActivity : ComponentActivity() {
               MapStyle(style = Style.LIGHT)
             }
           ) {
+            Log.d("TAG", "counter: $counter")
             PointAnnotationGroup(
               annotations = points.map {
                 PointAnnotationOptions()
@@ -99,6 +104,14 @@ public class PointAnnotationClusterActivity : ComponentActivity() {
               }
             )
           }
+          LaunchedEffect(Unit) {
+            withContext(Dispatchers.IO) {
+              while (true) {
+                counter++
+                delay(3000)
+              }
+            }
+          }
           LaunchedEffect(Unit) {
             withContext(Dispatchers.IO) {
               points = loadData()

It basically just forces recompose every 3 seconds

pengdev commented 3 weeks ago

Hey @kevinMoonware, the blink of view annotations is likely due to recomposition of your PointAnnotationGroup, due to the state changes in the annotations , I see you constructed annotations using

                annotations = listOf(
                    flightWithPositions.toFlightAnnotationOptions(),
                    vehicleWithPositions.toVehicleAnnotationOptions(),
                    userWithPositions.toUserAnnotationOptions()
                ).flatten()
                    .filter {
                        it.getPoint()?.let { point ->
                            ComposeMapboxManager.bounds.value?.contains(point, true) == true
                        } ?: false },

The recomposition of the parent composable will trigger the recalculation of the annotations, and since new annotation option instances are created, it will trigger the annotations being removed and added again.

To fix this issue, you can extract the calculation logic to a remember mutable state, so that new annotation options wouldn't be created each time PointAnnotationGroup is recomposed.

olle-cpac commented 3 days ago

If the points change they need to be recomposed. Remembering the annotation options will prevent that.