Skip to content

Migrate Android MapLibre Compose UI to the official SDK #853

Open
klemensz wants to merge 18 commits intostadiamaps:mainfrom
klemensz:maplibre-compose-migration-754
Open

Migrate Android MapLibre Compose UI to the official SDK #853
klemensz wants to merge 18 commits intostadiamaps:mainfrom
klemensz:maplibre-compose-migration-754

Conversation

@klemensz
Copy link
Copy Markdown

@klemensz klemensz commented Mar 25, 2026

Summary

This PR migrates ui-maplibre from the legacy Rallista MapLibre Compose integration to the official org.maplibre.compose:maplibre-compose-android SDK. (#754)

The main scope is Android phone/tablet Compose navigation UI. Android Auto is not fully migrated here and should follow in a separate PR.

Changes

  • switch ui-maplibre to the official MapLibre Compose Android artifact
  • replace the old MapViewCamera API with NavigationMapState
  • add Ferrostar camera modes for follow, follow-with-bearing, overview, and free camera
  • migrate route rendering to GeoJSON + LineLayer
  • add configurable location puck styling
  • add map tap and long-press callbacks
  • update the demo app to the new API
  • add unit tests for camera state, map state, GeoJSON generation, and location conversion

Notes

  • this PR is focused on Android phone/tablet Compose; ui-maplibre-car-app remains a compatibility path for now
  • NavigationMapView.onMapReadyCallback has been removed in favor of onMapLoadFinished and onMapLoadFailed
    • this avoids the reflection-based workaround for accessing the native style and aligns the API with official maplibre-compose load callbacks
  • Android Auto now uses the new map state/camera model, but the car host integration still depends on legacy Rallista components such as ComposableScreen and SurfaceGestureCallback-based host APIs
  • Android Auto surface gestures are now forwarded without relying on Ramani internals
    • scroll, scale, and best-effort fling gestures are translated into camera/projection updates through NavigationMapState using the official maplibre-compose API, so behavior may still differ somewhat from the previous native/Ramani-based implementation
  • the navigation puck shown during active navigation is a custom approximation of the old native GPS render mode
    • it is visually closer to the previous behavior, but not a 1:1 reproduction of the old LocationComponent RenderMode.GPS

Testing

cd android
./gradlew :ui-maplibre:testDebugUnitTest :ui-maplibre-car-app:compileDebugKotlin :demo-app:testDebugUnitTest --no-daemon

Screen recording

Screen_recording_20260327_105813.mp4

Migrates the Android phone and tablet navigation views from the legacy dependency to the official `org.maplibre.compose:maplibre-compose-android` artifact.

Key changes include:
* Introduced `NavigationMapState` to manage camera modes (follow user, overview, free) and zoom behavior.
* Replaced `MapViewCamera` with a custom camera layer supporting automatic orientation and recentering logic.
* Updated route rendering to use GeoJSON sources and `LineLayer` instead of legacy polyline APIs.
* Added `NavigationMapPuckStyle` for configurable location puck appearance.
* Refactored `NavigationMapView` to support official `MapOptions`, `LocationPuck`, and native `Style` access.
* Updated gesture handling to use Ferrostar-specific `NavigationMapClickHandler` returning geographic coordinates.
* Maintained legacy compatibility for Android Auto in `ui-maplibre-car-app` by keeping the Rallista dependency for that module.
* Added unit tests for GeoJSON serialization, location mapping, and camera state logic.
…ate logic to differentiate between "template" and "tracking" camera positions.
…d introduce a specialized navigation puck overlay.
…igationMapView`

The `NavigationMapView` component in the Android MapLibre UI module now exposes `onMapLoadFinished` and `onMapLoadFailed` callbacks instead of the legacy `onMapReadyCallback`. This change removes the internal reflection-based workaround (`nativeStyleOrNull`) previously used to extract the native MapLibre `Style` from the compose `CameraState`.
@klemensz klemensz marked this pull request as ready for review March 30, 2026 07:51
Comment on lines +101 to +111
LocationTrackingEffect(
locationState = userLocationState,
enabled = navigationMapState.isTrackingUser,
trackBearing = navigationMapState.cameraMode == NavigationCameraMode.FOLLOW_USER_WITH_BEARING,
) {
cameraState.position =
navigationMapState.trackingFollowingCameraPosition(
target = currentLocation.position,
bearing = currentLocation.bearing,
)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well... 😅 I guess this is why the video looked like a clock going tick tock advancing every second haha.

I assume this is a limitation inherent in the current MapLibre Compose API?

  • Do they support animation (e.g. smooth the transition over 1 second)? That could work as a stop gap. Though humorously this would "solve" the periodic complaints of location lag if we hooked it up to a source that could generate new locations at 60Hz 😂
  • Ideally we could hook into the underlying MapLibre location engine (I think that's the type name) interface that alre ady exists and leverage the native camera modes (C++ level).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user location currently comes from NavigationUiState.location, which during active navigation is typically the snapped location from the trip state. That means the UI is not rendering the raw GPS position, but the map-matched/snapped position, and in practice this currently seems to update at roughly 1 Hz.

While navigating, the puck is rendered via a custom map overlay (NavigationPuckOverlay) rather than the built-in maplibre-compose location puck. I implemented it this way because the built-in puck is not flexible enough for the current navigation-style appearance (for example the arrow / bearing presentation). I am currently experimenting with interpolating / animating the overlay position itself, but that is not entirely straightforward.

For the camera, I am also trying to smooth tracking via CameraState.animateTo().

Outside active navigation, Ferrostar uses the regular maplibre-compose location puck. I checked the maplibre-compose implementation as well, and it is structurally similar in that it also just renders the latest location value without doing any special native interpolation magic.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I improved this further in the migration branch.

The key fix is that the navigation puck and tracking camera now use the same shared route-relative displayed location/bearing while on-route. Previously, the camera was following a smoothed route bearing while the puck still had its own extra bearing smoothing / raw bearing fallback, which caused visible mismatch and step-like rotation.

Now the only smoothing happens in the shared displayed navigation location:

  • position is smoothed as forward progress along the route
  • bearing comes from the route tangent

That makes the snapped on-route behavior noticeably more stable and keeps puck/camera rotation aligned.

I already tested it in real life while driving. When going off the planned route, the location puck stays on the old route for a short time, and then switches to the newly calculated route.

That said, I see this as more of a stopgap than the final solution as well. The current implementation is still a Compose-layer approximation using a custom puck layer plus CameraState, whereas the old Android path relies on native MapLibre tracking behavior with snapped locations as input.

So for the long-term solution, I think we would still want an Android-native integration path in maplibre-compose that lets us use the underlying native location/tracking behavior again, instead of rebuilding it manually at the Compose layer.

Screen_recording_20260401_001833_short.mp4

Copy link
Copy Markdown
Author

@klemensz klemensz Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: There is still a rendering issue with a "shaking" navigation arrow when zooming in more, but that's probably only solvable with some kind of access to the native map (which I would target as a follow-up PR).

…ibre UI

Introduces a route-snapping mechanism for the navigation puck and camera using a new `rememberDisplayedNavigationLocation` hook. This ensures the displayed position and bearing stay locked to the route geometry during navigation, reducing jitter from noisy GPS data.
@klemensz klemensz changed the title Draft: migrate Android MapLibre Compose UI to the official SDK Migrate Android MapLibre Compose UI to the official SDK Mar 31, 2026
@klemensz
Copy link
Copy Markdown
Author

klemensz commented Apr 3, 2026

I think maplibre/maplibre-compose#761 could be directly relevant for this migration, although more as an improvement to the current workaround than as the final solution.

What it adds on the maplibre-compose side is an opt-in GeoJsonOptions.synchronousUpdate flag for Android GeoJSON sources, intended specifically for small, frequently updated in-memory GeoJSON sources. It also includes Android device tests for initial render and update-then-render behavior.

For this migration, the most relevant part seems to be the current navigation puck path, since that is now rendered as a GeoJSON source + layer in the migration branch. That looks like exactly the kind of source this flag is meant for.

The practical effect should be lower latency between puck data updates and visible layer updates, which may help reduce or eliminate the visible jitter at higher zoom levels.

Also, the screen recording in PR #761 is probably the most useful part for this discussion, because it shows the visual difference pretty directly.

I'll try to integrate the changes into this PR and see whether they have a positive effect.

Kudos to @hactar for pointing me to the existing issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants