Skip to content

Instantly share code, notes, and snippets.

@alexjlockwood
Created February 12, 2026 16:30
Show Gist options
  • Select an option

  • Save alexjlockwood/b3dd3a4b2e39414128c0d5a4d1a4aca1 to your computer and use it in GitHub Desktop.

Select an option

Save alexjlockwood/b3dd3a4b2e39414128c0d5a4d1a4aca1 to your computer and use it in GitHub Desktop.
package com.lyft.android.composemap
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.awaitDragOrCancellation
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.motionEventSpy
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.viewinterop.AndroidView
import com.lyft.android.common.geo.LatitudeLongitude
import com.lyft.android.composemap.internal.EmbeddedMapState
import com.lyft.android.composemap.internal.EmbeddedMapView
import com.lyft.android.composemap.internal.MapObjectsComposeAdapter
import com.lyft.android.composemap.internal.MapScopeImpl
import com.lyft.android.composemap.internal.MapSnapshotMemoryManager
import com.lyft.android.composemap.internal.maplibre.MapLibreObjectsComposeAdapter
import com.lyft.android.design.coreui.compose.attributes.Spacings
import com.lyft.android.maps.mapbox.compose.MapboxComposeBridge
import com.lyft.android.maps.mapbox.placeholder.MapboxPlaceholderLayerManager
interface EmbeddedMapScope :
MapScope,
MapPluginMapScope
enum class EmbeddedMapMode {
NON_INTERACTIVE,
INTERACTIVE,
TWO_FINGERS_INTERACTIVE,
}
@Immutable
class MapSnapshotHolder {
internal var snapshot: Bitmap? by mutableStateOf(null)
}
/**
* Remembers map state, optionally initial map position could be provided.
* If [initialPositions] list is empty - any other parameters will not be applied.
*/
@Composable
fun rememberMapState(
initialPositions: List<LatitudeLongitude> = emptyList(),
initialBearing: Float? = null,
initialTilt: Float? = null,
initialZoom: Float? = null,
initialPaddingLeft: Dp = Spacings.Three,
initialPaddingTop: Dp = Spacings.Three,
initialPaddingRight: Dp = Spacings.Three,
initialPaddingBottom: Dp = Spacings.Three,
): EmbeddedMapState {
return remember { EmbeddedMapState() }.also {
if (initialPositions.isNotEmpty()) {
LaunchedEffect(Unit) {
it.moveCamera(
positions = initialPositions,
bearing = initialBearing,
tilt = initialTilt,
zoom = initialZoom,
paddingLeft = initialPaddingLeft,
paddingTop = initialPaddingTop,
paddingRight = initialPaddingRight,
paddingBottom = initialPaddingBottom,
animationMode = MapState.AnimationMode.NoAnimation,
)
}
}
}
}
/**
* Renders map as a composable function.
*
* @param modifier
* @param state - MapState used to control camera and query it's current position.
* @param interactiveMode - mode of operation of the map. For displaying map in a list NON_INTERACTIVE or TWO_FINGERS_INTERACTIVE should be used.
* @param cacheTilesForZooming - if map is non interactive or not expected to be panned/zoomed a lot having this disabled improves memory footprint.
* @param mapSnapshotHolder - holder object used to preserve map's snapshot over reattaches. Should be stored in plugin's state and provided to the map.
* @param content - content function that supports drawing regular Map elements as well as MapPlugins
*/
@Composable
fun EmbeddedMap(
modifier: Modifier = Modifier,
state: EmbeddedMapState = rememberMapState(),
interactiveMode: EmbeddedMapMode = EmbeddedMapMode.NON_INTERACTIVE,
cacheTilesForZooming: Boolean = false,
mapSnapshotHolder: MapSnapshotHolder? = null,
content: @Composable EmbeddedMapScope.() -> Unit,
) {
val ctx = LocalContext.current
val noOffsetLong = remember { mutableLongStateOf(0L) }
val noOffsetInt = remember<State<Int>> { mutableIntStateOf(0) }
val composeBridge = remember { mutableStateOf<MapboxComposeBridge?>(null) }
val mapObjects = remember { mutableStateOf<MapObjectsComposeAdapter?>(null) }
LaunchedEffect(composeBridge.value, state) {
state.stateBridge = composeBridge.value
}
val coroutineScope = rememberCoroutineScope()
val embeddedMapScope = remember { mutableStateOf<EmbeddedMapScope?>(null) }
// todo support WATERMARK for GoogleMaps when added, see com.lyft.android.maps.google.GoogleMapView.setMapAttributionVisibility
val embeddedView = remember(ctx) {
EmbeddedMapView(
interactiveMode = interactiveMode,
cacheTilesForZooming = cacheTilesForZooming,
context = ctx,
coroutineScope = coroutineScope,
mapReady = { mapView, map, style ->
val bridge = MapboxComposeBridge(
mapView,
map,
).also { composeBridge.value = it }
val mapObjects = MapLibreObjectsComposeAdapter(
MapboxPlaceholderLayerManager(style),
style,
bridge,
).also { mapObjects.value = it }
embeddedMapScope.value = MapScopeImpl(
mapObjects,
mutableLongStateOf(0),
mutableIntStateOf(0),
state,
)
},
onClick = { composeBridge.value?.onClick(it.x, it.y) == true },
)
}
Box(modifier.clearAndSetSemantics { /*disabled since map isn't accessible*/ }) {
AndroidView({ embeddedView })
if (mapSnapshotHolder != null) {
mapSnapshotHolder.snapshot?.let {
Image(it.asImageBitmap(), contentDescription = null, Modifier.matchParentSize())
}
DisposableEffect(Unit) {
var rendered = false
embeddedView.addOnDidFinishRenderingMapListener {
rendered = true
mapSnapshotHolder.snapshot = null
}
onDispose {
if (!rendered) return@onDispose
mapSnapshotHolder.snapshot = embeddedView.toBitmap()
MapSnapshotMemoryManager.registerSnapshot(ctx, mapSnapshotHolder)
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.dispatchTouchEventsToMap(embeddedView)
.then(mapObjects.value?.handleMapClicks(noOffsetLong, noOffsetInt) ?: Modifier),
) {
embeddedMapScope.value?.content()
}
}
}
private fun Modifier.dispatchTouchEventsToMap(mapView: EmbeddedMapView) = then(
Modifier
.motionEventSpy(mapView::dispatchTouchEventFromCompose)
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(false)
if (
currentEvent.changes.size > 1 ||
awaitPointerEvent(PointerEventPass.Initial).changes.size > 1 ||
awaitDragOrCancellation(down.id) != null
) {
val changes = currentEvent.changes
for (i in changes.indices) {
// when drag detected (including pinch to zoom) - consume the event
changes[i].consume()
}
}
}
},
)
package com.lyft.android.composemap.internal
import android.annotation.SuppressLint
import android.content.ComponentCallbacks2
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.os.Bundle
import android.os.Parcelable
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.TextureView
import androidx.core.view.children
import com.lyft.android.composemap.EmbeddedMapMode
import com.lyft.android.experiments.constants.IConstantsProvider
import com.lyft.android.maps.core.IMapTouchDispatcher
import com.lyft.android.maps.core.IMapView.Companion.MAX_ZOOM_LEVEL
import com.lyft.android.maps.mapbox.MapBoxMapArguments
import com.lyft.android.scabbard.cornerstone.CornerstoneInterface
import com.lyft.android.scabbard.cornerstone.CornerstoneLocator
import com.lyft.android.scabbard.cornerstone.get
import com.lyft.android.scoop.findCornerstoneLocator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.MapLibreMapOptions
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapView.OnDidFailLoadingMapListener
import org.maplibre.android.maps.Style
import kotlin.coroutines.resume
@SuppressLint("ViewConstructor") // only created manually
internal class EmbeddedMapView(
private val interactiveMode: EmbeddedMapMode,
cacheTilesForZooming: Boolean,
context: Context,
coroutineScope: CoroutineScope,
mapReady: (mapView: MapView, map: MapLibreMap, style: Style) -> Unit,
private val onClick: (e: MotionEvent) -> Boolean,
) : MapView(
context,
MapLibreMapOptions
.createFromAttributes(context)
.attributionEnabled(false)
.logoEnabled(false)
.compassEnabled(false)
.tiltGesturesEnabled(false)
.rotateGesturesEnabled(false)
.maxZoomPreference(MAX_ZOOM_LEVEL)
.textureMode(true),
),
IMapTouchDispatcher {
override fun dispatchTouchEventFromCompose(ev: MotionEvent): Boolean {
return dispatchTouchEvent(ev)
}
private val clickDetector = GestureDetector(
context,
object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
return onClick(e)
}
},
)
override fun onTouchEvent(event: MotionEvent): Boolean {
if (interactiveMode == EmbeddedMapMode.NON_INTERACTIVE) return false
if (clickDetector.onTouchEvent(event)) return true
return when (interactiveMode) {
EmbeddedMapMode.NON_INTERACTIVE -> throw IllegalStateException("Should never happen!")
EmbeddedMapMode.TWO_FINGERS_INTERACTIVE -> handleTwoFingerTouch(event)
EmbeddedMapMode.INTERACTIVE -> {
requestDisallowInterceptTouchEvent(true)
super.onTouchEvent(event)
}
}
}
init {
@CornerstoneInterface
class EmbeddedMapDeps(sl: CornerstoneLocator = context.findCornerstoneLocator()) {
val mapBoxMapArguments: MapBoxMapArguments = sl.get(javaClass)
val constantsProvider: IConstantsProvider = sl.get(javaClass)
}
val deps = EmbeddedMapDeps()
setWillNotDraw(true)
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
getMapAsync { map ->
if (cacheTilesForZooming) {
map.tileCacheEnabled = true
} else {
map.tileCacheEnabled = false
map.prefetchZoomDelta = 0
}
if (interactiveMode == EmbeddedMapMode.NON_INTERACTIVE) {
map.uiSettings.setAllGesturesEnabled(false)
}
val mapView = this
coroutineScope.launch {
val style = map.loadStyle(
mapView = mapView,
styleUri = deps.mapBoxMapArguments.styleUrl,
timeoutMs = deps.constantsProvider.get(Experimentation.Constants.MAP_XP_FALLBACK_STYLE_DELAY_MILLIS),
fallbackStyleUri = deps.mapBoxMapArguments.styleFallbackUrl,
) ?: return@launch // map is not ready and it is not clear what we can do about it
mapReady(mapView, map, style)
}
}
}
override fun onSaveInstanceState(): Parcelable? {
val bundle = Bundle()
onSaveInstanceState(bundle)
bundle.putParcelable("original_view_state", super.onSaveInstanceState())
return bundle
}
override fun onRestoreInstanceState(state: Parcelable) {
if (state !is Bundle) return
@Suppress("DEPRECATION")
state.getParcelable<Parcelable?>("original_view_state")?.let { originalState ->
super.onRestoreInstanceState(originalState)
state.remove("original_view_state")
}
onCreate(state)
}
private val onLowMemCallback = object : ComponentCallbacks2 {
override fun onConfigurationChanged(newConfig: Configuration) = Unit
@Suppress("OVERRIDE_DEPRECATION")
override fun onLowMemory() {
this@EmbeddedMapView.onLowMemory()
}
override fun onTrimMemory(level: Int) {
this@EmbeddedMapView.onLowMemory()
}
}
override fun onAttachedToWindow() {
context.registerComponentCallbacks(onLowMemCallback)
super.onAttachedToWindow()
}
override fun onDetachedFromWindow() {
context.unregisterComponentCallbacks(onLowMemCallback)
super.onDetachedFromWindow()
}
fun toBitmap(): Bitmap? = children.filterIsInstance<TextureView>().firstOrNull()?.bitmap
private var gestureInProgress = false
private fun handleTwoFingerTouch(event: MotionEvent): Boolean {
val cancelAction = event.actionMasked == MotionEvent.ACTION_CANCEL || event.actionMasked == MotionEvent.ACTION_UP
return when {
event.actionMasked == MotionEvent.ACTION_DOWN -> {
super.onTouchEvent(event)
}
!cancelAction && (gestureInProgress || event.pointerCount > 1) -> {
gestureInProgress = true
parent?.requestDisallowInterceptTouchEvent(true)
super.onTouchEvent(event)
}
else -> {
parent?.requestDisallowInterceptTouchEvent(false)
gestureInProgress = false
val oldAction = event.action
event.action = MotionEvent.ACTION_CANCEL
super.onTouchEvent(event)
event.action = oldAction
false
}
}
}
}
private suspend fun MapLibreMap.loadStyle(
mapView: MapView,
styleUri: String?,
timeoutMs: Long,
fallbackStyleUri: String? = null,
): Style? {
val style = styleUri?.let {
withTimeoutOrNull(timeoutMs) {
doLoadStyle(mapView, it)
}
}
return style ?: fallbackStyleUri?.let { doLoadStyle(mapView = mapView, styleUri = fallbackStyleUri) }
}
private suspend fun MapLibreMap.doLoadStyle(
mapView: MapView,
styleUri: String,
): Style? = suspendCancellableCoroutine {
// Do the same checks as Mapbox does to not stuck with empty map in case of invalid style URL
// https://github.com/maplibre/maplibre-gl-native/blob/724dae8375e8e08ddcf76dc356a7edb9e5ddc164/platform/android/MapboxGLAndroidSDK/src/main/java/com/mapbox/mapboxsdk/module/http/HttpRequestImpl.java#L66
val isValidStyleUri = styleUri.startsWith("asset://", ignoreCase = true) ||
styleUri.startsWith("file://", ignoreCase = true) ||
styleUri.toHttpUrlOrNull() != null
if (isValidStyleUri) {
object : OnDidFailLoadingMapListener {
init {
it.invokeOnCancellation {
mapView.removeOnDidFailLoadingMapListener(this)
}
mapView.addOnDidFailLoadingMapListener(this)
}
override fun onDidFailLoadingMap(reason: String) = it.resume(null)
}
setStyle(styleUri) { style -> it.resume(style) }
} else {
it.resume(null)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment