Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ab5c764
WIP - Migrate `ui-maplibre` to official MapLibre Compose Android 0.12.1
klemensz Mar 25, 2026
2675759
Migration to official maplibre-compose: Update `NavigationMapPuckStyl…
klemensz Mar 25, 2026
ebdbebc
Add logging and documentation for native style access in `NavigationM…
klemensz Mar 25, 2026
bb01a36
Ensure overview animation duration is non-zero and improve demo simul…
klemensz Mar 25, 2026
839031e
Migration to official maplibre-compose: Refactor navigation camera st…
klemensz Mar 25, 2026
73ef51a
Update README.md and NavigationCameraTest.kt for Android Compose migr…
klemensz Mar 25, 2026
e1f75f7
Refactor Android navigation UI to use the new `NavigationMapState` an…
klemensz Mar 26, 2026
5758870
Update DemoNavigationView to display route endpoints
klemensz Mar 26, 2026
6ed4111
Add logging and refactor navigation puck rendering in Android
klemensz Mar 26, 2026
924f21b
Replace `onMapReadyCallback` with standard map load callbacks in `Nav…
klemensz Mar 27, 2026
62099b5
Add pan, fling, and scale gesture support to `NavigationMapState` and…
klemensz Mar 30, 2026
50b6a3a
Implement route snapping and improved camera tracking in Android MapL…
klemensz Mar 31, 2026
364e3fc
Merge branch 'main' into maplibre-compose-migration-754
klemensz Mar 31, 2026
38bf99c
Implement route snapping and improved camera tracking in Android Comp…
klemensz Apr 1, 2026
5adb653
Refactor navigation camera animation and tracking logic in Android UI…
klemensz Apr 1, 2026
fa44ef1
Merge branch 'stadiamaps:main' into maplibre-compose-migration-754
klemensz Apr 3, 2026
d3d8356
Merge branch 'main' into maplibre-compose-migration-754
klemensz Apr 3, 2026
0ae2b71
Merge remote-tracking branch 'origin/maplibre-compose-migration-754' …
klemensz Apr 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,74 @@ To update the snapshots, run `./gradlew recordPaparazziDebug`.
* Bump the version number to a `SNAPSHOT` in `build.gradle`.
* run `./gradlew publishToMavenLocal -Pskip.signing`
* reference the updated version number in the project, and ensure that `mavenLocal` is one of the `repositories`.

## MapLibre Compose Migration

`ui-maplibre` now targets the official MapLibre Compose Android artifact:

```kotlin
implementation("org.maplibre.compose:maplibre-compose-android:0.12.1")
```

Notable Android phone/tablet migration changes:

* `ui-maplibre` no longer uses `io.github.rallista:maplibre-compose`.
* `NavigationMapView`, `PortraitNavigationView`, `LandscapeNavigationView`, and `DynamicallyOrientingNavigationView` now use a Ferrostar-owned `NavigationMapState` via `rememberNavigationMapState()`.
* The old `MapViewCamera`-based camera API has been replaced by a small Ferrostar camera layer for:
* follow user
* follow user with bearing
* route overview
* free camera
* `NavigationMapView` now takes `MapOptions` instead of the old `MapControls` API.
* Location puck styling is configurable through `NavigationMapPuckStyle`.
* Route rendering now uses a GeoJSON source plus `LineLayer` instead of legacy polyline convenience APIs.
* Map tap and long-press callbacks use Ferrostar-facing callbacks with `GeographicCoordinate` plus screen position.
* `NavigationMapView` now exposes `onMapLoadFinished` and `onMapLoadFailed` instead of a native-style `onMapReadyCallback`.
* `NavigationMapView` and the phone/tablet wrapper views now take `baseStyle: BaseStyle` directly.
* `NavigationMapState.cameraState` is public for direct access to projection queries and imperative camera animation.
* Default route and puck rendering can be disabled with `routeOverlayBuilder = null` and `showDefaultPuck = false`.

Example usage:

```kotlin
val navigationMapState = rememberNavigationMapState()

DynamicallyOrientingNavigationView(
modifier = Modifier.fillMaxSize(),
baseStyle = BaseStyle.Uri(styleUrl),
navigationMapState = navigationMapState,
viewModel = viewModel,
)
```

Using a `BaseStyle` directly:

```kotlin
val navigationMapState = rememberNavigationMapState()

NavigationMapView(
baseStyle = BaseStyle.Uri(styleUrl),
navigationMapState = navigationMapState,
uiState = uiState,
mapOptions = MapOptions(),
routeOverlayBuilder = null,
showDefaultPuck = false,
)
```

If you generate style JSON in memory, `BaseStyle.Json(styleJson)` works as well.

Custom camera control now goes through `NavigationMapState`:

```kotlin
navigationMapState.zoomIn()
navigationMapState.recenter(isNavigating = true)
navigationMapState.showRouteOverview(boundingBox, paddingValues = mapInsets)
navigationMapState.cameraState.animateTo(finalPosition = cameraPosition)
navigationMapState.cameraState.animateTo(boundingBox = bounds, padding = mapInsets)
```

Current scope notes:

* This migration covers Android phone/tablet Compose first.
* `ui-maplibre-car-app` still exists as a compatibility path, but Android Auto has not been fully migrated to the new map state/camera model yet.
1 change: 0 additions & 1 deletion android/demo-app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ dependencies {
implementation project(':ui-maplibre-car-app')

implementation libs.maplibre.compose
implementation libs.maplibre.compose.car.app

implementation platform(libs.okhttp.bom)
implementation libs.okhttp.core
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,34 @@ package com.stadiamaps.ferrostar
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.maplibre.compose.camera.MapViewCamera
import com.maplibre.compose.rememberSaveableMapViewCamera
import com.maplibre.compose.symbols.Circle
import com.stadiamaps.ferrostar.composeui.config.NavigationViewComponentBuilder
import com.stadiamaps.ferrostar.composeui.config.VisualNavigationViewConfig
import com.stadiamaps.ferrostar.composeui.config.withCustomOverlayView
import com.stadiamaps.ferrostar.composeui.config.withSpeedLimitStyle
import com.stadiamaps.ferrostar.composeui.runtime.KeepScreenOnDisposableEffect
import com.stadiamaps.ferrostar.composeui.views.components.speedlimit.SignageStyle
import com.stadiamaps.ferrostar.maplibreui.NavigationMapClickResult
import com.stadiamaps.ferrostar.maplibreui.views.DynamicallyOrientingNavigationView
import kotlin.math.min
import org.maplibre.android.geometry.LatLng
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.style.BaseStyle
import org.maplibre.compose.util.MaplibreComposable
import uniffi.ferrostar.GeographicCoordinate

@Composable
fun DemoNavigationScene(
Expand Down Expand Up @@ -76,46 +81,56 @@ fun DemoNavigationScene(
permissionsLauncher.launch(allPermissions)
}
}

// Set up the map!
val camera = rememberSaveableMapViewCamera(MapViewCamera.TrackingUserLocation())
val droppedPin by viewModel.droppedPin.collectAsState()

DynamicallyOrientingNavigationView(
modifier = Modifier.fillMaxSize(),
styleUrl = AppModule.mapStyleUrl,
camera = camera,
baseStyle = BaseStyle.Uri(AppModule.mapStyleUrl),
viewModel = viewModel,
// Configure speed limit signage based on user preference or location
config = VisualNavigationViewConfig.Default().withSpeedLimitStyle(SignageStyle.MUTCD),
views =
NavigationViewComponentBuilder.Default()
.withCustomOverlayView(
customOverlayView = { modifier ->
NotNavigatingOverlay(modifier, viewModel)
NotNavigatingOverlay(modifier, viewModel)
},
),
onTapExit = { viewModel.stopNavigation() },
) { uiState ->
// Trivial, if silly example of how to add your own overlay layers.
// (Also incidentally highlights the lag inherent in MapLibre location tracking
// as-is.)
uiState.location?.let { location ->
Circle(
center = LatLng(location.coordinates.lat, location.coordinates.lng),
radius = 10f,
color = "Blue",
zIndex = 3,
)
onMapLongClick = { position, screenPosition ->
Log.d(
"DemoNavigationScene",
"Long press at lat=${position.lat}, lng=${position.lng}, screen=$screenPosition",
)
viewModel.setDroppedPin(position)
NavigationMapClickResult.Pass
},
) {
DemoDroppedPinOverlay(droppedPin)
}
}

if (location.horizontalAccuracy > 15) {
Circle(
center = LatLng(location.coordinates.lat, location.coordinates.lng),
radius = min(location.horizontalAccuracy.toFloat(), 150f),
color = "Blue",
opacity = 0.2f,
zIndex = 2,
)
}
}
}
@Composable
@MaplibreComposable
private fun DemoDroppedPinOverlay(droppedPin: GeographicCoordinate?) {
val pinJson = droppedPinFeatureCollectionJsonOrNull(droppedPin) ?: return
val pointSource = rememberGeoJsonSource(GeoJsonData.JsonString(pinJson))

CircleLayer(
id = "demo-dropped-pin",
source = pointSource,
color = const(Color.Green),
radius = const(12.dp),
strokeColor = const(Color.White),
strokeWidth = const(3.dp),
)
}

internal fun droppedPinFeatureCollectionJsonOrNull(pin: GeographicCoordinate?): String? =
pin?.let {
droppedPinFeatureCollectionJson(it)
}

internal fun droppedPinFeatureCollectionJson(pin: GeographicCoordinate): String =
"""
{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[${pin.lng},${pin.lat}]},"properties":{}}]}
""".trimIndent()
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,12 @@ package com.stadiamaps.ferrostar

import android.location.Location
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.viewModelScope
import com.maplibre.compose.camera.CameraState
import com.maplibre.compose.camera.MapViewCamera
import com.maplibre.compose.camera.extensions.incrementZoom
import com.maplibre.compose.camera.models.CameraPadding
import com.stadiamaps.ferrostar.core.DefaultNavigationViewModel
import com.stadiamaps.ferrostar.core.FerrostarCore
import com.stadiamaps.ferrostar.core.NavigationUiState
import com.stadiamaps.ferrostar.core.annotation.AnnotationPublisher
import com.stadiamaps.ferrostar.core.annotation.valhalla.valhallaExtendedOSRMAnnotationPublisher
import com.stadiamaps.ferrostar.core.boundingBox
import com.stadiamaps.ferrostar.core.location.NavigationLocationProvider
import com.stadiamaps.ferrostar.core.location.toUserLocation
import com.stadiamaps.ferrostar.support.initialSimulatedLocation
Expand All @@ -23,14 +17,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.maplibre.android.geometry.LatLngBounds
import uniffi.ferrostar.GeographicCoordinate
import uniffi.ferrostar.UserLocation
import uniffi.ferrostar.Waypoint
Expand All @@ -52,6 +44,9 @@ class DemoNavigationViewModel(
private val locationStateFlow = MutableStateFlow<UserLocation?>(null)
val location = locationStateFlow.asStateFlow()

private val _droppedPin = MutableStateFlow<GeographicCoordinate?>(null)
val droppedPin = _droppedPin.asStateFlow()

// Here's an example of injecting a custom location into the navigation UI state when isNavigating
// is false.
override val navigationUiState: StateFlow<NavigationUiState> =
Expand All @@ -66,17 +61,18 @@ class DemoNavigationViewModel(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = NavigationUiState.empty())
initialValue = NavigationUiState.empty()
)

init {
viewModelScope.launch {
_hasLocationPermission
.flatMapLatest { hasPermission ->
if (hasPermission) {
if (!hasPermission) {
flowOf(initialSimulatedLocation)
} else {
locationProvider.locationUpdates(5000L)
.map { it.toUserLocation() }
} else {
flowOf(initialSimulatedLocation)
}
}
.collect {
Expand All @@ -91,27 +87,17 @@ class DemoNavigationViewModel(

fun toggleSimulation() {
_simulated.value = !_simulated.value
if (!_simulated.value) {
locationProvider.disableSimulation()
}
}

fun enableAutoDriveSimulation() {
_simulated.value = true
}

init {
viewModelScope.launch {
_hasLocationPermission
.flatMapLatest { hasPermission ->
if (hasPermission) {
locationProvider.locationUpdates(5000L)
.map { it.toUserLocation() }
} else {
flowOf(initialSimulatedLocation)
}
}
.collect {
locationStateFlow.emit(it)
}
}
fun setDroppedPin(coordinate: GeographicCoordinate) {
_droppedPin.value = coordinate
}

override fun toggleMute() {
Expand Down Expand Up @@ -156,56 +142,6 @@ class DemoNavigationViewModel(
ferrostarCore.stopNavigation()
}

val mapViewCamera = mutableStateOf(
MapViewCamera.TrackingUserLocation()
)
val cameraPadding = mutableStateOf(CameraPadding())
val navigationCamera = mutableStateOf(MapViewCamera.TrackingUserLocationWithBearing(zoom = 16.0, pitch = 45.0))

fun isTrackingUser(): Boolean =
when (mapViewCamera.value.state) {
is CameraState.TrackingUserLocation,
is CameraState.TrackingUserLocationWithBearing -> true
else -> false
}

fun zoomIn() {
mapViewCamera.value = mapViewCamera.value.incrementZoom(1.0)
}

fun zoomOut() {
mapViewCamera.value = mapViewCamera.value.incrementZoom(-1.0)
}

fun centerCamera() {
if (isTrackingUser()) {
centerOnRoute()
} else {
centerOnUser()
}
}

private fun centerOnRoute() {
val boundingBox = navigationUiState.value.routeGeometry?.boundingBox()
boundingBox?.let {
val latLngBounds = LatLngBounds.from(
boundingBox.north,
boundingBox.east,
boundingBox.south,
boundingBox.west
)
mapViewCamera.value = MapViewCamera.BoundingBox(
latLngBounds,
pitch = 0.0,
padding = cameraPadding.value
)
}
}

private fun centerOnUser() {
mapViewCamera.value = navigationCamera.value
}

companion object {
const val TAG = "DemoNavigationViewModel"
}
Expand Down
Loading