DEV Community

Cover image for Effective Map Composables: Collections of Draggable Markers
Uli Bubenheimer
Uli Bubenheimer

Posted on

Effective Map Composables: Collections of Draggable Markers

Welcome to the conclusion—for now—of my series on effective Marker patterns for the android-maps-compose GitHub library, finishing up by exploring collections of draggable Markers.

Why does this series exist, and why the specific focus on Markers? Markers are an essential part of many Map-related applications. The android-maps-compose library holds a few obstacles that make correct Marker usage challenging. Some have been resolved in more recent library versions, yet it can still be tricky to deal with Marker-related Composables soundly, and even more so when it comes to collections of Markers. This series aims to highlight areas of concern and provide simple, useful patterns for facilitating Marker use cases.

This fourth post builds on the takeaways from the first three. The complete, runnable example for the post is available in the current android-maps-compose release on GitHub.

Markers Collection

N.B. this series does not cover Marker Clustering, an option for handling typically larger numbers of Markers; Markers form clusters when zooming out.

A quick outline of what awaits below:

  1. An initial, fully functional solution combining elements from prior posts.
  2. A more flexible modification using the key() composable to synchronize collection changes.
  3. The final approach hoisting state to a model layer.

TL;DR the gist of the end result:

@Stable
class DraggableMarkersModel(dataModel: Map<MarkerKey, LatLng>) {
    private val markersMap: SnapshotStateMap<MarkerKey, MarkerState> =
        dataModel.entries.map { (markerKey, position) ->
            markerKey to MarkerState(position)
        }.toMutableStateMap()
    )

    @Composable
    fun Markers() = markersMap.forEach { (markerKey, markerState) ->
        key(markerKey) {
            Marker(
                markerState,
                draggable = true
            )
        }
    }
}

val markersModel = DraggableMarkersModel(...)
//...
markersModel.Markers()
Enter fullscreen mode Exit fullscreen mode

Read on for the discussion, embellishments, and alternatives.


Collections of draggable Markers may be stateful in two ways:

  1. Collection state: adding/inserting/replacing/removing Markers changes state.
  2. Marker state: user-initiated dragging or updating Marker position from a model changes state.

As in prior posts, the model stores only positional data, using the immutable LatLng type.

The easiest solution combines the approaches from the second post (draggable Markers) and the third post (Markers collection). Consider reviewing those posts. The result is below:

// Arbitrary class, for key(markerKey) { ... }
class MarkerKey

val markersModel: SnapshotStateMap<MarkerKey, LatLng> =
    mutableStateMapOf()

@Composable
fun Markers(
    keyedPositions: Map<MarkerKey, LatLng>,
    onMarkerUpdate: (MarkerKey, LatLng) -> Unit //ADDED
): Unit = keyedPositions.forEach { (markerKey, position) ->
    key(markerKey) {
        DraggableMarker(    //CHANGED from SimpleMarker
            initialPosition = position,
            onUpdate = { newPosition ->
                onMarkerUpdate(markerKey, newPosition)
            }
        )
    }
}

@Composable
fun DraggableMarker(
    initialPosition: LatLng,
    onUpdate: (LatLng) -> Unit
): Unit {
    val state = remember { MarkerState(initialPosition) }

    Marker(state, draggable = true)

    LaunchedEffect(Unit) {
        snapshotFlow { state.position }
            .collect { position -> onUpdate(position) }
    }
}
Enter fullscreen mode Exit fullscreen mode

There are only two small and obvious changes here from the prior posts, both in the Markers Composable from the collections post:

  1. Replace (non-draggable) SimpleMarker with DraggableMarker.
  2. Add a callback to pass up Marker position change events from dragging. This is for updating the model and any additional actions.

Note that SnapshotStateMap is unordered; map modifications periodically change iteration order. This choice of SnapshotStateMap is convenient because it is @Stable (in the Compose sense) and stable (released as part of the Compose runtime). To add ordering, pick one of the other types listed in the "Non-draggable Marker collections" post. No major changes are needed to the code in this post here, as long as the chosen data representation is @Stable in the Compose sense.

That's it for the most basic solution, and it's pretty solid to address basic needs.


The basic solution does not work as well for more complex requirements. For example, instead of continuously sending Marker update events during dragging, dragging a Marker can be conceptually grouped into a single atomic operation, consisting of 3 steps:

  1. User taps and holds Marker to initiate dragging.
  2. User moves Marker.
  3. User releases Marker.

The Google Play GoogleMap SDK is the source of truth during Marker dragging and follows this 3-step model. The SDK updates Marker position state and Marker position on the screen, while the app cannot reasonably do much besides observing.

Consequently, an app could delay updating its model until the user finishes dragging: updating a model may trigger other actions, making it expensive. Otherwise: continuous Marker position changes during dragging could trigger a cascade of model updates, which could, in turn, impair the interactivity of dragging and cause jank.

However, there may still be a need to take more limited action in response to continuous Marker position changes. The code below draws a continuously updating Polygon around the current Marker positions to represent this kind of limited action.

The delayed model update approach is also useful for cases that restrict Marker dragging destinations. In this case a model update may happen only if the final dragging destination meets the app-imposed constraints. Alternatively, trying to directly mess with a user's ongoing dragging action as soon as it violates constraints is not typically a great approach, because of the source of truth dilemma that I laid out in prior posts.

Displaying a polygon from a collection of Marker coordinates is trivial with the Polygon composable. Deriving a changing collection of polygon coordinates from a changing collection of MarkerStates that derives from a changing collection of model coordinates is less trivial without going through some sort of model.

Curiously, the key Composable has a convenient trick up its sleeve—it can return a value:

@Composable
inline fun <T : Any?> key(
    vararg keys: Any?,
    block: @Composable () -> T
): T
Enter fullscreen mode Exit fullscreen mode

This enables the fairly straightforward, while unorthodox, change below:

@Composable
fun Markers(
    keyedPositions: Map<MarkerKey, LatLng>,
    onMarkerUpdate: (MarkerKey, LatLng) -> Unit
): Unit {
    val currentPositions: List<LatLng> =             //ADDED
        keyedPositions.map { (markerKey, position) ->//CHANGED(forEach)
            key(markerKey) {
                DraggableMarker(
                    initialPosition = position,
                    onUpdate = { newPosition ->
                        onMarkerUpdate(markerKey, newPosition)
                    }
                )
            }
        }

    Polygon(currentPositions)                        //ADDED
}

@Composable
fun DraggableMarker(
    initialPosition: LatLng,
    onUpdate: (LatLng) -> Unit
): LatLng {                                          //CHANGED (Unit)
    val state = remember { MarkerState(initialPosition) }

    Marker(state, draggable = true)

    LaunchedEffect(Unit) {
        snapshotFlow { state.position }
            .collect { position -> onUpdate(position) }
    }

    return state.position                            //ADDED
}
Enter fullscreen mode Exit fullscreen mode

The code above returns each current Marker position, dragging or not, through the key Composable to transform the more static model data into a list of current Marker positions to outline the updated polygon. The list remains updated throughout model changes and dragging; the polygon recomposes accordingly.

This technique deserves a separate callout, as it can automatically associate composition-derived data (MarkerState in this case) with newly added elements of an ordered or unordered, changing collection; it re-uses the composition's key() matching labor for additional gain. It just works, somewhat magically, in the face of arbitrary collection insertions and deletions, plus MarkerState changes. Attempting to do this in composition without key() gets pretty awkward pretty quickly.

You may notice that both the key() Composable and DraggableMarker now return the Marker's current position, in addition to displaying the Marker (emitting a MarkerNode). This is usually considered a bad practice: the Compose API guidelines recommend that a Composable should do one of these, not both. The reason:

Emit operations must occur in the order the content is to appear in the composition. Using return values to communicate with the caller restricts the shape of calling code and prevents interactions with other declarative calls that come before it.

It's about ordering of UI content, e.g. a title should not appear below a body.

The kicker is: in the case of GoogleMap content, ordering is irrelevant. The content can be emitted in any order, and it will look the same. Consequently, emitting a Node and returning a value at the same time actually works ok, because the caller can be flexible about reordering its contents.

One nit: the code above recomposes pervasively for every position change from dragging. This is easy to optimize by passing lambdas instead of the state values themselves, and adding a Polygon Composable override evaluating the lambdas:

@Composable
fun DraggableMarker(/*...*/): () -> LatLng {
    //...
    return { state.position }
}

@Composable
fun Polygon(positions: List<() -> LatLng>) =
    Polygon(positions.map { it() })
Enter fullscreen mode Exit fullscreen mode

Ultimately, however, the code logic with this approach to key() usage appears less clear, due to mixed responsibilities.

Hoisting state and code logic to the model layer can help alleviate this concern; hoisting enables deriving another model (positionsModel below) from the original model data; the derived model holds the current Marker positions to draw the Polygon. The model-hoisted approach also makes it clear that MarkerState becomes the exclusive source of truth for Marker position post-initialization:

/**
 * Initializes MarkerState from model once,
 * never syncs with the model later:
 * model is initial source of truth.
 * Real app needs may be different.
 */
@Stable
class DraggableMarkersModel(dataModel: Map<MarkerKey, LatLng>) {
    private val markersMap: SnapshotStateMap<MarkerKey, MarkerState> =
        dataModel.entries.map { (markerKey, position) ->
            markerKey to MarkerState(position)
        }.toMutableStateMap()
    )

    @Composable
    fun Markers() = markersMap.forEach { (markerKey, markerState) ->
        key(markerKey) {
            Marker(
                markerState,
                draggable = true
            )
        }
    }

    val positionsModel: List<LatLng>
        get() = markersMap.values.map { markerState ->
            markerState.position
        }
}
Enter fullscreen mode Exit fullscreen mode

Note that there is no clear need here for snapshotFlow to observe position changes, due to hoisting MarkerState to the model layer. However, most code will need to reintroduce snapshotFlow, to take action for changes.

Model usage looks as follows:

val markersModel = DraggableMarkersModel(...)
//...
markersModel.Markers() // Display Markers
//...
Polygon(markersModel.positionsModel) // Display Polygon
Enter fullscreen mode Exit fullscreen mode

For completeness, here are functions for Marker addition and removal, after the model is initialized:

@Stable
class DraggableMarkersModel(/*...*/) {
    private val markersMap:
        SnapshotStateMap<MarkerKey, MarkerState> = /*...*/

    fun addMarker(position: LatLng) {
        markersMap += MarkerKey() to MarkerState(position)
    }

    fun deleteMarker(key: MarkerKey) {
        markersMap -= key
    }

    //...
}
Enter fullscreen mode Exit fullscreen mode

The code does not show a way to update Marker position from the model side in addition to position updates from user dragging. This is a thorny concern due to race conditions. The second post in the series, "Draggable Markers," explores potential solutions. A viable option is to replace (delete + add) an existing Marker with a brand-new one with a different MarkerKey.


Follow me via my profile link to stay in the loop about this and other development topics.

I would love to hear your thoughts—consider leaving a comment below. Composable maps APIs are still in their infancy, with much uncharted territory.

If you need professional assistance with your Compose project, please reach out through my profile. With a deep understanding of Maps Compose APIs I can help overcome challenges and make your Compose-based mapping solution a success.

 

Attribution: cover image at the top of the post generated with DALL-E

Top comments (0)