Effective Map Composables: Draggable Markers
Uli Bubenheimer
Posted on August 22, 2024
This post continues exploring effective patterns and best practices for the android-maps-compose GitHub library. See the first article for an introduction to the series, and for establishing common ground. The complete, runnable example for this post is available in the current android-maps-compose release on GitHub.
After covering non-draggable Markers in the previous post, this article shifts the focus to draggable Markers.
TL;DR: with a single draggable marker, consider using the pattern below to initialize its position from the model, and letting it update from user dragging events afterward. If this is insufficient, leverage the key Composable to replace Marker and MarkerState whenever the Marker model position changes from external sources:
@Composable
fun DraggableMarker(
initialPosition: LatLng,
onUpdate: (LatLng) -> Unit
) {
val state = remember { MarkerState(initialPosition) }
Marker(state, draggable = true)
LaunchedEffect(Unit) {
snapshotFlow { state.position }
.collect { position -> onUpdate(position) }
}
}
Read on for the full discussion.
The earlier post developed a convenient pattern for encapsulating (Marker)State:
@Composable
fun SimpleMarker(position: LatLng) {
val state = rememberUpdatedMarkerState(position)
Marker(state = state)
}
@Composable
fun rememberUpdatedMarkerState(newPosition: LatLng): MarkerState =
remember { MarkerState(position = newPosition, draggable = false) }
.apply { position = newPosition }
This is possible only because there is a single source of truth: the app's own model.
But why is there a need for this pattern in the first place? Because MarkerState is general enough to accommodate competing sources of truth: the app's data model and the legacy GoogleMap (from the Google Play Maps SDK) wanting to own the state (to reflect the device user dragging the marker). With SimpleMarker
, the caller does not need to deal with unwieldy MarkerState when the Marker is not draggable.
Technically, SimpleMarker merely ignores updates to MarkerState from within the Marker()
Composable; the state object still exists internally and could be observed for changes, and the legacy GoogleMap still owns the marker's actual display state. However, without dragging, there are no changes to this state besides those controlled via SimpleMarker: The legacy GoogleMap offers no straightforward way to observe marker state changes when not dragging, so even if the legacy marker display state changed, no one would know. The SimpleMarker approach is a safe architectural simplification.
Once dragging comes into play, the competing sources of truth can complicate things. An ideal Compose-based approach would somehow eliminate the legacy GoogleMap as a source of truth, making the Marker truly stateless in unidirectional data flow fashion; it would cleanly encapsulate and hoist Marker state, updating the state from user drag events via callbacks. Alas, this is not feasible without replacing the inherently stateful legacy GoogleMap architecture, which directly updates the legacy marker's display state from user input events.
Without the ability to eliminate the conflicting source of truth, the objective shifts to workarounds for specific use cases. A common case is initializing Marker position from the initial model location and letting the user control the position afterward via dragging. Here, the initial, short-lived source of truth (for Marker initialization) is the model, and the legacy GoogleMap becomes the source of truth post-initialization. There is a well-defined transfer of control in this scenario. This is similar to how the hoisted state pattern is commonly used in Compose UI: initialize from model, then update from input event source.
This use case is more complex than the scenario from the first post, with multiple potentially viable implementation options. With only a single Marker, one straightforward approach is to keep MarkerState encapsulated close to its usage site in the Marker Composable:
@Composable
fun DraggableMarker(initialPosition: LatLng) {
val state = remember { MarkerState(initialPosition) }
Marker(state, draggable = true)
}
Pretty straightforward: a draggable Marker whose initial position comes from the data model; post-initialization, ignore the model, and make the legacy GoogleMap the sole source of truth.
This behavior is reminiscent of rememberMarkerState from the android-maps-compose API, which I discussed in the previous post. It is not the same, though. rememberMarkerState uses rememberSaveable under the hood, which introduces yet another source of truth. rememberSaveable treats MarkerState position as UI logic and is not suitable when Marker position syncs with a model.
Encapsulating MarkerState in this fashion has a number of benefits:
- Restricts gratuitous state access and enhances code clarity: when hoisting Composable state, intermediate call hierarchy levels gain write access to the hoisted state and create additional potential sources of truth.
- Compartmentalizes state access: MarkerState has responsibilities beyond tracking Marker position. These should not typically be accessible from the same places that deal with Marker position.
- Future-proofing: the android-maps-compose library may change the various responsibilities of MarkerState over time, including Marker position. Encapsulating MarkerState can insulate the app from such changes to some degree.
- Preempts concurrent update errors: MarkerState relies on snapshot state. Concurrent updates to snapshot state can fail, triggering exceptions. Confining MarkerState to the composition ensures non-concurrent usage.
- Accommodates intermediate models: MarkerState, or select parts of it, can be made a part of an intermediate model feeding other UI changes during an ongoing marker dragging user interaction, prior to persisting changes in the higher-level data model at the end of a dragging interaction. Updating the higher-level data model during active dragging is not always appropriate and may be costly.
Typically, the Marker dragging position is persisted in the app model, via a callback:
@Composable
fun DraggableMarker(
initialPosition: LatLng,
onUpdate: (LatLng) -> Unit
) {
val state = remember { MarkerState(initialPosition) }
Marker(state, draggable = true)
LaunchedEffect(Unit) {
snapshotFlow { state.position }
.collect { position -> onUpdate(position) }
}
}
Here marker position update events flow up the call chain, to update the data model. Data model changes may flow back down the chain, but the code ignores them because the state already has them, and they would be a conflicting source of truth.
The result is another reusable, general-purpose API, DraggableMarker
, which keeps the existence of MarkerState encapsulated as an implementation detail. Paired with SimpleMarker
from the previous post, this forms a combo of higher-level Marker API patterns that cover the primary use cases without exposing MarkerState externally.
An alternative implementation could hoist MarkerState to the model level. This would eliminate the need to pass around the initialPosition
parameter; it is used only for initial composition, not recomposition. In theory, hoisting also improves testability, as the Marker position could be updated from a test to simulate dragging; however, other MarkerState properties related to dragging cannot be manipulated in this fashion. The cost, on the other hand, is less effective MarkerState encapsulation, losing various of the benefits highlighted above.
The alternative implementation looks more useful when dealing with a collection of draggable markers rather than a single one: There is no longer a single initialization parameter to pass down but a whole bunch. In this scenario, encapsulating a collection of MarkerStates in a state holder object can avoid additional potential sources of truth. I will go over an example in a future post.
What about the case where external updates to the data model require updating the Marker position to a new location post-initialization, potentially while the marker is being dragged? This can be tricky to implement correctly, but there is a clean and simple general approach: replace Marker and MarkerState entirely, typically using the key Composable. I will provide examples in future posts. I will add here that it should technically suffice to only replace MarkerState without replacing the Marker Composable; Marker()
ought to be stateless, while MarkerState
encapsulates all the stateful parts. It is not enough at this time, in general, merely due to bugs in the android-maps-compose implementation.
Things can get uglier if the above approaches are insufficient for some reason; having multiple sources of truth can lead to data races or complicated code logic. Here are some potential problems:
-
Concurrent update errors: MarkerState is snapshot state. Concurrently applying updates may fail:
- The current android-maps-compose implementation will not fail at this time when updating MarkerState, but this is not guaranteed to remain the case.
- Conversely, applying concurrent updates from app code may fail for non-global snapshots at any time. (If you do not use snapshots explicitly, this may not be something to worry about too much.)
- Visual artifacts: concurrent updates have the potential to cause the marker to flicker or jump on the map.
- Data races: concurrent updates make it hard to predict whether the user's dragging action or a programmatic update will win, determining where the marker will end up.
The current android-maps-compose implementation updates MarkerState from the main thread, making data races a lesser concern if app code also performs MarkerState updates from only the main thread.
Other options for addressing the dual source of truth problem may be viable depending on the use case.
Follow me via my profile link to stay in the loop about future posts in this series 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 deep knowledge of Maps Compose APIs and their deficits, 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
Posted on August 22, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.