androidgoogle-mapsandroid-jetpack-composegoogle-maps-markers

Android Maps Compose | MarkerState.setMarker IllegalStateException


I currently have an Android application built with Jetpack Compose on the Play Store with android-maps-compose librairie. For several days now, I've been receiving reports of recurring crashes from the Play Store, but I'm unable to reproduce the issue on my end.

Here's the StackTrace:

Exception java.lang.IllegalStateException:
  at com.google.maps.android.compose.MarkerState.setMarker$maps_compose_release (Marker.kt:29)
  at com.google.maps.android.compose.MarkerNode.onAttached (Marker.kt:5)
  at com.google.maps.android.compose.MapApplier.insertBottomUp (MapApplier.kt:13)
  at com.google.maps.android.compose.MapApplier.insertBottomUp (MapApplier.kt:13)
  at androidx.compose.runtime.changelist.Operation$PostInsertNodeFixup.execute (Operation.kt:26)
  at androidx.compose.runtime.changelist.Operations.executeAndFlushAllPendingOperations (Operations.kt:23)
  at androidx.compose.runtime.changelist.FixupList.executeAndFlushAllPendingFixups (FixupList.java:36)
  at androidx.compose.runtime.changelist.Operation$InsertSlotsWithFixups.execute (Operation.kt:36)
  at androidx.compose.runtime.changelist.Operations.executeAndFlushAllPendingOperations (Operations.kt:23)
  at androidx.compose.runtime.changelist.ChangeList.executeAndFlushAllPendingChanges (ChangeList.kt:3)
  at androidx.compose.runtime.CompositionImpl.applyChangesInLocked (Composition.kt:50)
  at androidx.compose.runtime.CompositionImpl.applyChanges (Composition.kt:6)
  at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2$1.invoke (Recomposer.kt:174)
  at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2$1.invoke (Recomposer.kt:174)
  at androidx.compose.ui.platform.AndroidUiFrameClock$withFrameNanos$2$callback$1.doFrame (AndroidUiFrameClock.android.kt:7)
  at androidx.compose.ui.platform.AndroidUiDispatcher.performFrameDispatch (AndroidUiDispatcher.java:48)
  at androidx.compose.ui.platform.AndroidUiDispatcher.access$performFrameDispatch (AndroidUiDispatcher.java:48)
  at androidx.compose.ui.platform.AndroidUiDispatcher$dispatchCallback$1.doFrame (AndroidUiDispatcher.android.kt:48)
  at android.view.Choreographer$CallbackRecord.run (Choreographer.java:1648)
  at android.view.Choreographer$CallbackRecord.run (Choreographer.java:1659)
  at android.view.Choreographer.doCallbacks (Choreographer.java:1129)
  at android.view.Choreographer.doFrame (Choreographer.java:1045)
  at android.view.Choreographer$FrameDisplayEventReceiver.run (Choreographer.java:1622)
  at android.os.Handler.handleCallback (Handler.java:958)
  at android.os.Handler.dispatchMessage (Handler.java:99)
  at android.os.Looper.loopOnce (Looper.java:230)
  at android.os.Looper.loop (Looper.java:319)
  at android.app.ActivityThread.main (ActivityThread.java:8893)
  at java.lang.reflect.Method.invoke
  at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run (RuntimeInit.java:608)
  at com.android.internal.os.ZygoteInit.main (ZygoteInit.java:1103)

Below is the code for my screen used to display the map (simplified to include only the map part):


@Composable
fun MyMapsView(
    cameraPositionState: CameraPositionState,
) {

    val viewModel: AdaptedMapViewModel = koinViewModel()
    val darkTheme = appIsInDarkMode()
    val markerPositionStates = remember {
        mutableMapOf<Long, MarkerState>()
    }

    LaunchedEffect(Unit) {
        viewModel.init(darkTheme)
    }

    LaunchedEffect(viewModel.markers.value) {
        viewModel.updateMarkerToDisplay()
    }

    LaunchedEffect(viewModel.groups.value) {
        viewModel.updateMarkerToDisplay()
    }

    LaunchedEffect(viewModel.sortMode.value) {
        viewModel.updateMarkerToDisplay()
    }

    Box(Modifier.fillMaxSize()) {
        GoogleMap(
            modifier = Modifier.fillMaxSize(),
            cameraPositionState = cameraPositionState,
            uiSettings = MapUiSettings(
                myLocationButtonEnabled = false, zoomControlsEnabled = false, mapToolbarEnabled = false
            ),
            properties = MapProperties(
                mapStyleOptions = MapStyleOptions.loadRawResourceStyle(
                    LocalContext.current, viewModel.mapStyle.value.rawResourceId
                ),
                mapType = viewModel.mapType.value
            )
        ) {

            MapMarkersAndCircles(viewModel, markerPositionStates)
        }
    }
}

@Composable
private fun MapMarkersAndCircles(
    viewModel: AdaptedMapViewModel, markerPositionStates: MutableMap<Long, MarkerState>
) {
    viewModel.markersToDisplay.value.forEach { marker ->

        if (markerPositionStates.containsKey(marker.id)) {
            markerPositionStates[marker.id]!!.position = marker.latLng()!!
        } else {
            markerPositionStates.putIfAbsent(
                marker.id, rememberMarkerState(
                    key = "${marker.id}",
                    position = marker.latLng()!!
                )
            )
        }
        MarkerComposable(
            keys = arrayOf(marker.id),
            tag = marker.id,
            state = markerPositionStates[marker.id]!!,
            anchor = if (marker.emoji.isNotEmpty()) Offset(0.5F, 0.5F) else Offset(0.5F, 1F),
            visible = viewModel.groups.value.find { it.id == marker.groupId }?.enable ?: true && marker.active,
        ) {
            Icon(
                painter = painterResource(id = R.drawable.ic_marker_fill),
                contentDescription = null,
                tint = markerColor(viewModel.mapStyle.value),
                modifier = Modifier.size(40.dp)
            )
        }
        marker.circles.sortedByDescending { it.radius() }.forEach { circle ->
            MapCircle(viewModel, marker, circle)
        }
    }
}

@Composable
private fun MapCircle(viewModel: AdaptedMapViewModel, marker: FullDataMarker, circle: FullDataCircle) {
    return Circle(
        center = marker.latLng()!!,
        clickable = true,
        visible = viewModel.groups.value.find { it.id == marker.groupId }?.enable ?: true && marker.active && circle.active,
        fillColor = if (viewModel.circleStyle.value == CircleStyle.OUTLINE) Color.Transparent
        else Color(circle.color).copy(alpha = 0.3f),
        radius = circle.radius(),
    )
}

fun moveToLocation(
    coroutineScope: CoroutineScope, cameraPositionState: CameraPositionState, latLng: LatLng
) {
    coroutineScope.launch {
        cameraPositionState.animate(
            update = CameraUpdateFactory.newLatLng(latLng), durationMs = 1000
        )
    }
}

And here's the code for the ViewModel (simplified to include only the map, markers, etc.):


class AdaptedMapViewModel(
    private val userPreferencesManager: UserPreferencesManager,
    private val appDataBaseManager: AppDataBaseManager,
) : ViewModel() {

    private val _markersToDisplay = mutableStateOf((listOf<FullDataMarker>()))
    val markersToDisplay: State<List<FullDataMarker>> = _markersToDisplay

    private val _mapType = mutableStateOf(userPreferencesManager.defaultAppMapType())
    val mapType: State<MapType> = _mapType

    private val _mapStyle = mutableStateOf(MapStyle.STANDARD)
    val mapStyle: State<MapStyle> = _mapStyle

    private val _initiated = mutableStateOf(false)

    val sortMode: State<SortOrder> = userPreferencesManager.sortOrder

    val circleStyle = userPreferencesManager.circleStyle

    val markers = appDataBaseManager.markers
    val groups = appDataBaseManager.markerGroups


    fun init(darkTheme: Boolean) {
        if (_initiated.value) return
        _mapStyle.value = userPreferencesManager.defaultAppMapStyle(darkTheme)
        updateMarkerToDisplay()
        _initiated.value = true
    }

    fun updateMarkerToDisplay() {
        _markersToDisplay.value = markers.value
            .generalOrder(null, groups.value, sortMode.value)
            .filter { it.active }
    }

}

I suspect the issue may arise from the fact that the list of markers to display evolves over time, but I've already tried several approaches, and this one seems to "work best".

