Created
February 12, 2026 16:30
-
-
Save alexjlockwood/b3dd3a4b2e39414128c0d5a4d1a4aca1 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | |
| } | |
| } | |
| } | |
| }, | |
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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