Remove local maplibre compose library
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2022-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.maplibre.compose"
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
explicitApi()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(libs.maplibre)
|
||||
api(libs.maplibre.ktx)
|
||||
api(libs.maplibre.annotation)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
* Copyright 2021 Google LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.maplibre.compose
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import org.maplibre.android.location.modes.CameraMode as InternalCameraMode
|
||||
|
||||
@Immutable
|
||||
public enum class CameraMode {
|
||||
NONE,
|
||||
NONE_COMPASS,
|
||||
NONE_GPS,
|
||||
TRACKING,
|
||||
TRACKING_COMPASS,
|
||||
TRACKING_GPS,
|
||||
TRACKING_GPS_NORTH;
|
||||
|
||||
@InternalCameraMode.Mode
|
||||
internal fun toInternal(): Int = when (this) {
|
||||
NONE -> InternalCameraMode.NONE
|
||||
NONE_COMPASS -> InternalCameraMode.NONE_COMPASS
|
||||
NONE_GPS -> InternalCameraMode.NONE_GPS
|
||||
TRACKING -> InternalCameraMode.TRACKING
|
||||
TRACKING_COMPASS -> InternalCameraMode.TRACKING_COMPASS
|
||||
TRACKING_GPS -> InternalCameraMode.TRACKING_GPS
|
||||
TRACKING_GPS_NORTH -> InternalCameraMode.TRACKING_GPS_NORTH
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
fun fromInternal(@InternalCameraMode.Mode mode: Int): CameraMode = when (mode) {
|
||||
InternalCameraMode.NONE -> NONE
|
||||
InternalCameraMode.NONE_COMPASS -> NONE_COMPASS
|
||||
InternalCameraMode.NONE_GPS -> NONE_GPS
|
||||
InternalCameraMode.TRACKING -> TRACKING
|
||||
InternalCameraMode.TRACKING_COMPASS -> TRACKING_COMPASS
|
||||
InternalCameraMode.TRACKING_GPS -> TRACKING_GPS
|
||||
InternalCameraMode.TRACKING_GPS_NORTH -> TRACKING_GPS_NORTH
|
||||
else -> error("Unknown camera mode: $mode")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
* Copyright 2021 Google LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.maplibre.compose
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_ANIMATION
|
||||
import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE
|
||||
import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION
|
||||
|
||||
/**
|
||||
* Enumerates the different reasons why the map camera started to move.
|
||||
*
|
||||
* Based on enum values from https://docs.maptiler.com/maplibre-gl-native-android/org.maplibre.android.maps/#oncameramovestartedlistener.
|
||||
*
|
||||
* [NO_MOVEMENT_YET] is used as the initial state before any map movement has been observed.
|
||||
*
|
||||
* [UNKNOWN] is used to represent when an unsupported integer value is provided to [fromInt] - this
|
||||
* may be a new constant value from the Maps SDK that isn't supported by maps-compose yet, in which
|
||||
* case this library should be updated to include a new enum value for that constant.
|
||||
*/
|
||||
@Immutable
|
||||
public enum class CameraMoveStartedReason(public val value: Int) {
|
||||
UNKNOWN(-2),
|
||||
NO_MOVEMENT_YET(-1),
|
||||
GESTURE(REASON_API_GESTURE),
|
||||
API_ANIMATION(REASON_API_ANIMATION),
|
||||
DEVELOPER_ANIMATION(REASON_DEVELOPER_ANIMATION);
|
||||
|
||||
public companion object {
|
||||
/**
|
||||
* Converts from the Maps SDK [org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener]
|
||||
* constants to [CameraMoveStartedReason], or returns [UNKNOWN] if there is no such
|
||||
* [CameraMoveStartedReason] for the given [value].
|
||||
*
|
||||
* See https://docs.maptiler.com/maplibre-gl-native-android/org.maplibre.android.maps/#oncameramovestartedlistener.
|
||||
*/
|
||||
public fun fromInt(value: Int): CameraMoveStartedReason {
|
||||
return values().firstOrNull { it.value == value } ?: return UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
* Copyright 2021 Google LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.maplibre.compose
|
||||
|
||||
import android.location.Location
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.maplibre.android.camera.CameraPosition
|
||||
import org.maplibre.android.camera.CameraUpdateFactory
|
||||
import org.maplibre.android.maps.MapLibreMap
|
||||
import org.maplibre.android.maps.Projection
|
||||
|
||||
/**
|
||||
* Create and [rememberSaveable] a [CameraPositionState] using [CameraPositionState.Saver].
|
||||
* [init] will be called when the [CameraPositionState] is first created to configure its
|
||||
* initial state.
|
||||
*/
|
||||
@Composable
|
||||
public inline fun rememberCameraPositionState(
|
||||
crossinline init: CameraPositionState.() -> Unit = {}
|
||||
): CameraPositionState = rememberSaveable(saver = CameraPositionState.Saver) {
|
||||
CameraPositionState().apply(init)
|
||||
}
|
||||
|
||||
/**
|
||||
* A state object that can be hoisted to control and observe the map's camera state.
|
||||
* A [CameraPositionState] may only be used by a single [MapLibreMap] composable at a time
|
||||
* as it reflects instance state for a single view of a map.
|
||||
*
|
||||
* @param position the initial camera position
|
||||
* @param cameraMode the initial camera mode
|
||||
*/
|
||||
public class CameraPositionState(
|
||||
position: CameraPosition = CameraPosition.Builder().build(),
|
||||
cameraMode: CameraMode = CameraMode.NONE,
|
||||
) {
|
||||
/**
|
||||
* Whether the camera is currently moving or not. This includes any kind of movement:
|
||||
* panning, zooming, or rotation.
|
||||
*/
|
||||
public var isMoving: Boolean by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* The reason for the start of the most recent camera moment, or
|
||||
* [CameraMoveStartedReason.NO_MOVEMENT_YET] if the camera hasn't moved yet or
|
||||
* [CameraMoveStartedReason.UNKNOWN] if an unknown constant is received from the Maps SDK.
|
||||
*/
|
||||
public var cameraMoveStartedReason: CameraMoveStartedReason by mutableStateOf(
|
||||
CameraMoveStartedReason.NO_MOVEMENT_YET
|
||||
)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* Returns the current [Projection] to be used for converting between screen
|
||||
* coordinates and lat/lng.
|
||||
*/
|
||||
public val projection: Projection?
|
||||
get() = map?.projection
|
||||
|
||||
/**
|
||||
* Local source of truth for the current camera position.
|
||||
* While [map] is non-null this reflects the current position of [map] as it changes.
|
||||
* While [map] is null it reflects the last known map position, or the last value set by
|
||||
* explicitly setting [position].
|
||||
*/
|
||||
internal var rawPosition by mutableStateOf(position)
|
||||
|
||||
/**
|
||||
* Current position of the camera on the map.
|
||||
*/
|
||||
public var position: CameraPosition
|
||||
get() = rawPosition
|
||||
set(value) {
|
||||
synchronized(lock) {
|
||||
val map = map
|
||||
if (map == null) {
|
||||
rawPosition = value
|
||||
} else {
|
||||
map.moveCamera(CameraUpdateFactory.newCameraPosition(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local source of truth for the current camera mode.
|
||||
* While [map] is non-null this reflects the current camera mode as it changes.
|
||||
* While [map] is null it reflects the last known camera mode, or the last value set by
|
||||
* explicitly setting [cameraMode].
|
||||
*/
|
||||
internal var rawCameraMode by mutableStateOf(cameraMode)
|
||||
|
||||
/**
|
||||
* Current tracking mode of the camera.
|
||||
*/
|
||||
public var cameraMode: CameraMode
|
||||
get() = rawCameraMode
|
||||
set(value) {
|
||||
synchronized(lock) {
|
||||
val map = map
|
||||
if (map == null) {
|
||||
rawCameraMode = value
|
||||
} else {
|
||||
map.locationComponent.cameraMode = value.toInternal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's last available location.
|
||||
*/
|
||||
public var location: Location? by mutableStateOf(null)
|
||||
internal set
|
||||
|
||||
// Used to perform side effects thread-safely.
|
||||
// Guards all mutable properties that are not `by mutableStateOf`.
|
||||
private val lock = Unit
|
||||
|
||||
// The map currently associated with this CameraPositionState.
|
||||
// Guarded by `lock`.
|
||||
private var map: MapLibreMap? by mutableStateOf(null)
|
||||
|
||||
// The current map is set and cleared by side effect.
|
||||
// There can be only one associated at a time.
|
||||
internal fun setMap(map: MapLibreMap?) {
|
||||
synchronized(lock) {
|
||||
if (this.map == null && map == null) return
|
||||
if (this.map != null && map != null) {
|
||||
error("CameraPositionState may only be associated with one MapLibreMap at a time")
|
||||
}
|
||||
this.map = map
|
||||
if (map == null) {
|
||||
isMoving = false
|
||||
} else {
|
||||
map.moveCamera(CameraUpdateFactory.newCameraPosition(position))
|
||||
map.locationComponent.cameraMode = cameraMode.toInternal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public companion object {
|
||||
/**
|
||||
* The default saver implementation for [CameraPositionState].
|
||||
*/
|
||||
public val Saver: Saver<CameraPositionState, SaveableCameraPositionData> = Saver(
|
||||
save = { SaveableCameraPositionData(it.position, it.cameraMode.toInternal()) },
|
||||
restore = { CameraPositionState(it.position, CameraMode.fromInternal(it.cameraMode)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Provides the [CameraPositionState] used by the map. */
|
||||
internal val LocalCameraPositionState = staticCompositionLocalOf { CameraPositionState() }
|
||||
|
||||
/** The current [CameraPositionState] used by the map. */
|
||||
public val currentCameraPositionState: CameraPositionState
|
||||
@[MapLibreMapComposable ReadOnlyComposable Composable]
|
||||
get() = LocalCameraPositionState.current
|
||||
|
||||
@Parcelize
|
||||
public data class SaveableCameraPositionData(
|
||||
val position: CameraPosition,
|
||||
val cameraMode: Int
|
||||
) : Parcelable
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
* Copyright 2021 Google LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.maplibre.compose
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import org.maplibre.android.style.layers.Property
|
||||
|
||||
@Immutable
|
||||
public enum class IconAnchor {
|
||||
CENTER,
|
||||
LEFT,
|
||||
RIGHT,
|
||||
TOP,
|
||||
BOTTOM,
|
||||
TOP_LEFT,
|
||||
TOP_RIGHT,
|
||||
BOTTOM_LEFT,
|
||||
BOTTOM_RIGHT;
|
||||
|
||||
@Property.ICON_ANCHOR
|
||||
internal fun toInternal(): String = when (this) {
|
||||
CENTER -> Property.ICON_ANCHOR_CENTER
|
||||
LEFT -> Property.ICON_ANCHOR_LEFT
|
||||
RIGHT -> Property.ICON_ANCHOR_RIGHT
|
||||
TOP -> Property.ICON_ANCHOR_TOP
|
||||
BOTTOM -> Property.ICON_ANCHOR_BOTTOM
|
||||
TOP_LEFT -> Property.ICON_ANCHOR_TOP_LEFT
|
||||
TOP_RIGHT -> Property.ICON_ANCHOR_TOP_RIGHT
|
||||
BOTTOM_LEFT -> Property.ICON_ANCHOR_BOTTOM_LEFT
|
||||
BOTTOM_RIGHT -> Property.ICON_ANCHOR_BOTTOM_RIGHT
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
* Copyright 2021 Google LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.maplibre.compose
|
||||
|
||||
import androidx.compose.runtime.AbstractApplier
|
||||
import org.maplibre.android.maps.MapLibreMap
|
||||
import org.maplibre.android.maps.Style
|
||||
import org.maplibre.android.plugins.annotation.SymbolManager
|
||||
|
||||
internal interface MapNode {
|
||||
fun onAttached() {}
|
||||
fun onRemoved() {}
|
||||
fun onCleared() {}
|
||||
}
|
||||
|
||||
private object MapNodeRoot : MapNode
|
||||
|
||||
internal class MapApplier(
|
||||
val map: MapLibreMap,
|
||||
val style: Style,
|
||||
val symbolManager: SymbolManager,
|
||||
) : AbstractApplier<MapNode>(MapNodeRoot) {
|
||||
private val decorations = mutableListOf<MapNode>()
|
||||
|
||||
override fun onClear() {
|
||||
symbolManager.deleteAll()
|
||||
decorations.forEach { it.onCleared() }
|
||||
decorations.clear()
|
||||
}
|
||||
|
||||
override fun insertBottomUp(index: Int, instance: MapNode) {
|
||||
decorations.add(index, instance)
|
||||
instance.onAttached()
|
||||
}
|
||||
|
||||
override fun insertTopDown(index: Int, instance: MapNode) {
|
||||
// insertBottomUp is preferred
|
||||
}
|
||||
|
||||
override fun move(from: Int, to: Int, count: Int) {
|
||||
decorations.move(from, to, count)
|
||||
}
|
||||
|
||||
override fun remove(index: Int, count: Int) {
|
||||
repeat(count) {
|
||||
decorations[index + it].onRemoved()
|
||||
}
|
||||
decorations.remove(index, count)
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
* Copyright 2021 Google LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.maplibre.compose
|
||||
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Composition
|
||||
import androidx.compose.runtime.CompositionContext
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCompositionContext
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import org.maplibre.android.MapLibre
|
||||
import org.maplibre.android.maps.MapLibreMap
|
||||
import org.maplibre.android.maps.MapView
|
||||
import org.maplibre.android.maps.Style
|
||||
import org.maplibre.android.plugins.annotation.SymbolManager
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
/**
|
||||
* A compose container for a MapLibre [MapView].
|
||||
*
|
||||
* Heavily inspired by https://github.com/googlemaps/android-maps-compose
|
||||
*
|
||||
* @param styleUri a URI where to asynchronously fetch a style for the map
|
||||
* @param modifier Modifier to be applied to the MapLibreMap
|
||||
* @param images images added to the map's style to be later used with [Symbol]
|
||||
* @param cameraPositionState the [CameraPositionState] to be used to control or observe the map's
|
||||
* camera state
|
||||
* @param uiSettings the [MapUiSettings] to be used for UI-specific settings on the map
|
||||
* @param symbolManagerSettings the [MapSymbolManagerSettings] to be used for symbol manager settings
|
||||
* @param locationSettings the [MapLocationSettings] to be used for location settings
|
||||
* @param content the content of the map
|
||||
*/
|
||||
@Composable
|
||||
public fun MapLibreMap(
|
||||
styleUri: String,
|
||||
modifier: Modifier = Modifier,
|
||||
images: ImmutableMap<String, Int> = persistentMapOf(),
|
||||
cameraPositionState: CameraPositionState = rememberCameraPositionState(),
|
||||
uiSettings: MapUiSettings = DefaultMapUiSettings,
|
||||
symbolManagerSettings: MapSymbolManagerSettings = DefaultMapSymbolManagerSettings,
|
||||
locationSettings: MapLocationSettings = DefaultMapLocationSettings,
|
||||
content: (@Composable @MapLibreMapComposable () -> Unit)? = null,
|
||||
) {
|
||||
// When in preview, early return a Box with the received modifier preserving layout
|
||||
if (LocalInspectionMode.current) {
|
||||
@Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return.
|
||||
Box(
|
||||
modifier = modifier.background(Color.DarkGray)
|
||||
) {
|
||||
Text("[Map]", modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val mapView = remember {
|
||||
MapLibre.getInstance(context)
|
||||
MapView(context)
|
||||
}
|
||||
|
||||
@Suppress("ModifierReused")
|
||||
AndroidView(modifier = modifier, factory = { mapView })
|
||||
MapLifecycle(mapView)
|
||||
|
||||
// rememberUpdatedState and friends are used here to make these values observable to
|
||||
// the subcomposition without providing a new content function each recomposition
|
||||
val currentCameraPositionState by rememberUpdatedState(cameraPositionState)
|
||||
val currentUiSettings by rememberUpdatedState(uiSettings)
|
||||
val currentMapLocationSettings by rememberUpdatedState(locationSettings)
|
||||
val currentSymbolManagerSettings by rememberUpdatedState(symbolManagerSettings)
|
||||
|
||||
val parentComposition = rememberCompositionContext()
|
||||
val currentContent by rememberUpdatedState(content)
|
||||
|
||||
LaunchedEffect(styleUri, images) {
|
||||
disposingComposition {
|
||||
parentComposition.newComposition(
|
||||
context = context,
|
||||
mapView = mapView,
|
||||
styleUri = styleUri,
|
||||
images = images,
|
||||
) {
|
||||
MapUpdater(
|
||||
cameraPositionState = currentCameraPositionState,
|
||||
uiSettings = currentUiSettings,
|
||||
locationSettings = currentMapLocationSettings,
|
||||
symbolManagerSettings = currentSymbolManagerSettings,
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalCameraPositionState provides cameraPositionState,
|
||||
) {
|
||||
currentContent?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun disposingComposition(factory: () -> Composition) {
|
||||
val composition = factory()
|
||||
try {
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
composition.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun CompositionContext.newComposition(
|
||||
context: Context,
|
||||
mapView: MapView,
|
||||
styleUri: String,
|
||||
images: ImmutableMap<String, Int>,
|
||||
noinline content: @Composable () -> Unit
|
||||
): Composition {
|
||||
val map = mapView.awaitMap()
|
||||
val style = map.awaitStyle(context, styleUri, images)
|
||||
val symbolManager = SymbolManager(mapView, map, style)
|
||||
return Composition(
|
||||
MapApplier(map, style, symbolManager),
|
||||
this
|
||||
).apply {
|
||||
setContent(content)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun MapView.awaitMap(): MapLibreMap = suspendCoroutine { continuation ->
|
||||
getMapAsync { map ->
|
||||
continuation.resume(map)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun MapLibreMap.awaitStyle(
|
||||
context: Context,
|
||||
styleUri: String,
|
||||
images: ImmutableMap<String, Int>,
|
||||
): Style = suspendCoroutine { continuation ->
|
||||
setStyle(
|
||||
Style.Builder().apply {
|
||||
fromUri(styleUri)
|
||||
images.forEach { (id, drawableRes) ->
|
||||
withImage(id, checkNotNull(context.getDrawable(drawableRes)) {
|
||||
"Drawable resource $drawableRes with id $id not found"
|
||||
})
|
||||
}
|
||||
}
|
||||
) { style ->
|
||||
continuation.resume(style)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers lifecycle observers to the local [MapView].
|
||||
*/
|
||||
@Composable
|
||||
private fun MapLifecycle(mapView: MapView) {
|
||||
val context = LocalContext.current
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
val previousState = remember { mutableStateOf(Lifecycle.Event.ON_CREATE) }
|
||||
DisposableEffect(context, lifecycle, mapView) {
|
||||
val mapLifecycleObserver = mapView.lifecycleObserver(previousState)
|
||||
val callbacks = mapView.componentCallbacks()
|
||||
|
||||
lifecycle.addObserver(mapLifecycleObserver)
|
||||
context.registerComponentCallbacks(callbacks)
|
||||
|
||||
onDispose {
|
||||
lifecycle.removeObserver(mapLifecycleObserver)
|
||||
context.unregisterComponentCallbacks(callbacks)
|
||||
}
|
||||
}
|
||||
DisposableEffect(mapView) {
|
||||
onDispose {
|
||||
mapView.onDestroy()
|
||||
mapView.removeAllViews()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MapView.lifecycleObserver(previousState: MutableState<Lifecycle.Event>): LifecycleEventObserver =
|
||||
LifecycleEventObserver { _, event ->
|
||||
event.targetState
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_CREATE -> {
|
||||
// Skip calling mapView.onCreate if the lifecycle did not go through onDestroy - in
|
||||
// this case the MapLibreMap composable also doesn't leave the composition. So,
|
||||
// recreating the map does not restore state properly which must be avoided.
|
||||
if (previousState.value != Lifecycle.Event.ON_STOP) {
|
||||
this.onCreate(Bundle())
|
||||
}
|
||||
}
|
||||
Lifecycle.Event.ON_START -> this.onStart()
|
||||
Lifecycle.Event.ON_RESUME -> this.onResume()
|
||||
Lifecycle.Event.ON_PAUSE -> this.onPause()
|
||||
Lifecycle.Event.ON_STOP -> this.onStop()
|
||||
Lifecycle.Event.ON_DESTROY -> {
|
||||
// handled in onDispose
|
||||
}
|
||||
Lifecycle.Event.ON_ANY -> error("ON_ANY should never be used")
|
||||
}
|
||||
previousState.value = event
|
||||
}
|
||||
|
||||
private fun MapView.componentCallbacks(): ComponentCallbacks2 =
|
||||
object : ComponentCallbacks2 {
|
||||
override fun onConfigurationChanged(config: Configuration) = Unit
|
||||
|
||||
@Suppress("OVERRIDE_DEPRECATION")
|
||||
override fun onLowMemory() = Unit
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
// We call the `MapView.onLowMemory` method for any memory trim level
|
||||
this@componentCallbacks.onLowMemory()
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
* Copyright 2021 Google LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.maplibre.compose
|
||||
|
||||
import androidx.compose.runtime.ComposableTargetMarker
|
||||
|
||||
/**
|
||||
* An annotation that can be used to mark a composable function as being expected to be use in a
|
||||
* composable function that is also marked or inferred to be marked as a [MapLibreMapComposable].
|
||||
*
|
||||
* This will produce build warnings when [MapLibreMapComposable] composable functions are used outside
|
||||
* of a [MapLibreMapComposable] content lambda, and vice versa.
|
||||
*/
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
@ComposableTargetMarker(description = "MapLibre Map Composable")
|
||||
@Target(
|
||||
AnnotationTarget.FILE,
|
||||
AnnotationTarget.FUNCTION,
|
||||
AnnotationTarget.PROPERTY_GETTER,
|
||||
AnnotationTarget.TYPE,
|
||||
AnnotationTarget.TYPE_PARAMETER,
|
||||
)
|
||||
public annotation class MapLibreMapComposable
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
* Copyright 2021 Google LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.maplibre.compose
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
internal val DefaultMapLocationSettings = MapLocationSettings()
|
||||
|
||||
/**
|
||||
* Data class for UI-related settings on the map.
|
||||
*
|
||||
* Note: Should not be a data class if in need of maintaining binary compatibility
|
||||
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
|
||||
*/
|
||||
public data class MapLocationSettings(
|
||||
public val locationEnabled: Boolean = false,
|
||||
public val backgroundTintColor: Color = Color.Unspecified,
|
||||
public val foregroundTintColor: Color = Color.Unspecified,
|
||||
public val backgroundStaleTintColor: Color = Color.Unspecified,
|
||||
public val foregroundStaleTintColor: Color = Color.Unspecified,
|
||||
public val accuracyColor: Color = Color.Unspecified,
|
||||
public val pulseEnabled: Boolean = false,
|
||||
public val pulseColor: Color = Color.Unspecified
|
||||
)
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
* Copyright 2021 Google LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.maplibre.compose
|
||||
|
||||
internal val DefaultMapSymbolManagerSettings = MapSymbolManagerSettings()
|
||||
|
||||
/**
|
||||
* Data class for UI-related settings on the map.
|
||||
*
|
||||
* Note: Should not be a data class if in need of maintaining binary compatibility
|
||||
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
|
||||
*/
|
||||
public data class MapSymbolManagerSettings(
|
||||
public val iconAllowOverlap: Boolean = false,
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
* Copyright 2021 Google LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.maplibre.compose
|
||||
|
||||
import android.view.Gravity
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
internal val DefaultMapUiSettings = MapUiSettings()
|
||||
|
||||
/**
|
||||
* Data class for UI-related settings on the map.
|
||||
*
|
||||
* Note: Should not be a data class if in need of maintaining binary compatibility
|
||||
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
|
||||
*/
|
||||
public data class MapUiSettings(
|
||||
public val compassEnabled: Boolean = true,
|
||||
public val rotationGesturesEnabled: Boolean = true,
|
||||
public val scrollGesturesEnabled: Boolean = true,
|
||||
public val tiltGesturesEnabled: Boolean = true,
|
||||
public val zoomGesturesEnabled: Boolean = true,
|
||||
public val logoGravity: Int = Gravity.BOTTOM,
|
||||
public val attributionGravity: Int = Gravity.BOTTOM,
|
||||
public val attributionTintColor: Color = Color.Unspecified,
|
||||
)
|
||||
@@ -1,153 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
* Copyright 2021 Google LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
@file:Suppress("MatchingDeclarationName")
|
||||
|
||||
package io.element.android.libraries.maplibre.compose
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ComposeNode
|
||||
import androidx.compose.runtime.currentComposer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import org.maplibre.android.location.LocationComponentActivationOptions
|
||||
import org.maplibre.android.location.LocationComponentOptions
|
||||
import org.maplibre.android.location.OnCameraTrackingChangedListener
|
||||
import org.maplibre.android.location.engine.LocationEngineRequest
|
||||
import org.maplibre.android.maps.MapLibreMap
|
||||
import org.maplibre.android.maps.Style
|
||||
|
||||
private const val LOCATION_REQUEST_INTERVAL = 750L
|
||||
|
||||
internal class MapPropertiesNode(
|
||||
val map: MapLibreMap,
|
||||
style: Style,
|
||||
context: Context,
|
||||
cameraPositionState: CameraPositionState,
|
||||
locationSettings: MapLocationSettings,
|
||||
) : MapNode {
|
||||
init {
|
||||
map.locationComponent.activateLocationComponent(
|
||||
LocationComponentActivationOptions.Builder(context, style)
|
||||
.locationComponentOptions(
|
||||
LocationComponentOptions.builder(context)
|
||||
.backgroundTintColor(locationSettings.backgroundTintColor.toArgb())
|
||||
.foregroundTintColor(locationSettings.foregroundTintColor.toArgb())
|
||||
.backgroundStaleTintColor(locationSettings.backgroundStaleTintColor.toArgb())
|
||||
.foregroundStaleTintColor(locationSettings.foregroundStaleTintColor.toArgb())
|
||||
.accuracyColor(locationSettings.accuracyColor.toArgb())
|
||||
.pulseEnabled(locationSettings.pulseEnabled)
|
||||
.pulseColor(locationSettings.pulseColor.toArgb())
|
||||
.build()
|
||||
)
|
||||
.locationEngineRequest(
|
||||
LocationEngineRequest.Builder(LOCATION_REQUEST_INTERVAL)
|
||||
.setPriority(LocationEngineRequest.PRIORITY_HIGH_ACCURACY)
|
||||
.setFastestInterval(LOCATION_REQUEST_INTERVAL)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
cameraPositionState.setMap(map)
|
||||
}
|
||||
|
||||
var cameraPositionState = cameraPositionState
|
||||
set(value) {
|
||||
if (value == field) return
|
||||
field.setMap(null)
|
||||
field = value
|
||||
value.setMap(map)
|
||||
}
|
||||
|
||||
override fun onAttached() {
|
||||
map.addOnCameraIdleListener {
|
||||
cameraPositionState.isMoving = false
|
||||
// addOnCameraIdleListener is only invoked when the camera position
|
||||
// is changed via .animate(). To handle updating state when .move()
|
||||
// is used, it's necessary to set the camera's position here as well
|
||||
cameraPositionState.rawPosition = map.cameraPosition
|
||||
// Updating user location on every camera move due to lack of a better location updates API.
|
||||
cameraPositionState.location = map.locationComponent.lastKnownLocation
|
||||
}
|
||||
map.addOnCameraMoveCancelListener {
|
||||
cameraPositionState.isMoving = false
|
||||
}
|
||||
map.addOnCameraMoveStartedListener {
|
||||
cameraPositionState.cameraMoveStartedReason = CameraMoveStartedReason.fromInt(it)
|
||||
cameraPositionState.isMoving = true
|
||||
}
|
||||
map.addOnCameraMoveListener {
|
||||
cameraPositionState.rawPosition = map.cameraPosition
|
||||
// Updating user location on every camera move due to lack of a better location updates API.
|
||||
cameraPositionState.location = map.locationComponent.lastKnownLocation
|
||||
}
|
||||
map.locationComponent.addOnCameraTrackingChangedListener(object : OnCameraTrackingChangedListener {
|
||||
override fun onCameraTrackingDismissed() {}
|
||||
|
||||
override fun onCameraTrackingChanged(currentMode: Int) {
|
||||
cameraPositionState.rawCameraMode = CameraMode.fromInternal(currentMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onRemoved() {
|
||||
cameraPositionState.setMap(null)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
cameraPositionState.setMap(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to keep the primary map properties up to date. This should never leave the map composition.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
@Composable
|
||||
internal inline fun MapUpdater(
|
||||
cameraPositionState: CameraPositionState,
|
||||
locationSettings: MapLocationSettings,
|
||||
uiSettings: MapUiSettings,
|
||||
symbolManagerSettings: MapSymbolManagerSettings,
|
||||
) {
|
||||
val mapApplier = currentComposer.applier as MapApplier
|
||||
val map = mapApplier.map
|
||||
val style = mapApplier.style
|
||||
val symbolManager = mapApplier.symbolManager
|
||||
val context = LocalContext.current
|
||||
ComposeNode<MapPropertiesNode, MapApplier>(
|
||||
factory = {
|
||||
MapPropertiesNode(
|
||||
map = map,
|
||||
style = style,
|
||||
context = context,
|
||||
cameraPositionState = cameraPositionState,
|
||||
locationSettings = locationSettings,
|
||||
)
|
||||
},
|
||||
update = {
|
||||
set(locationSettings.locationEnabled) { map.locationComponent.isLocationComponentEnabled = it }
|
||||
|
||||
set(uiSettings.compassEnabled) { map.uiSettings.isCompassEnabled = it }
|
||||
set(uiSettings.rotationGesturesEnabled) { map.uiSettings.isRotateGesturesEnabled = it }
|
||||
set(uiSettings.scrollGesturesEnabled) { map.uiSettings.isScrollGesturesEnabled = it }
|
||||
set(uiSettings.tiltGesturesEnabled) { map.uiSettings.isTiltGesturesEnabled = it }
|
||||
set(uiSettings.zoomGesturesEnabled) { map.uiSettings.isZoomGesturesEnabled = it }
|
||||
set(uiSettings.logoGravity) { map.uiSettings.logoGravity = it }
|
||||
set(uiSettings.attributionGravity) { map.uiSettings.attributionGravity = it }
|
||||
set(uiSettings.attributionTintColor) { map.uiSettings.setAttributionTintColor(it.toArgb()) }
|
||||
|
||||
set(symbolManagerSettings.iconAllowOverlap) { symbolManager.iconAllowOverlap = it }
|
||||
|
||||
update(cameraPositionState) { this.cameraPositionState = it }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
* Copyright 2021 Google LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.maplibre.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ComposeNode
|
||||
import androidx.compose.runtime.currentComposer
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import org.maplibre.android.geometry.LatLng
|
||||
import org.maplibre.android.plugins.annotation.Symbol
|
||||
import org.maplibre.android.plugins.annotation.SymbolManager
|
||||
import org.maplibre.android.plugins.annotation.SymbolOptions
|
||||
|
||||
internal class SymbolNode(
|
||||
val symbolManager: SymbolManager,
|
||||
val symbol: Symbol,
|
||||
) : MapNode {
|
||||
override fun onRemoved() {
|
||||
symbolManager.delete(symbol)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
symbolManager.delete(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A state object that can be hoisted to control and observe the symbol state.
|
||||
*
|
||||
* @param position the initial symbol position
|
||||
*/
|
||||
public class SymbolState(
|
||||
position: LatLng
|
||||
) {
|
||||
/**
|
||||
* Current position of the symbol.
|
||||
*/
|
||||
public var position: LatLng by mutableStateOf(position)
|
||||
|
||||
public companion object {
|
||||
/**
|
||||
* The default saver implementation for [SymbolState].
|
||||
*/
|
||||
public val Saver: Saver<SymbolState, LatLng> = Saver(
|
||||
save = { it.position },
|
||||
restore = { SymbolState(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
public fun rememberSymbolState(
|
||||
position: LatLng = LatLng(0.0, 0.0)
|
||||
): SymbolState = rememberSaveable(saver = SymbolState.Saver) {
|
||||
SymbolState(position)
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable for a symbol on the map.
|
||||
*
|
||||
* @param iconId an id of an image from the current [Style]
|
||||
* @param state the [SymbolState] to be used to control or observe the symbol
|
||||
* state such as its position and info window
|
||||
* @param iconAnchor the anchor for the symbol image
|
||||
*/
|
||||
@Composable
|
||||
@MapLibreMapComposable
|
||||
public fun Symbol(
|
||||
iconId: String,
|
||||
state: SymbolState = rememberSymbolState(),
|
||||
iconAnchor: IconAnchor? = null,
|
||||
) {
|
||||
val mapApplier = currentComposer.applier as MapApplier
|
||||
val symbolManager = mapApplier.symbolManager
|
||||
ComposeNode<SymbolNode, MapApplier>(
|
||||
factory = {
|
||||
SymbolNode(
|
||||
symbolManager = symbolManager,
|
||||
symbol = symbolManager.create(
|
||||
SymbolOptions().apply {
|
||||
withLatLng(state.position)
|
||||
withIconImage(iconId)
|
||||
iconAnchor?.let { withIconAnchor(it.toInternal()) }
|
||||
}
|
||||
),
|
||||
)
|
||||
},
|
||||
update = {
|
||||
update(state.position) {
|
||||
this.symbol.latLng = it
|
||||
symbolManager.update(this.symbol)
|
||||
}
|
||||
update(iconId) {
|
||||
this.symbol.iconImage = it
|
||||
symbolManager.update(this.symbol)
|
||||
}
|
||||
update(iconAnchor) {
|
||||
this.symbol.iconAnchor = it?.toInternal()
|
||||
symbolManager.update(this.symbol)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user