diff --git a/api/android-for-mapbox.api b/api/android-for-mapbox.api index 35c9a95..a3bce19 100644 --- a/api/android-for-mapbox.api +++ b/api/android-for-mapbox.api @@ -189,7 +189,7 @@ public final class com.mapconductor.mapbox.MapboxMapViewHolder implements com.ma public java.lang.Object getMap(); } public final class com.mapconductor.mapbox.MapboxMapViewKt { - public static final void MapboxMapView(com.mapconductor.mapbox.MapboxViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, com.mapconductor.core.map.MapProjection, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final void MapboxMapView(com.mapconductor.mapbox.MapboxViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, com.mapconductor.core.map.MapProjection, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); public static final com.mapconductor.mapbox.MapboxMapViewController createMapboxViewController(com.mapconductor.mapbox.MapboxMapViewHolder, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.MapProjection, com.mapconductor.core.map.MutableMapServiceRegistry); public static final com.mapconductor.mapbox.polygon.MapboxPolygonConductor getPolygonController(com.mapconductor.mapbox.MapboxMapViewHolder); public static final com.mapconductor.mapbox.circle.MapboxCircleController getCircleController(com.mapconductor.mapbox.MapboxMapViewHolder); diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt index 4ef7cee..f2a0f8d 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt @@ -21,9 +21,13 @@ import com.mapconductor.core.circle.CircleManager import com.mapconductor.core.map.CameraRestriction import com.mapconductor.core.map.MapCameraPositionInterface import com.mapconductor.core.map.MapProjection +import com.mapconductor.core.map.MapViewStyle import com.mapconductor.core.map.MutableMapServiceRegistry +import com.mapconductor.core.map.StyleMutationStore import com.mapconductor.core.map.VectorStyleAsDesign +import com.mapconductor.core.map.VectorStyleMutationSupportKey import com.mapconductor.core.map.VectorStyleSupportKey +import com.mapconductor.core.map.applyStyleMutations import com.mapconductor.core.marker.MarkerEventControllerInterface import com.mapconductor.core.marker.MarkerManager import com.mapconductor.core.marker.MarkerOverlayRendererInterface @@ -71,6 +75,15 @@ fun MapboxMapView( onCameraMove: OnCameraMoveHandler? = null, onCameraMoveEnd: OnCameraMoveHandler? = null, projection: MapProjection = MapProjection.Mercator, + /** + * How the map looks, when the app states it rather than naming a design. + * + * A vector style *is* the basemap. `com.mapconductor:vectorstyle` builds + * one; what happens underneath depends on this backend and the app does + * not have to know. + */ + style: MapViewStyle? = null, + onStyleDiagnostics: ((List) -> Unit)? = null, content: (@Composable MapboxMapViewScope.() -> Unit)? = null, ) { val holderRef = remember { Ref() } @@ -146,6 +159,29 @@ fun MapboxMapView( VectorStyleSupportKey, VectorStyleAsDesign(state) { url, rules -> MapboxMapDesign.StyleUri(url, rules) }, ) + // Mapbox can also be told to change a loaded style in place, + // so adjusting one need not hand it a new document -- which + // would drop its tiles and rebuild every overlay the app + // added. `VectorStyle` looks this up and takes the cheap road + // when it is here. + // + // The style is read through a lambda rather than captured: + // every design change replaces it, and a target holding the + // old one would patch a style the map has already dropped. + val mutations = + StyleMutationStore { list -> + applyStyleMutations( + MapboxStyleMutationTarget( + style = { holder.map.style }, + ready = { mapController.isShowingRequestedStyle() }, + ), + list, + ) + } + state.serviceRegistry.put(VectorStyleMutationSupportKey, mutations) + // Whatever was applied is lost the moment the map loads a + // style again; this is where it goes back on. + mapController.styleLoadedListener = { mutations.onStyleLoaded() } holderRef.value = holder controllerRef.value = mapController @@ -168,6 +204,8 @@ fun MapboxMapView( // Pass content if it needs to be rendered within the overlay providers in MapViewBase, // or handle it here if it's specific to MapboxMapView structure before calling MapViewBase. // For now, assuming content relates to overlay definitions. + style = style, + onStyleDiagnostics = onStyleDiagnostics, content = content, // This might need adjustment based on how overlays are handled customDisposableEffect = { _, holderRef -> diff --git a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt index 3542951..e10bc14 100644 --- a/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt +++ b/src/main/java/com/mapconductor/mapbox/MapboxMapViewController.kt @@ -208,6 +208,11 @@ class MapboxMapViewController internal constructor( this@MapboxMapViewController.mapDesignType = mapDesign mapDesignTypeChangeListener?.invoke(mapDesign) } + + // Last, once the overlays are back on: a vector style + // adjustment lost everything it set with the old style, and + // this is the moment the new one is known to be complete. + styleLoadedListener?.invoke() } } @@ -358,9 +363,39 @@ class MapboxMapViewController internal constructor( internal var mapDesignTypeChangeListener: MapboxMapDesignTypeChangeHandler? = null + /** + * Called once the map has a style up and the overlays are back on it. + * + * How a vector style adjustment survives a design change. Kept in step + * with `MapLibreViewController.styleLoadedListener`. + */ + internal var styleLoadedListener: (() -> Unit)? = null + + /** The document the map was last asked for, and the one it reported loading. */ + private var requestedStyle: String? = null + private var loadedStyleValue: String? = null + + /** + * Whether the style on screen is the document the map was last asked + * for. + * + * What [MapboxStyleMutationTarget] answers `isReady` with, and not the + * same question as "is a style loaded". A vector style hands the map a + * new document and then applies the deltas compiled for it; in between, + * the map still has the document being replaced, whose layers have + * other names, so every delta came back as a property this map "cannot + * set by name". Mirrors `MapLibreViewController.isShowingRequestedStyle`. + */ + internal fun isShowingRequestedStyle(): Boolean = + holder.map.style != null && loadedStyleValue == requestedStyle + override fun setMapDesignType(value: MapboxDesignType) { + val document = value.getValue() + requestedStyle = document mainCoroutine.launch { - holder.mapView.mapboxMap.loadStyle(value.getValue()) + holder.mapView.mapboxMap.loadStyle(document) { + loadedStyleValue = document + } } } diff --git a/src/main/java/com/mapconductor/mapbox/MapboxStyleMutationTarget.kt b/src/main/java/com/mapconductor/mapbox/MapboxStyleMutationTarget.kt new file mode 100644 index 0000000..43cbe09 --- /dev/null +++ b/src/main/java/com/mapconductor/mapbox/MapboxStyleMutationTarget.kt @@ -0,0 +1,142 @@ +package com.mapconductor.mapbox + +import com.mapbox.bindgen.Expected +import com.mapbox.bindgen.None +import com.mapbox.bindgen.Value +import com.mapbox.maps.LayerPosition +import com.mapbox.maps.Style +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.map.StyleMutationTarget + +/** + * Changes a loaded Mapbox style in place. + * + * The seven calls [StyleMutationTarget] asks for, against Mapbox's style + * API. Everything above them — what a mutation means, which can be skipped, + * keeping one bad property from costing the rest — is + * `applyStyleMutations` in core, shared with every other provider. + * + * ## Why this is shorter than the MapLibre one + * + * Mapbox v11 keeps a generic door open: `setStyleLayerProperty` takes the + * spec's own property name and a `Value`, and `Value.fromJson` builds one + * straight from text. Paint keys, layout keys, `filter`, `minzoom` and + * `maxzoom` all go through that one call, and a whole layer document goes + * through `addStyleLayer`. There is no typed-constructor detour and no + * expression builder, so the compiler's JSON reaches the renderer unread. + * + * MapLibre on Android has no such door, which is why + * `MapLibreStyleMutationTarget` is three times the length for the same seven + * calls. The adapters differ; the decisions above them do not. + */ +@InternalMapConductorApi +internal class MapboxStyleMutationTarget( + private val style: () -> Style?, + /** + * Whether the style on screen is the document these deltas are for. + * + * Not `style() != null`: a vector style hands the map a new document + * and applies the deltas compiled for it straight after, and in between + * the map still has the one being replaced. See + * `MapboxMapViewController.isShowingRequestedStyle`. + */ + private val ready: () -> Boolean, +) : StyleMutationTarget { + override val isReady: Boolean get() = ready() + + override fun hasLayer(layerId: String): Boolean = style()?.styleLayerExists(layerId) == true + + override fun setPaint( + layerId: String, + key: String, + valueJson: String, + ) = setProperty(layerId, key, valueJson) + + // Mapbox does not split paint from layout on this call: both are the + // spec's property name on the layer. + override fun setLayout( + layerId: String, + key: String, + valueJson: String, + ) = setProperty(layerId, key, valueJson) + + override fun setFilter( + layerId: String, + filterJson: String, + ) = setProperty(layerId, "filter", filterJson) + + override fun setZoomRange( + layerId: String, + minZoom: Float, + maxZoom: Float, + ) { + val target = style() ?: return + // Both ends, because the caller always sends both -- a mutation that + // moves one has to restate the other to be undoable. + orThrow("minzoom", target.setStyleLayerProperty(layerId, "minzoom", Value(minZoom.toDouble()))) + orThrow("maxzoom", target.setStyleLayerProperty(layerId, "maxzoom", Value(maxZoom.toDouble()))) + } + + override fun addLayer( + layerId: String, + layerJson: String, + beforeId: String?, + ) { + val target = style() ?: return + // Already there: the style was reloaded and the whole set is going + // back on. + if (target.styleLayerExists(layerId)) return + val layer = valueOf(layerJson) + // `beforeId` is the layer to go *under*, matching the style + // document's own order -- earlier in the list is drawn first. + val position = + beforeId + ?.takeIf { target.styleLayerExists(it) } + ?.let { LayerPosition(null, it, null) } + orThrow("addLayer", target.addStyleLayer(layer, position)) + } + + override fun removeLayer(layerId: String) { + val target = style() ?: return + // Mapbox calls removing an absent layer an error; the contract says + // it is not one. + if (!target.styleLayerExists(layerId)) return + orThrow("removeLayer", target.removeStyleLayer(layerId)) + } + + private fun setProperty( + layerId: String, + key: String, + valueJson: String, + ) { + val target = style() ?: return + orThrow(key, target.setStyleLayerProperty(layerId, key, valueOf(valueJson))) + } + + /** + * A JSON text as the `Value` Mapbox wants. + * + * Literals, expression arrays and whole layer objects all arrive the same + * way, so there is nothing here to decide — unlike MapLibre, which needs + * the value's shape chosen for it. + */ + private fun valueOf(json: String): Value { + val parsed = Value.fromJson(json) + if (parsed.isError) throw IllegalArgumentException("not a style value: ${parsed.error}") + return parsed.value ?: Value.nullValue() + } + + /** + * Mapbox answers with an error rather than throwing, and a refused + * property that went unnoticed would leave the map looking unlike what + * the rules asked for with nothing said. Throwing puts it back on the + * path core already has for this: the mutation comes back unapplied and + * the caller reports it. + */ + private fun orThrow( + what: String, + result: Expected, + ) { + if (result.isError) throw UnsupportedOperationException("$what: ${result.error}") + } +}