Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3d9b1a8
refactoring
michalgwo Jan 30, 2025
42f3194
created snapshotter and implemented for android
michalgwo Jan 30, 2025
1c7f907
minor fix
michalgwo Jan 30, 2025
9eb77d9
removed params not available on ios
michalgwo Jan 30, 2025
6cc1bd9
snapshotter implementation for ios
michalgwo Jan 30, 2025
33da024
removed unused class
michalgwo Jan 30, 2025
c530f06
created empty JsMapSnapshotter
michalgwo Jan 30, 2025
2924d62
demo - cancelling snapshotter on pause
michalgwo Jan 30, 2025
cd513a2
docs
michalgwo Jan 30, 2025
899bc6c
code formatting
michalgwo Jan 30, 2025
a15e808
fixed padding bug
michalgwo Mar 2, 2025
8e1f386
added comment that snapshotter is missing on JS
michalgwo Mar 2, 2025
5939370
made MapSnapshotter internal and functions nonpublic
michalgwo Mar 2, 2025
c174270
fixed docs
michalgwo Mar 2, 2025
eedfe37
based MapSnapshotter on coroutines
michalgwo Mar 2, 2025
75835bf
code formatting
michalgwo Mar 2, 2025
e5d48f3
snapshot returns ImageBitmap, fixed cancelling
michalgwo Mar 9, 2025
bdea821
docs
michalgwo Mar 9, 2025
b63fd03
fixed error handling
michalgwo Mar 9, 2025
6dc11ab
fixed crash
michalgwo Mar 16, 2025
87d7396
fixed ios map snapshotter
michalgwo Mar 16, 2025
4902ac3
code formatting
michalgwo Mar 16, 2025
a25596a
Merge branch 'main' into map-snapshotter
michalgwo Mar 16, 2025
6e7fc51
docs
michalgwo May 11, 2025
b9cbd25
Merge branch 'main' into map-snapshotter
michalgwo May 24, 2025
e6e4c39
fixed merge
michalgwo May 24, 2025
a673c8e
update layoutDir and density when it changes
michalgwo May 24, 2025
de7e30f
set with to px and default style
michalgwo May 24, 2025
3223d69
show AlertDialog on error
michalgwo May 24, 2025
dfbd3db
imports
michalgwo May 24, 2025
fa8f5d2
fixed LocalLifecycleOwner
michalgwo May 24, 2025
02a6fd2
Merge branch 'main' into map-snapshotter
michalgwo May 25, 2025
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
1 change: 1 addition & 0 deletions demo-app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ kotlin {
implementation(compose.material3)
implementation(compose.runtime)
implementation(compose.ui)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentNegotiation)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import dev.sargunv.maplibrecompose.demoapp.demos.EdgeToEdgeDemo
import dev.sargunv.maplibrecompose.demoapp.demos.FrameRateDemo
import dev.sargunv.maplibrecompose.demoapp.demos.LocalTilesDemo
import dev.sargunv.maplibrecompose.demoapp.demos.MarkersDemo
import dev.sargunv.maplibrecompose.demoapp.demos.SnapshotterDemo
import dev.sargunv.maplibrecompose.demoapp.demos.StyleSwitcherDemo
import dev.sargunv.maplibrecompose.demoapp.demos.platformDemos
import dev.sargunv.maplibrecompose.demoapp.generated.Res
Expand All @@ -68,6 +69,7 @@ private val DEMOS = buildList {
if (!Platform.isDesktop) add(CameraStateDemo)
if (Platform.usesMaplibreNative) add(CameraFollowDemo)
if (!Platform.isDesktop) add(FrameRateDemo)
if (Platform.supportsSnapshotter) add(SnapshotterDemo)
addAll(platformDemos)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package dev.sargunv.maplibrecompose.demoapp.demos

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import dev.sargunv.maplibrecompose.compose.CameraState
import dev.sargunv.maplibrecompose.compose.MaplibreMap
import dev.sargunv.maplibrecompose.compose.rememberCameraState
import dev.sargunv.maplibrecompose.compose.rememberStyleState
import dev.sargunv.maplibrecompose.core.SnapshotException
import dev.sargunv.maplibrecompose.demoapp.DEFAULT_STYLE
import dev.sargunv.maplibrecompose.demoapp.Demo
import dev.sargunv.maplibrecompose.demoapp.DemoMapControls
import dev.sargunv.maplibrecompose.demoapp.DemoOrnamentSettings
import dev.sargunv.maplibrecompose.demoapp.DemoScaffold
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch

object SnapshotterDemo : Demo {
override val name = "Snapshotter"
override val description = "Take a snapshot of the map"

@Composable
override fun Component(navigateUp: () -> Unit) {
val cameraState = rememberCameraState()
val styleState = rememberStyleState()
val isLoading = remember { mutableStateOf(false) }
val snapshot = remember { mutableStateOf<ImageBitmap?>(null) }
val snapshotError = remember { mutableStateOf<String?>(null) }
val lifeCycleOwner = LocalLifecycleOwner.current

DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE) {
isLoading.value = false
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}

DemoScaffold(this, navigateUp) {
Column {
Box(modifier = Modifier.weight(1f)) {
MaplibreMap(
styleUri = DEFAULT_STYLE,
cameraState = cameraState,
styleState = styleState,
ornamentSettings = DemoOrnamentSettings(),
)
DemoMapControls(cameraState, styleState)

if (isLoading.value) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}

SnapshotterControls(cameraState, isLoading, snapshot, snapshotError)

snapshot.value?.let {
SnapshotDialog(snapshot = it, onDismissRequest = { snapshot.value = null })
}

snapshotError.value?.let {
AlertDialog(
onDismissRequest = { snapshotError.value = null },
title = { Text(text = "Error during snapshot generation") },
text = { Text(text = it) },
confirmButton = { TextButton(onClick = { snapshotError.value = null }) { Text("OK") } },
)
}
}
}
}

@Composable
private fun SnapshotterControls(
cameraState: CameraState,
isLoading: MutableState<Boolean>,
snapshot: MutableState<ImageBitmap?>,
snapshotError: MutableState<String?>,
) {
val scope = rememberCoroutineScope()
var snapshotJob by remember { mutableStateOf<Job?>(null) }

Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Button(
onClick = {
snapshotJob =
scope.launch {
isLoading.value = true
try {
val response =
cameraState.snapshot(
width = 512,
height = 512,
cameraPosition = cameraState.position,
)

snapshot.value = response
} catch (error: SnapshotException) {
snapshotError.value = error.message
} catch (error: CancellationException) {
println("Snapshot generation cancelled")
}

isLoading.value = false
snapshotJob = null
}
}
) {
Text("Take snapshot")
}
Button(enabled = snapshotJob != null, onClick = { snapshotJob?.cancel() }) {
Text("Cancel snapshot")
}
}
}

@Composable
fun SnapshotDialog(snapshot: ImageBitmap, onDismissRequest: () -> Unit) {
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Image(
modifier = Modifier.padding(16.dp),
bitmap = snapshot,
contentDescription = "Snapshot",
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,8 @@ val Platform.supportsLayers: Boolean
val Platform.supportsBlending: Boolean
get() = isAndroid || isIos

val Platform.supportsSnapshotter: Boolean
get() = isAndroid || isIos

val Platform.usesMaplibreNative: Boolean
get() = isAndroid || isIos
2 changes: 1 addition & 1 deletion docs/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ We don't yet support Wasm because one of our dependencies,
| Add data sources by URI or GeoJSON | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: |
| Add images to the style | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: |
| Add Material 3 controls | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: |
| Snapshot the map as an image | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: |
| Add Compose UI annotations | :x: | :x: | :x: | :x: | :x: |
| Snapshot the map as an image | :x: | :x: | :x: | :x: | :x: |
| Configure the offline cache | :x: | :x: | :x: | :x: | :x: |
| Configure layer transitions | :x: | :x: | :x: | :x: | :x: |

Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ gradle-mkdocs = "4.0.1"
gradle-spotless = "7.0.3"

tool-prettier = "3.5.3"
lifecycle = "2.9.0"

[libraries]
alchemist = { module = "io.github.kevincianfarini.alchemist:alchemist", version.ref = "alchemist" }
Expand All @@ -52,6 +53,7 @@ ktor-serialization-kotlinxJson = { module = "io.ktor:ktor-serialization-kotlinx-
maplibre-android = { module = "org.maplibre.gl:android-sdk", version.ref = "maplibre-android-sdk" }
maplibre-android-scalebar = { module = "org.maplibre.gl:android-plugin-scalebar-v9", version.ref = "maplibre-android-plugins" }
spatialk-geojson = { group = "io.github.dellisd.spatialk", name = "geojson", version.ref = "spatialk" }
lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref="lifecycle" }

[plugins]
android-application = { id = "com.android.application", version.ref = "gradle-android" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.viewinterop.AndroidView
import co.touchlab.kermit.Logger
import dev.sargunv.maplibrecompose.core.AndroidMap
import dev.sargunv.maplibrecompose.core.AndroidMapSnapshotter
import dev.sargunv.maplibrecompose.core.AndroidScaleBar
import dev.sargunv.maplibrecompose.core.MapOptions
import dev.sargunv.maplibrecompose.core.MaplibreMap
Expand Down Expand Up @@ -79,6 +80,7 @@ internal fun AndroidMapView(
mapView = mapView,
map = map,
scaleBar = AndroidScaleBar(context, mapView, map),
mapSnapshotter = AndroidMapSnapshotter(context, layoutDir, density),
layoutDir = layoutDir,
density = density,
callbacks = callbacks,
Expand All @@ -92,6 +94,8 @@ internal fun AndroidMapView(
},
update = { _ ->
val map = currentMap ?: return@AndroidView
map.getMapSnapshotter().density = density
map.getMapSnapshotter().layoutDir = layoutDir
map.layoutDir = layoutDir
map.density = density
map.callbacks = callbacks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import androidx.compose.ui.unit.dp
import co.touchlab.kermit.Logger
import dev.sargunv.maplibrecompose.core.util.correctedAndroidUri
import dev.sargunv.maplibrecompose.core.util.toBoundingBox
import dev.sargunv.maplibrecompose.core.util.toCameraPosition
import dev.sargunv.maplibrecompose.core.util.toGravity
import dev.sargunv.maplibrecompose.core.util.toLatLng
import dev.sargunv.maplibrecompose.core.util.toLatLngBounds
import dev.sargunv.maplibrecompose.core.util.toMLNCameraPosition
import dev.sargunv.maplibrecompose.core.util.toMLNExpression
import dev.sargunv.maplibrecompose.core.util.toOffset
import dev.sargunv.maplibrecompose.core.util.toPointF
Expand All @@ -30,7 +32,6 @@ import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.time.Duration
import kotlin.time.DurationUnit
import org.maplibre.android.camera.CameraPosition as MLNCameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.VisibleRegion as MLNVisibleRegion
import org.maplibre.android.gestures.MoveGestureDetector
Expand All @@ -52,6 +53,7 @@ internal class AndroidMap(
private val mapView: MapView,
private val map: MapLibreMap,
private val scaleBar: AndroidScaleBar,
private val mapSnapshotter: AndroidMapSnapshotter,
layoutDir: LayoutDirection,
density: Density,
internal var callbacks: MaplibreMap.Callbacks,
Expand Down Expand Up @@ -258,47 +260,14 @@ internal class AndroidMap(
}
}

private fun MLNCameraPosition.toCameraPosition(): CameraPosition =
with(density) {
CameraPosition(
target = target?.toPosition() ?: Position(0.0, 0.0),
zoom = zoom,
bearing = bearing,
tilt = tilt,
padding =
padding?.let {
PaddingValues.Absolute(
left = it[0].toInt().toDp(),
top = it[1].toInt().toDp(),
right = it[2].toInt().toDp(),
bottom = it[3].toInt().toDp(),
)
} ?: PaddingValues.Absolute(0.dp),
)
}

private fun CameraPosition.toMLNCameraPosition(): MLNCameraPosition =
with(density) {
MLNCameraPosition.Builder()
.target(target.toLatLng())
.zoom(zoom)
.tilt(tilt)
.bearing(bearing)
.padding(
left = padding.calculateLeftPadding(layoutDir).toPx().toDouble(),
top = padding.calculateTopPadding().toPx().toDouble(),
right = padding.calculateRightPadding(layoutDir).toPx().toDouble(),
bottom = padding.calculateBottomPadding().toPx().toDouble(),
)
.build()
}

override fun getCameraPosition(): CameraPosition {
return map.cameraPosition.toCameraPosition()
return map.cameraPosition.toCameraPosition(density)
}

override fun setCameraPosition(cameraPosition: CameraPosition) {
map.moveCamera(CameraUpdateFactory.newCameraPosition(cameraPosition.toMLNCameraPosition()))
map.moveCamera(
CameraUpdateFactory.newCameraPosition(cameraPosition.toMLNCameraPosition(density, layoutDir))
)
}

private class CancelableCoroutineCallback(private val cont: Continuation<Unit>) :
Expand All @@ -308,10 +277,14 @@ internal class AndroidMap(
override fun onFinish() = cont.resume(Unit)
}

override fun getStyleUri() = lastStyleUri

override suspend fun animateCameraPosition(finalPosition: CameraPosition, duration: Duration) =
suspendCoroutine { cont ->
map.animateCamera(
CameraUpdateFactory.newCameraPosition(finalPosition.toMLNCameraPosition()),
CameraUpdateFactory.newCameraPosition(
finalPosition.toMLNCameraPosition(density, layoutDir)
),
duration.toInt(DurationUnit.MILLISECONDS),
CancelableCoroutineCallback(cont),
)
Expand Down Expand Up @@ -373,6 +346,8 @@ internal class AndroidMap(

override fun metersPerDpAtLatitude(latitude: Double) =
map.projection.getMetersPerPixelAtLatitude(latitude)

override fun getMapSnapshotter() = mapSnapshotter
}

private fun MLNVisibleRegion.toVisibleRegion() =
Expand Down
Loading
Loading