I added a tag and a key, used a list of MarketState: markerPositionStates to avoid having multiple states per marker (which was previously the case). I'm not sure what else to test at this point.

Edit

After performing an update (for a completely different topic), the returned stack trace is no longer the same. (I also updated some implementations, but not android-maps-compose directly) Here it is:

Exception java.lang.IllegalStateException: MarkerState may only be associated with one Marker at a time.
  at com.google.maps.android.compose.MarkerState.setMarker$maps_compose_release (Marker.kt:29)
  at com.google.maps.android.compose.MarkerNode.onAttached (Marker.kt:5)
  at com.google.maps.android.compose.MapApplier.insertBottomUp (MapApplier.kt:13)
  at com.google.maps.android.compose.MapApplier.insertBottomUp (MapApplier.kt:13)
  at androidx.compose.runtime.changelist.Operation$PostInsertNodeFixup.execute (Operation.kt:26)
  at androidx.compose.runtime.changelist.Operations.executeAndFlushAllPendingOperations (Operations.kt:23)
  at androidx.compose.runtime.changelist.FixupList.executeAndFlushAllPendingFixups (FixupList.java:36)
  at androidx.compose.runtime.changelist.Operation$InsertSlotsWithFixups.execute (Operation.kt:36)
  at androidx.compose.runtime.changelist.Operations.executeAndFlushAllPendingOperations (Operations.kt:23)
  at androidx.compose.runtime.changelist.ChangeList.executeAndFlushAllPendingChanges (ChangeList.kt:3)
  at androidx.compose.runtime.CompositionImpl.applyChangesInLocked (Composition.kt:50)
  at androidx.compose.runtime.CompositionImpl.applyChanges (Composition.kt:6)
  at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2$1.invoke (Recomposer.kt:174)
  at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2$1.invoke (Recomposer.kt:174)
  at androidx.compose.ui.platform.AndroidUiFrameClock$withFrameNanos$2$callback$1.doFrame (AndroidUiFrameClock.android.kt:7)
  at androidx.compose.ui.platform.AndroidUiDispatcher.performFrameDispatch (AndroidUiDispatcher.java:48)
  at androidx.compose.ui.platform.AndroidUiDispatcher.access$performFrameDispatch (AndroidUiDispatcher.java:48)
  at androidx.compose.ui.platform.AndroidUiDispatcher$dispatchCallback$1.doFrame (AndroidUiDispatcher.android.kt:48)
  at android.view.Choreographer$CallbackRecord.run (Choreographer.java:1648)
  at android.view.Choreographer$CallbackRecord.run (Choreographer.java:1659)
  at android.view.Choreographer.doCallbacks (Choreographer.java:1129)
  at android.view.Choreographer.doFrame (Choreographer.java:1045)
  at android.view.Choreographer$FrameDisplayEventReceiver.run (Choreographer.java:1622)
  at android.os.Handler.handleCallback (Handler.java:958)
  at android.os.Handler.dispatchMessage (Handler.java:99)
  at android.os.Looper.loopOnce (Looper.java:230)
  at android.os.Looper.loop (Looper.java:319)
  at android.app.ActivityThread.main (ActivityThread.java:8893)
  at java.lang.reflect.Method.invoke
  at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run (RuntimeInit.java:608)
  at com.android.internal.os.ZygoteInit.main (ZygoteInit.java:1103)

I'm not sure if the two are ultimately the same or not, but so far I've never had both at the same time. What I don't understand is that the only modification in the update has nothing to do with this part. So, I don't see why there would be a change in the stack trace.

The main problem is that I never manage to reproduce the bug on my side. Therefore, it's impossible for me to test before I find its origin (or else I deploy to a small portion of my audience to see if the problem persists).


Solution

  • If you're trying to change the state of a marker after it has been removed from the map or after the relevant Compose UI element has been disposed of, you'll encounter this exception. Ensure that you're not trying to modify the marker state after the marker or the Compose UI element has been destroyed.

    You could try using key() that is

    viewModel.markersToDisplay.value.forEach { marker -> key(markerState) { //rest of code }}

    but if that's not working then you could create a new MarkerState from existing one

    state = MarkerState(position = LatLng(markerPositionStates[marker.id].latitude, markerPositionStates[marker.id].longitude))

    So that it will create a unique MarkerState each time