From b9fa8f77c71e6bfcf204055027e6477def335d04 Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Fri, 27 Feb 2026 22:47:19 +1100 Subject: [PATCH] feat(ui): switch from google maps to maplibre Wayy more performant and flexible --- composeApp/build.gradle.kts | 5 +- .../banksia/ui/platform/maps/Maps.android.kt | 250 +++++++++++------- gradle/libs.versions.toml | 7 +- 3 files changed, 154 insertions(+), 108 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 5a15896..02c6e0f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinSerialization) alias(libs.plugins.androidApplication) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) @@ -39,9 +40,6 @@ kotlin { implementation(libs.androidx.activity.compose) implementation(libs.kotlinx.coroutines.android) implementation(libs.play.services.location) - implementation(libs.play.services.maps) - implementation(libs.maps.compose) - implementation(libs.maps.compose.utils) } commonMain.dependencies { implementation(libs.compose.components.resources) @@ -62,6 +60,7 @@ kotlin { implementation(libs.ktor.client.core) implementation(libs.ktor.client.contentnegotiation) implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.maplibre.compose) implementation(libs.moko.geo) implementation(libs.moko.geo.compose) implementation(projects.shared) diff --git a/composeApp/src/androidMain/kotlin/moe/lava/banksia/ui/platform/maps/Maps.android.kt b/composeApp/src/androidMain/kotlin/moe/lava/banksia/ui/platform/maps/Maps.android.kt index 2a03a8c..921af04 100644 --- a/composeApp/src/androidMain/kotlin/moe/lava/banksia/ui/platform/maps/Maps.android.kt +++ b/composeApp/src/androidMain/kotlin/moe/lava/banksia/ui/platform/maps/Maps.android.kt @@ -1,25 +1,20 @@ +@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH") + package moe.lava.banksia.ui.platform.maps import android.Manifest +import android.annotation.SuppressLint import android.content.pm.PackageManager -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -27,28 +22,43 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.gms.location.LocationServices -import com.google.android.gms.maps.CameraUpdateFactory -import com.google.android.gms.maps.model.LatLng -import com.google.android.gms.maps.model.LatLngBounds -import com.google.android.gms.maps.model.MapStyleOptions -import com.google.maps.android.compose.ComposeMapColorScheme -import com.google.maps.android.compose.DefaultMapProperties -import com.google.maps.android.compose.DefaultMapUiSettings -import com.google.maps.android.compose.GoogleMap -import com.google.maps.android.compose.MarkerComposable -import com.google.maps.android.compose.Polyline -import com.google.maps.android.compose.rememberCameraPositionState -import com.google.maps.android.compose.rememberUpdatedMarkerState import kotlinx.coroutines.flow.Flow -import moe.lava.banksia.R -import moe.lava.banksia.ui.components.RouteIcon +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import moe.lava.banksia.model.RouteType +import moe.lava.banksia.ui.components.getUIProperties import moe.lava.banksia.ui.platform.BanksiaTheme +import moe.lava.banksia.ui.screens.MELBOURNE import moe.lava.banksia.ui.screens.MapScreenEvent import moe.lava.banksia.ui.state.MapState import moe.lava.banksia.util.BoxedValue import moe.lava.banksia.util.Point +import moe.lava.banksia.util.log +import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.expressions.dsl.case +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.convertToString +import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.dsl.switch +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.map.MapOptions +import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.map.OrnamentOptions +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.compose.style.BaseStyle +import org.maplibre.compose.util.ClickResult +import org.maplibre.spatialk.geojson.BoundingBox +import org.maplibre.spatialk.geojson.FeatureCollection +import org.maplibre.spatialk.geojson.Position +import org.maplibre.spatialk.geojson.dsl.addFeature +import org.maplibre.spatialk.geojson.dsl.buildFeatureCollection -fun Point.toLatLng(): LatLng = LatLng(this.lat, this.lng) +import org.maplibre.compose.camera.CameraPosition as MLCameraPosition +import org.maplibre.spatialk.geojson.Point as MLPoint + +fun Point.toPos(): Position = Position(this.lng, this.lat) @Composable private fun checkLocationPermission() = @@ -62,6 +72,40 @@ actual fun getScreenHeight(): Int { } } +@Serializable +data class MarkerProps( + val type: RouteType, +) + +private fun buildMarkers(markers: List): FeatureCollection { + return buildFeatureCollection { + markers.forEach { marker -> + val type = when (marker) { + is Marker.Stop -> marker.type + is Marker.Vehicle -> marker.type + } + val id = when (marker) { + is Marker.Stop -> marker.id + is Marker.Vehicle -> marker.ref + } + addFeature( + geometry = MLPoint(marker.point.toPos()), + properties = MarkerProps(type), + ) { + setId(id) + } + } + } +} + +private val colorTypeExpression @Composable get() = switch( + input = feature["type"].convertToString(), + cases = RouteType.entries.map { + case(label = it.name, output = const(it.getUIProperties().colour)) + }.toTypedArray(), + fallback = const(BanksiaTheme.colors.surface), +) + @OptIn(ExperimentalMaterial3Api::class) @Composable actual fun Maps( @@ -72,104 +116,110 @@ actual fun Maps( setLastKnownLocation: (Point) -> Unit, extInsets: WindowInsets, ) { - val camPos = rememberCameraPositionState() + val camPos = rememberCameraState( + MLCameraPosition( + zoom = 16.0, + target = MELBOURNE.toPos() + ) + ) val newCameraPos by cameraPositionFlow.collectAsStateWithLifecycle(null) LaunchedEffect(newCameraPos) { + log("maps", "newPos ${newCameraPos?.value}") val pos = newCameraPos?.value ?: return@LaunchedEffect - val update = if (pos.bounds != null) { + if (pos.bounds != null) { val (northeast, southwest) = pos.bounds - val bounds = LatLngBounds( - southwest.toLatLng(), - northeast.toLatLng() + camPos.animateTo( + boundingBox = BoundingBox( + southwest.toPos(), + northeast.toPos() + ) ) - CameraUpdateFactory.newLatLngBounds(bounds, 150) - } else - CameraUpdateFactory.newLatLngZoom(pos.centre.toLatLng(), 16.0f) - - camPos.animate(update, 1000) + } else { + camPos.animateTo(MLCameraPosition( + target = pos.centre.toPos(), + zoom = 16.0, + )) + } } val ctx = LocalContext.current val fusedLocation = remember { LocationServices.getFusedLocationProviderClient(ctx) } LaunchedEffect(Unit) { + @SuppressLint("MissingPermission") fusedLocation.lastLocation.addOnSuccessListener { if (it != null) { - camPos.position = com.google.android.gms.maps.model.CameraPosition( - LatLng( - it.latitude, - it.longitude - ), 16.0f, 0.0f, 0.0f + camPos.position = MLCameraPosition( + zoom = 16.0, + target = Position(it.longitude, it.latitude) ) setLastKnownLocation(Point(it.latitude, it.longitude)) } } } - GoogleMap( - modifier = Modifier.fillMaxSize(), - cameraPositionState = camPos, - mapColorScheme = if (isSystemInDarkTheme()) { - ComposeMapColorScheme.DARK - } else { - ComposeMapColorScheme.LIGHT - }, - properties = DefaultMapProperties.copy( - mapStyleOptions = MapStyleOptions.loadRawResourceStyle( - LocalContext.current, - R.raw.def_mapstyle - ), - isMyLocationEnabled = checkLocationPermission(), - ), - uiSettings = DefaultMapUiSettings.copy( - zoomControlsEnabled = false, - myLocationButtonEnabled = false, - mapToolbarEnabled = false, - ), - contentPadding = WindowInsets.safeDrawing.add(extInsets).asPaddingValues() + MaplibreMap( + modifier = modifier, + baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/positron"), + cameraState = camPos, + options = MapOptions( + ornamentOptions = OrnamentOptions( + padding = WindowInsets.safeDrawing.add(extInsets).asPaddingValues(), + isScaleBarEnabled = false, + isAttributionEnabled = false, + ) + ) ) { - // [TODO]: Slight lag when routes with many stops such as the 901 bus is set - for (marker in state.stops) { - val state = rememberUpdatedMarkerState(marker.point.toLatLng()) - MarkerComposable( - keys = arrayOf(marker), - zIndex = 0f, - state = state, - onClick = { - onEvent(MapScreenEvent.SelectStop(marker.type to marker.id)) - false + if (state.stops.isNotEmpty()) { + val stopsSource = rememberGeoJsonSource( + GeoJsonData.Features(buildMarkers(state.stops)) + ) + CircleLayer( + id = "maps-stops0", + source = stopsSource, + color = const(BanksiaTheme.colors.surface), + radius = const(3.dp), + strokeWidth = const(2.dp), + strokeColor = colorTypeExpression, + onClick = { features -> + val feature = features[0] + val marker = Json.decodeFromJsonElement(feature.properties!!) + onEvent(MapScreenEvent.SelectStop(marker.type to feature.id!!.content)) + ClickResult.Consume } - ) { - Box( - modifier = Modifier - .size(12.dp) - .clip(CircleShape) - .background(BanksiaTheme.colors.surface) - .border(2.dp, marker.colour, CircleShape) - ) - } - } - for (marker in state.vehicles) { - val state = rememberUpdatedMarkerState(marker.point.toLatLng()) - MarkerComposable( - keys = arrayOf(marker), - zIndex = 1f, - state = state, - onClick = { - onEvent(MapScreenEvent.SelectRun(marker.ref)) - false - } - ) { - RouteIcon( - size = 30.dp, - routeType = marker.type, - ) - } - } - for (polyline in state.polylines) { - Polyline( - points = polyline.points.map { it.toLatLng() }, - color = polyline.colour ) } + + // TODO +// if (state.vehicles.isNotEmpty()) { +// val stopsSource = rememberGeoJsonSource( +// GeoJsonData.Features(buildMarkers(state.vehicles)) +// ) +// SymbolLayer +// CircleLayer( +// id = "maps-vehicles0", +// source = stopsSource, +// color = const(BanksiaTheme.colors.surface), +// radius = const(3.dp), +// strokeWidth = const(2.dp), +// strokeColor = colorTypeExpression, +// onClick = { features -> +// val feature = features[0] +// val marker = Json.decodeFromJsonElement(feature.properties!!) +// onEvent(MapScreenEvent.SelectStop(marker.type to feature.id!!.content)) +// ClickResult.Consume +// } +// ) +// } +// +// if (state.polylines.isNotEmpty()) { +// val polySource = rememberGeoJsonSource( +// +// ) +// LineLayer( +// id = "maps-routeline", +// source = polySource, +// color = colorTypeExpression, +// ) +// } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 73378fa..638a098 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,12 +24,11 @@ kotlinxSerialization = "1.10.0" ksp = "2.3.4" ktor = "3.4.0" logback = "1.5.32" -mapsCompose = "6.12.2" +maplibre = "0.12.1" material = "1.7.3" material3 = "1.11.0-alpha02" okio = "3.16.4" playServicesLocation = "21.3.0" -playServicesMaps = "19.2.0" sqlite = "2.6.2" room = "2.8.4" secretsGradlePlugin = "2.0.1" @@ -73,11 +72,9 @@ ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "kto ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" } ktor-server-tests = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } -maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "mapsCompose" } -maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "mapsCompose" } +maplibre-compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibre" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } -play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "playServicesMaps" } room-common = { group = "androidx.room", name = "room-common", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }