Skip to content
Draft
Show file tree
Hide file tree
Changes from 10 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
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import dev.sargunv.maplibrecompose.demoapp.demos.ClusteredPointsDemo
import dev.sargunv.maplibrecompose.demoapp.demos.EdgeToEdgeDemo
import dev.sargunv.maplibrecompose.demoapp.demos.FrameRateDemo
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.material3.controls.AttributionButton
import dev.sargunv.maplibrecompose.material3.controls.DisappearingCompassButton
Expand All @@ -62,6 +63,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)
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.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

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 lifeCycleOwner = LocalLifecycleOwner.current

DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE) {
cameraState.cancelSnapshotter()
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)

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

@Composable
private fun SnapshotterControls(
cameraState: CameraState,
isLoading: MutableState<Boolean>,
snapshot: MutableState<ImageBitmap?>,
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Button(
onClick = {
isLoading.value = true
cameraState.snapshot(
width = 500.dp,
height = 500.dp,
styleUri = DEFAULT_STYLE,
cameraPosition = cameraState.position,
callback = {
isLoading.value = false
snapshot.value = it
},
)
}
) {
Text("Take 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 @@ -90,5 +90,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
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.MaplibreMap
import org.maplibre.android.MapLibre
Expand Down Expand Up @@ -68,6 +69,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 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 @@ -253,45 +255,14 @@ internal class AndroidMap(
}
}

private fun MLNCameraPosition.toCameraPosition(): CameraPosition =
CameraPosition(
target = target?.toPosition() ?: Position(0.0, 0.0),
zoom = zoom,
bearing = bearing,
tilt = tilt,
padding =
padding?.let {
PaddingValues.Absolute(
left = it[0].dp,
top = it[1].dp,
right = it[2].dp,
bottom = it[3].dp,
)
} ?: 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()
}

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 @@ -304,7 +275,9 @@ internal class AndroidMap(
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 @@ -366,6 +339,8 @@ internal class AndroidMap(

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

override fun getMapSnapshotter() = mapSnapshotter
}

private fun MLNVisibleRegion.toVisibleRegion() =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package dev.sargunv.maplibrecompose.core

import android.content.Context
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import dev.sargunv.maplibrecompose.core.util.correctedAndroidUri
import dev.sargunv.maplibrecompose.core.util.toLatLngBounds
import dev.sargunv.maplibrecompose.core.util.toMLNCameraPosition
import io.github.dellisd.spatialk.geojson.BoundingBox
import org.maplibre.android.maps.Style
import org.maplibre.android.snapshotter.MapSnapshotter as MLNMapSnapshotter

internal class AndroidMapSnapshotter(
private val context: Context,
private val layoutDir: LayoutDirection,
private val density: Density,
) : MapSnapshotter {
private var impl: MLNMapSnapshotter? = null
private var isStarted: Boolean = false

override fun snapshot(
width: Dp,
height: Dp,
styleUri: String,
region: BoundingBox?,
cameraPosition: CameraPosition?,
showLogo: Boolean,
callback: (ImageBitmap) -> Unit,
errorHandler: (String) -> Unit,
) {
with(density) {
if (isStarted) return

isStarted = true

val styleBuilder = Style.Builder().fromUri(styleUri.correctedAndroidUri())
val options =
MLNMapSnapshotter.Options(width.roundToPx(), height.roundToPx())
.withStyleBuilder(styleBuilder)
.withRegion(region?.toLatLngBounds())
.withCameraPosition(cameraPosition?.toMLNCameraPosition(this, layoutDir))
.withLogo(showLogo)

impl = MLNMapSnapshotter(context, options)
impl?.start({ snapshot ->
callback(snapshot.bitmap.asImageBitmap())
isStarted = false
}) { error ->
errorHandler(error)
isStarted = false
}
}
}

override fun cancel() {
impl?.cancel()
isStarted = false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@ package dev.sargunv.maplibrecompose.core.util
import android.graphics.PointF
import android.graphics.RectF
import android.view.Gravity
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonNull
import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive
import dev.sargunv.maplibrecompose.core.CameraPosition
import dev.sargunv.maplibrecompose.expressions.ast.BooleanLiteral
import dev.sargunv.maplibrecompose.expressions.ast.ColorLiteral
import dev.sargunv.maplibrecompose.expressions.ast.CompiledExpression
Expand All @@ -31,6 +34,7 @@ import io.github.dellisd.spatialk.geojson.BoundingBox
import io.github.dellisd.spatialk.geojson.Position
import java.net.URI
import java.net.URISyntaxException
import org.maplibre.android.camera.CameraPosition as MLNCameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.geometry.LatLngBounds
import org.maplibre.android.style.expressions.Expression as MLNExpression
Expand Down Expand Up @@ -70,6 +74,37 @@ internal fun BoundingBox.toLatLngBounds(): LatLngBounds =
lonWest = southwest.longitude,
)

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

internal fun CameraPosition.toMLNCameraPosition(
density: Density,
layoutDir: LayoutDirection,
): 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()
}

internal fun CompiledExpression<*>.toMLNExpression(): MLNExpression? =
if (this == NullLiteral) null else MLNExpression.Converter.convert(normalizeJsonLike(false))

Expand Down
Loading