From c58080b491e447e012852c3ef06c19256ba1a7cf Mon Sep 17 00:00:00 2001 From: Brandon Roeder Date: Mon, 13 Jul 2026 13:53:52 -0500 Subject: [PATCH] Add feature state functionality to Android (#4219) --- include/mbgl/renderer/renderer.hpp | 4 + .../src/cpp/android_renderer_frontend.cpp | 22 +++ .../src/cpp/android_renderer_frontend.hpp | 11 ++ .../src/cpp/native_map_view.cpp | 61 +++++- .../src/cpp/native_map_view.hpp | 18 ++ .../src/cpp/style/sources/source.cpp | 76 ++++++- .../src/cpp/style/sources/source.hpp | 25 ++- .../org/maplibre/android/maps/NativeMap.java | 16 ++ .../maplibre/android/maps/NativeMapView.java | 52 +++++ .../android/style/expressions/Expression.java | 10 + .../android/style/sources/GeoJsonSource.kt | 62 ++++++ .../android/style/sources/Source.java | 16 ++ .../android/style/sources/VectorSource.kt | 68 +++++++ .../maplibre/android/maps/MapLibreMapTest.kt | 1 + .../activity/SequentialActivityTest.kt | 1 + .../testapp/style/GeoJsonSourceTests.java | 74 +++++++ .../src/main/AndroidManifest.xml | 12 ++ .../activity/feature/FeatureStateActivity.kt | 185 ++++++++++++++++++ .../res/raw/test_feature_state_style.json | 43 ++++ .../src/main/res/values/descriptions.xml | 1 + .../src/main/res/values/titles.xml | 1 + .../android/docs/styling/feature-state.md | 37 ++++ src/mbgl/renderer/renderer.cpp | 8 + 23 files changed, 797 insertions(+), 7 deletions(-) create mode 100644 platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/feature/FeatureStateActivity.kt create mode 100644 platform/android/MapLibreAndroidTestApp/src/main/res/raw/test_feature_state_style.json create mode 100644 platform/android/docs/styling/feature-state.md diff --git a/include/mbgl/renderer/renderer.hpp b/include/mbgl/renderer/renderer.hpp index 4d2e79363..973a857ed 100644 --- a/include/mbgl/renderer/renderer.hpp +++ b/include/mbgl/renderer/renderer.hpp @@ -81,6 +81,10 @@ public: const std::optional& sourceLayerID, const std::string& featureID) const; + FeatureState getFeatureState(const std::string& sourceID, + const std::optional& sourceLayerID, + const std::string& featureID) const; + void removeFeatureState(const std::string& sourceID, const std::optional& sourceLayerID, const std::optional& featureID, diff --git a/platform/android/MapLibreAndroid/src/cpp/android_renderer_frontend.cpp b/platform/android/MapLibreAndroid/src/cpp/android_renderer_frontend.cpp index aa4ce26eb..9f1bea299 100644 --- a/platform/android/MapLibreAndroid/src/cpp/android_renderer_frontend.cpp +++ b/platform/android/MapLibreAndroid/src/cpp/android_renderer_frontend.cpp @@ -170,6 +170,28 @@ std::vector AndroidRendererFrontend::querySourceFeatures(const std::str return mapRenderer.actor().ask(&Renderer::querySourceFeatures, sourceID, options).get(); } +void AndroidRendererFrontend::setFeatureState(const std::string& sourceID, + const std::optional& sourceLayerID, + const std::string& featureID, + const FeatureState& state) const { + mapRenderer.actor().invoke(&Renderer::setFeatureState, sourceID, sourceLayerID, featureID, state); +} + +FeatureState AndroidRendererFrontend::getFeatureState(const std::string& sourceID, + const std::optional& sourceLayerID, + const std::string& featureID) const { + auto getFeatureState = static_cast&, const std::string&) const>(&Renderer::getFeatureState); + return mapRenderer.actor().ask(getFeatureState, sourceID, sourceLayerID, featureID).get(); +} + +void AndroidRendererFrontend::removeFeatureState(const std::string& sourceID, + const std::optional& sourceLayerID, + const std::optional& featureID, + const std::optional& stateKey) const { + mapRenderer.actor().invoke(&Renderer::removeFeatureState, sourceID, sourceLayerID, featureID, stateKey); +} + std::vector AndroidRendererFrontend::queryRenderedFeatures(const ScreenBox& box, const RenderedQueryOptions& options) const { // Select the right overloaded method diff --git a/platform/android/MapLibreAndroid/src/cpp/android_renderer_frontend.hpp b/platform/android/MapLibreAndroid/src/cpp/android_renderer_frontend.hpp index 37e5af7a3..2336cf921 100644 --- a/platform/android/MapLibreAndroid/src/cpp/android_renderer_frontend.hpp +++ b/platform/android/MapLibreAndroid/src/cpp/android_renderer_frontend.hpp @@ -52,6 +52,17 @@ public: std::vector queryRenderedFeatures(const ScreenCoordinate&, const RenderedQueryOptions&) const; std::vector queryRenderedFeatures(const ScreenBox&, const RenderedQueryOptions&) const; std::vector querySourceFeatures(const std::string& sourceID, const SourceQueryOptions&) const; + void setFeatureState(const std::string& sourceID, + const std::optional& sourceLayerID, + const std::string& featureID, + const FeatureState& state) const; + FeatureState getFeatureState(const std::string& sourceID, + const std::optional& sourceLayerID, + const std::string& featureID) const; + void removeFeatureState(const std::string& sourceID, + const std::optional& sourceLayerID, + const std::optional& featureID, + const std::optional& stateKey) const; AnnotationIDs queryPointAnnotations(const ScreenBox& box, const std::chrono::milliseconds& timeout) const; AnnotationIDs queryShapeAnnotations(const ScreenBox& box, const std::chrono::milliseconds& timeout) const; diff --git a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp index 935b170ce..e778f7032 100644 --- a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp +++ b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp @@ -40,6 +40,7 @@ #include "conversion/collection.hpp" #include "style/conversion/filter.hpp" #include "geojson/feature.hpp" +#include "gson/json_object.hpp" #include "rendering_stats.hpp" #include "android_renderer_frontend.hpp" @@ -1015,6 +1016,59 @@ jni::Local>> NativeMapView::queryRender return Feature::convert(env, rendererFrontend->queryRenderedFeatures(box, {layers, toFilter(env, jfilter)})); } +void NativeMapView::setFeatureState(JNIEnv& env, + const jni::String& sourceId, + const jni::String& sourceLayerId, + const jni::String& featureId, + const jni::Object& state) { + if (!sourceId || !featureId || !state) { + return; + } + + rendererFrontend->setFeatureState( + jni::Make(env, sourceId), + sourceLayerId ? std::optional(jni::Make(env, sourceLayerId)) : std::nullopt, + jni::Make(env, featureId), + gson::JsonObject::convert(env, state)); + map->triggerRepaint(); +} + +jni::Local> NativeMapView::getFeatureState(JNIEnv& env, + const jni::String& sourceId, + const jni::String& sourceLayerId, + const jni::String& featureId) { + if (!sourceId || !featureId) { + return jni::Local>(); + } + + const auto state = rendererFrontend->getFeatureState( + jni::Make(env, sourceId), + sourceLayerId ? std::optional(jni::Make(env, sourceLayerId)) : std::nullopt, + jni::Make(env, featureId)); + if (state.empty()) { + return jni::Local>(); + } + + return gson::JsonObject::New(env, state); +} + +void NativeMapView::removeFeatureState(JNIEnv& env, + const jni::String& sourceId, + const jni::String& sourceLayerId, + const jni::String& featureId, + const jni::String& stateKey) { + if (!sourceId) { + return; + } + + rendererFrontend->removeFeatureState( + jni::Make(env, sourceId), + sourceLayerId ? std::optional(jni::Make(env, sourceLayerId)) : std::nullopt, + featureId ? std::optional(jni::Make(env, featureId)) : std::nullopt, + stateKey ? std::optional(jni::Make(env, stateKey)) : std::nullopt); + map->triggerRepaint(); +} + jni::Local> NativeMapView::getLight(JNIEnv& env) { mbgl::style::Light* light = map->getStyle().getLight(); if (light) { @@ -1183,7 +1237,7 @@ jni::Local>> NativeMapView::getSources(JNIEnv& en auto jSources = jni::Array>::New(env, sources.size()); int index = 0; for (auto source : sources) { - jSources.Set(env, index, Source::peerForCoreSource(env, *source, *rendererFrontend)); + jSources.Set(env, index, Source::peerForCoreSource(env, *source, *rendererFrontend, *map)); index++; } @@ -1199,7 +1253,7 @@ jni::Local> NativeMapView::getSource(JNIEnv& env, const jni: } // Create and return the source's native peer - return jni::NewLocal(env, Source::peerForCoreSource(env, *coreSource, *rendererFrontend)); + return jni::NewLocal(env, Source::peerForCoreSource(env, *coreSource, *rendererFrontend, *map)); } void NativeMapView::addSource(JNIEnv& env, const jni::Object& obj, jlong sourcePtr) { @@ -1428,6 +1482,9 @@ void NativeMapView::registerNative(jni::JNIEnv& env) { METHOD(&NativeMapView::queryShapeAnnotations, "nativeQueryShapeAnnotations"), METHOD(&NativeMapView::queryRenderedFeaturesForPoint, "nativeQueryRenderedFeaturesForPoint"), METHOD(&NativeMapView::queryRenderedFeaturesForBox, "nativeQueryRenderedFeaturesForBox"), + METHOD(&NativeMapView::setFeatureState, "nativeSetFeatureState"), + METHOD(&NativeMapView::getFeatureState, "nativeGetFeatureState"), + METHOD(&NativeMapView::removeFeatureState, "nativeRemoveFeatureState"), METHOD(&NativeMapView::getLight, "nativeGetLight"), METHOD(&NativeMapView::getLayers, "nativeGetLayers"), METHOD(&NativeMapView::getLayer, "nativeGetLayer"), diff --git a/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp b/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp index 41a9ba98c..652589304 100644 --- a/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp +++ b/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp @@ -15,6 +15,7 @@ #include "graphics/rectf.hpp" #include "geojson/feature.hpp" #include "geojson/geometry.hpp" +#include "gson/json_object.hpp" #include "geometry/lat_lng.hpp" #include "geometry/projected_meters.hpp" #include "style/layers/layer_manager.hpp" @@ -258,6 +259,23 @@ public: const jni::Array&, const jni::Array>& jfilter); + void setFeatureState(JNIEnv&, + const jni::String& sourceId, + const jni::String& sourceLayerId, + const jni::String& featureId, + const jni::Object& state); + + jni::Local> getFeatureState(JNIEnv&, + const jni::String& sourceId, + const jni::String& sourceLayerId, + const jni::String& featureId); + + void removeFeatureState(JNIEnv&, + const jni::String& sourceId, + const jni::String& sourceLayerId, + const jni::String& featureId, + const jni::String& stateKey); + jni::Local> getLight(JNIEnv&); jni::Local>> getLayers(JNIEnv&); diff --git a/platform/android/MapLibreAndroid/src/cpp/style/sources/source.cpp b/platform/android/MapLibreAndroid/src/cpp/style/sources/source.cpp index ec7026d44..9f02ad79d 100644 --- a/platform/android/MapLibreAndroid/src/cpp/style/sources/source.cpp +++ b/platform/android/MapLibreAndroid/src/cpp/style/sources/source.cpp @@ -52,11 +52,14 @@ static std::unique_ptr createSourcePeer(jni::JNIEnv& env, const jni::Object& Source::peerForCoreSource(jni::JNIEnv& env, mbgl::style::Source& coreSource, - AndroidRendererFrontend& frontend) { + AndroidRendererFrontend& frontend, + mbgl::Map& map) { if (!coreSource.peer.has_value()) { coreSource.peer = createSourcePeer(env, coreSource, &frontend); } - return coreSource.peer.get>()->javaPeer; + auto* peer = coreSource.peer.get>().get(); + peer->bindToMap(frontend, map); + return peer->javaPeer; } const jni::Object& Source::peerForCoreSource(jni::JNIEnv& env, mbgl::style::Source& coreSource) { @@ -172,7 +175,7 @@ void Source::addToMap(JNIEnv& env, const jni::Object& obj, mbgl::Map& ma // Add strong reference to java source javaPeer = jni::NewGlobal(env, obj); - rendererFrontend = &frontend; + bindToMap(frontend, map); } bool Source::removeFromMap(JNIEnv&, const jni::Object&, mbgl::Map& map) { @@ -204,6 +207,67 @@ jni::Local Source::getMinimumTileUpdateInterval(JNIEnv& env) { return jni::Box(env, jni::jlong(source.getMinimumTileUpdateInterval().count() / 1000000)); } +jni::jboolean Source::setFeatureState(JNIEnv& env, + const jni::String& sourceLayerId, + const jni::String& featureId, + const jni::Object& state) { + if (!rendererFrontend || !featureId || !state) { + return jni::jni_false; + } + + rendererFrontend->setFeatureState( + source.getID(), + sourceLayerId ? std::optional(jni::Make(env, sourceLayerId)) : std::nullopt, + jni::Make(env, featureId), + gson::JsonObject::convert(env, state)); + if (map) { + map->triggerRepaint(); + } + return jni::jni_true; +} + +jni::Local> Source::getFeatureState(JNIEnv& env, + const jni::String& sourceLayerId, + const jni::String& featureId) { + if (!rendererFrontend || !featureId) { + return jni::Local>(); + } + + const auto state = rendererFrontend->getFeatureState( + source.getID(), + sourceLayerId ? std::optional(jni::Make(env, sourceLayerId)) : std::nullopt, + jni::Make(env, featureId)); + if (state.empty()) { + return jni::Local>(); + } + + return gson::JsonObject::New(env, state); +} + +jni::jboolean Source::removeFeatureState(JNIEnv& env, + const jni::String& sourceLayerId, + const jni::String& featureId, + const jni::String& stateKey) { + if (!rendererFrontend) { + return jni::jni_false; + } + + rendererFrontend->removeFeatureState( + source.getID(), + sourceLayerId ? std::optional(jni::Make(env, sourceLayerId)) : std::nullopt, + featureId ? std::optional(jni::Make(env, featureId)) : std::nullopt, + stateKey ? std::optional(jni::Make(env, stateKey)) : std::nullopt); + if (map) { + map->triggerRepaint(); + } + return jni::jni_true; +} + +void Source::bindToMap(AndroidRendererFrontend& frontend, mbgl::Map& map) { + rendererFrontend = &frontend; + this->map = ↦ +} + void Source::releaseJavaPeer() { // We can't release the peer if the source was not removed from the map if (!ownedSource) { @@ -220,6 +284,7 @@ void Source::releaseJavaPeer() { javaPeer.reset(); rendererFrontend = nullptr; + map = nullptr; } void Source::registerNative(jni::JNIEnv& env) { @@ -242,7 +307,10 @@ void Source::registerNative(jni::JNIEnv& env) { METHOD(&Source::isVolatile, "nativeIsVolatile"), METHOD(&Source::setVolatile, "nativeSetVolatile"), METHOD(&Source::setMinimumTileUpdateInterval, "nativeSetMinimumTileUpdateInterval"), - METHOD(&Source::getMinimumTileUpdateInterval, "nativeGetMinimumTileUpdateInterval")); + METHOD(&Source::getMinimumTileUpdateInterval, "nativeGetMinimumTileUpdateInterval"), + METHOD(&Source::setFeatureState, "nativeSetFeatureState"), + METHOD(&Source::getFeatureState, "nativeGetFeatureState"), + METHOD(&Source::removeFeatureState, "nativeRemoveFeatureState")); // Register subclasses GeoJSONSource::registerNative(env); diff --git a/platform/android/MapLibreAndroid/src/cpp/style/sources/source.hpp b/platform/android/MapLibreAndroid/src/cpp/style/sources/source.hpp index b7782c348..ff8b627f4 100644 --- a/platform/android/MapLibreAndroid/src/cpp/style/sources/source.hpp +++ b/platform/android/MapLibreAndroid/src/cpp/style/sources/source.hpp @@ -6,6 +6,7 @@ #include "../value.hpp" #include "../../android_renderer_frontend.hpp" +#include "../../gson/json_object.hpp" #include @@ -19,7 +20,10 @@ public: static void registerNative(jni::JNIEnv&); static const jni::Object& peerForCoreSource(jni::JNIEnv&, mbgl::style::Source&); - static const jni::Object& peerForCoreSource(jni::JNIEnv&, mbgl::style::Source&, AndroidRendererFrontend&); + static const jni::Object& peerForCoreSource(jni::JNIEnv&, + mbgl::style::Source&, + AndroidRendererFrontend&, + mbgl::Map&); /* * Called when a Java object is created for a core source that belongs to a map. @@ -62,6 +66,22 @@ public: jni::Local getMinimumTileUpdateInterval(JNIEnv&); + jni::jboolean setFeatureState(JNIEnv&, + const jni::String& sourceLayerId, + const jni::String& featureId, + const jni::Object& state); + + jni::Local> getFeatureState(JNIEnv&, + const jni::String& sourceLayerId, + const jni::String& featureId); + + jni::jboolean removeFeatureState(JNIEnv&, + const jni::String& sourceLayerId, + const jni::String& featureId, + const jni::String& stateKey); + + void bindToMap(AndroidRendererFrontend&, mbgl::Map&); + protected: // Set on newly created sources until added to the map. std::unique_ptr ownedSource; @@ -74,6 +94,9 @@ protected: // RendererFrontend pointer is valid only when added to the map. AndroidRendererFrontend* rendererFrontend{nullptr}; + + // Map pointer is valid only when added to the map. + mbgl::Map* map{nullptr}; }; } // namespace android diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java index 044556460..24ae9491e 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java @@ -8,6 +8,7 @@ import androidx.annotation.IntRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.google.gson.JsonObject; import org.maplibre.geojson.Feature; import org.maplibre.geojson.Geometry; import org.maplibre.android.annotations.Marker; @@ -190,6 +191,21 @@ interface NativeMap { @Nullable String[] layerIds, @Nullable Expression filter); + void setFeatureState(@NonNull String sourceId, + @Nullable String sourceLayerId, + @NonNull String featureId, + @NonNull JsonObject state); + + @Nullable + JsonObject getFeatureState(@NonNull String sourceId, + @Nullable String sourceLayerId, + @NonNull String featureId); + + void removeFeatureState(@NonNull String sourceId, + @Nullable String sourceLayerId, + @Nullable String featureId, + @Nullable String stateKey); + // // Projection API // diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java index 572564713..735f475be 100755 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java @@ -13,6 +13,7 @@ import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.google.gson.JsonObject; import org.maplibre.geojson.Feature; import org.maplibre.geojson.Geometry; import org.maplibre.android.LibraryLoader; @@ -1123,6 +1124,39 @@ final class NativeMapView implements NativeMap { return features != null ? Arrays.asList(features) : new ArrayList(); } + @Override + public void setFeatureState(@NonNull String sourceId, + @Nullable String sourceLayerId, + @NonNull String featureId, + @NonNull JsonObject state) { + if (checkState("setFeatureState")) { + return; + } + nativeSetFeatureState(sourceId, sourceLayerId, featureId, state); + } + + @Override + @Nullable + public JsonObject getFeatureState(@NonNull String sourceId, + @Nullable String sourceLayerId, + @NonNull String featureId) { + if (checkState("getFeatureState")) { + return null; + } + return nativeGetFeatureState(sourceId, sourceLayerId, featureId); + } + + @Override + public void removeFeatureState(@NonNull String sourceId, + @Nullable String sourceLayerId, + @Nullable String featureId, + @Nullable String stateKey) { + if (checkState("removeFeatureState")) { + return; + } + nativeRemoveFeatureState(sourceId, sourceLayerId, featureId, stateKey); + } + @Override public void setApiBaseUrl(String baseUrl) { if (checkState("setApiBaseUrl")) { @@ -1711,6 +1745,24 @@ final class NativeMapView implements NativeMap { String[] layerIds, Object[] filter); + @Keep + private native void nativeSetFeatureState(String sourceId, + String sourceLayerId, + String featureId, + JsonObject state); + + @Nullable + @Keep + private native JsonObject nativeGetFeatureState(String sourceId, + String sourceLayerId, + String featureId); + + @Keep + private native void nativeRemoveFeatureState(String sourceId, + String sourceLayerId, + String featureId, + String stateKey); + @NonNull @Keep private native Light nativeGetLight(); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/expressions/Expression.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/expressions/Expression.java index 51c8d2110..3f1a64618 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/expressions/Expression.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/expressions/Expression.java @@ -1439,6 +1439,16 @@ public class Expression { return new Expression("id"); } + /** + * Gets a value from feature state. + * + * @param property the state property to read + * @return the expression + */ + public static Expression featureState(@NonNull String property) { + return new Expression("feature-state", literal(property)); + } + /** * Gets the value of a cluster property accumulated so far. Can only be used in the clusterProperties * option of a clustered GeoJSON source. diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/GeoJsonSource.kt b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/GeoJsonSource.kt index 9a1dc5fa6..0fb2657d2 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/GeoJsonSource.kt +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/GeoJsonSource.kt @@ -2,6 +2,7 @@ package org.maplibre.android.style.sources import androidx.annotation.Keep import androidx.annotation.UiThread +import com.google.gson.JsonObject import org.maplibre.geojson.Feature import org.maplibre.geojson.FeatureCollection import org.maplibre.geojson.Geometry @@ -404,6 +405,67 @@ class GeoJsonSource : Source { return nativeGetUrl() } + /** + * Sets the state of a feature in this source. + * + * Feature state can be read in style expressions through `["feature-state", key]`. + * The target feature must already have an id in the underlying source data. + * + * @param featureId the id of the feature whose state to set + * @param state a JSON object with the state key-value pairs to merge + * @return true if the source is attached to a map and the update was dispatched + */ + fun setFeatureState(featureId: String, state: JsonObject): Boolean { + checkThread() + return nativeSetFeatureState(null, featureId, state) + } + + /** + * Gets the current state of a feature in this source. + * + * The target feature must already have an id in the underlying source data. + * + * @param featureId the id of the feature whose state to get + * @return the feature state, or null + */ + fun getFeatureState(featureId: String): JsonObject? { + checkThread() + return nativeGetFeatureState(null, featureId) + } + + /** + * Removes state from a feature in this source, or from all features when [featureId] is null. + * + * @param featureId the id of the feature, or null to target all features + * @param stateKey the state key to remove, or null to remove all keys + * @return true if the source is attached to a map and the update was dispatched + */ + fun removeFeatureState(featureId: String?, stateKey: String?): Boolean { + checkThread() + return nativeRemoveFeatureState(null, featureId, stateKey) + } + + /** + * Removes all state from a single feature in this source. + * + * @param featureId the id of the feature + * @return true if the source is attached to a map and the update was dispatched + */ + fun removeFeatureState(featureId: String): Boolean { + checkThread() + return nativeRemoveFeatureState(null, featureId, null) + } + + /** + * Removes all feature state entries from this source. + * + * @return true if the source is attached to a map and the update was dispatched + */ + fun resetFeatureStates(): Boolean { + checkThread() + return nativeRemoveFeatureState(null, null, null) + } + /** * Queries the source for features. * diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/Source.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/Source.java index 04e857f21..559c9540a 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/Source.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/Source.java @@ -4,6 +4,7 @@ import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.google.gson.JsonObject; import org.maplibre.android.LibraryLoader; import org.maplibre.android.utils.ThreadUtils; @@ -198,6 +199,21 @@ public abstract class Source { @Keep protected native Long nativeGetMinimumTileUpdateInterval(); + @Keep + protected native boolean nativeSetFeatureState(@Nullable String sourceLayerId, + @NonNull String featureId, + @NonNull JsonObject state); + + @Nullable + @Keep + protected native JsonObject nativeGetFeatureState(@Nullable String sourceLayerId, + @NonNull String featureId); + + @Keep + protected native boolean nativeRemoveFeatureState(@Nullable String sourceLayerId, + @Nullable String featureId, + @Nullable String stateKey); + public void setDetached() { detached = true; } diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/VectorSource.kt b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/VectorSource.kt index 4293d4c84..162ea5582 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/VectorSource.kt +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/style/sources/VectorSource.kt @@ -4,6 +4,7 @@ import android.net.Uri import androidx.annotation.Keep import androidx.annotation.Size import androidx.annotation.UiThread +import com.google.gson.JsonObject import org.maplibre.geojson.Feature import org.maplibre.android.style.expressions.Expression import java.net.URL @@ -102,6 +103,73 @@ class VectorSource : Source { initialize(id, tileSet.toValueObject()) } + /** + * Sets the state of a feature in this source. + * + * Feature state can be read in style expressions through `["feature-state", key]`. + * The target feature must already have an id in the underlying source data. + * + * @param sourceLayerId the source layer id + * @param featureId the id of the feature whose state to set + * @param state a JSON object with the state key-value pairs to merge + * @return true if the source is attached to a map and the update was dispatched + */ + fun setFeatureState(sourceLayerId: String, featureId: String, state: JsonObject): Boolean { + checkThread() + return nativeSetFeatureState(sourceLayerId, featureId, state) + } + + /** + * Gets the current state of a feature in this source. + * + * The target feature must already have an id in the underlying source data. + * + * @param sourceLayerId the source layer id + * @param featureId the id of the feature whose state to get + * @return the feature state, or null + */ + fun getFeatureState(sourceLayerId: String, featureId: String): JsonObject? { + checkThread() + return nativeGetFeatureState(sourceLayerId, featureId) + } + + /** + * Removes state from a feature in this source, or from all features in a source layer + * when [featureId] is null. + * + * @param sourceLayerId the source layer id + * @param featureId the id of the feature, or null to target all features + * @param stateKey the state key to remove, or null to remove all keys + * @return true if the source is attached to a map and the update was dispatched + */ + fun removeFeatureState(sourceLayerId: String, featureId: String?, stateKey: String?): Boolean { + checkThread() + return nativeRemoveFeatureState(sourceLayerId, featureId, stateKey) + } + + /** + * Removes all state from a single feature in this source. + * + * @param sourceLayerId the source layer id + * @param featureId the id of the feature + * @return true if the source is attached to a map and the update was dispatched + */ + fun removeFeatureState(sourceLayerId: String, featureId: String): Boolean { + checkThread() + return nativeRemoveFeatureState(sourceLayerId, featureId, null) + } + + /** + * Removes all feature state entries from the given source layer. + * + * @param sourceLayerId the source layer id + * @return true if the source is attached to a map and the update was dispatched + */ + fun resetFeatureStates(sourceLayerId: String): Boolean { + checkThread() + return nativeRemoveFeatureState(sourceLayerId, null, null) + } + /** * Queries the source for features. * diff --git a/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/MapLibreMapTest.kt b/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/MapLibreMapTest.kt index 8985465a2..dd722a600 100644 --- a/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/MapLibreMapTest.kt +++ b/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/MapLibreMapTest.kt @@ -261,4 +261,5 @@ class MapLibreMapTest : BaseTest() { verify { developerAnimationListener.onDeveloperAnimationStarted() } verify { nativeMapView.setZoom(2.0, target, 0) } } + } diff --git a/platform/android/MapLibreAndroidTestApp/src/androidTest/java/org/maplibre/android/testapp/activity/SequentialActivityTest.kt b/platform/android/MapLibreAndroidTestApp/src/androidTest/java/org/maplibre/android/testapp/activity/SequentialActivityTest.kt index 7822a3d5b..b0bef694d 100644 --- a/platform/android/MapLibreAndroidTestApp/src/androidTest/java/org/maplibre/android/testapp/activity/SequentialActivityTest.kt +++ b/platform/android/MapLibreAndroidTestApp/src/androidTest/java/org/maplibre/android/testapp/activity/SequentialActivityTest.kt @@ -37,6 +37,7 @@ class SequentialActivityTest(private val activity: Class) { org.maplibre.android.testapp.activity.camera.CameraAnimatorActivity::class.java, org.maplibre.android.testapp.activity.customlayer.CustomLayerActivity::class.java, org.maplibre.android.testapp.activity.events.ObserverActivity::class.java, + org.maplibre.android.testapp.activity.feature.FeatureStateActivity::class.java, org.maplibre.android.testapp.activity.feature.QuerySourceFeaturesActivity::class.java, org.maplibre.android.testapp.activity.fragment.MapFragmentActivity::class.java, org.maplibre.android.testapp.activity.imagegenerator.SnapshotActivity::class.java, diff --git a/platform/android/MapLibreAndroidTestApp/src/androidTest/java/org/maplibre/android/testapp/style/GeoJsonSourceTests.java b/platform/android/MapLibreAndroidTestApp/src/androidTest/java/org/maplibre/android/testapp/style/GeoJsonSourceTests.java index f5e5d247f..1047e00fb 100644 --- a/platform/android/MapLibreAndroidTestApp/src/androidTest/java/org/maplibre/android/testapp/style/GeoJsonSourceTests.java +++ b/platform/android/MapLibreAndroidTestApp/src/androidTest/java/org/maplibre/android/testapp/style/GeoJsonSourceTests.java @@ -1,5 +1,7 @@ package org.maplibre.android.testapp.style; +import com.google.gson.JsonObject; + import org.junit.Ignore; import org.maplibre.android.style.sources.CannotAddSourceException; import org.maplibre.android.style.sources.GeoJsonOptions; @@ -7,6 +9,7 @@ import org.maplibre.geojson.Feature; import org.maplibre.geojson.FeatureCollection; import org.maplibre.geojson.Point; import org.maplibre.android.geometry.LatLng; +import org.maplibre.android.maps.Style; import org.maplibre.android.style.layers.CircleLayer; import org.maplibre.android.style.layers.Layer; import org.maplibre.android.style.sources.GeoJsonSource; @@ -112,6 +115,77 @@ public class GeoJsonSourceTests extends EspressoTest { }); } + @Test + public void testFeatureStateUpdatesStyleOwnedSourceImmediately() { + validateTestSetup(); + MapLibreMapAction.invoke(maplibreMap, (uiController, maplibreMap) -> { + boolean[] styleLoaded = {false}; + String styleJson = ResourceUtils.readRawResource(rule.getActivity(), R.raw.test_feature_state_style); + maplibreMap.setStyle(new Style.Builder().fromJson(styleJson), style -> styleLoaded[0] = true); + + while (!styleLoaded[0]) { + uiController.loopMainThreadForAtLeast(100); + } + + TestingAsyncUtils.INSTANCE.waitForLayer(uiController, mapView); + + GeoJsonSource source = maplibreMap.getStyle().getSourceAs("style-source"); + assertEquals(0, queryFeatureCount(maplibreMap)); + + JsonObject state = new JsonObject(); + state.addProperty("selected", true); + source.setFeatureState("feature-1", state); + + // The feature-state update is applied on a subsequent render pass, so + // poll until the filter re-evaluates rather than relying on a single + // didBecomeIdle callback (which can be missed if the repaint completes + // before the listener is attached). + assertEquals(1, pollFeatureCount(uiController, maplibreMap, 1)); + }); + } + + @Test + public void testFeatureStateKeyRemovalAcrossSource() { + validateTestSetup(); + MapLibreMapAction.invoke(maplibreMap, (uiController, maplibreMap) -> { + boolean[] styleLoaded = {false}; + String styleJson = ResourceUtils.readRawResource(rule.getActivity(), R.raw.test_feature_state_style); + maplibreMap.setStyle(new Style.Builder().fromJson(styleJson), style -> styleLoaded[0] = true); + + while (!styleLoaded[0]) { + uiController.loopMainThreadForAtLeast(100); + } + + TestingAsyncUtils.INSTANCE.waitForLayer(uiController, mapView); + + GeoJsonSource source = maplibreMap.getStyle().getSourceAs("style-source"); + JsonObject state = new JsonObject(); + state.addProperty("selected", true); + source.setFeatureState("feature-1", state); + assertEquals(1, pollFeatureCount(uiController, maplibreMap, 1)); + + source.removeFeatureState(null, "selected"); + assertEquals(0, pollFeatureCount(uiController, maplibreMap, 0)); + }); + } + + private int queryFeatureCount(org.maplibre.android.maps.MapLibreMap maplibreMap) { + return maplibreMap.queryRenderedFeatures( + maplibreMap.getProjection().toScreenLocation(new LatLng(0.0, 0.0)), "selected-layer").size(); + } + + private int pollFeatureCount(androidx.test.espresso.UiController uiController, + org.maplibre.android.maps.MapLibreMap maplibreMap, + int expected) { + long deadline = System.nanoTime() / 1_000_000L + 5_000L; + int count = queryFeatureCount(maplibreMap); + while (count != expected && System.nanoTime() / 1_000_000L < deadline) { + uiController.loopMainThreadForAtLeast(100); + count = queryFeatureCount(maplibreMap); + } + return count; + } + @Test public void testClearCollectionDuringConversion() { // https://github.com/mapbox/mapbox-gl-native/issues/14565 diff --git a/platform/android/MapLibreAndroidTestApp/src/main/AndroidManifest.xml b/platform/android/MapLibreAndroidTestApp/src/main/AndroidManifest.xml index 4c2485a78..e12347751 100644 --- a/platform/android/MapLibreAndroidTestApp/src/main/AndroidManifest.xml +++ b/platform/android/MapLibreAndroidTestApp/src/main/AndroidManifest.xml @@ -831,6 +831,18 @@ android:name="android.support.PARENT_ACTIVITY" android:value=".activity.FeatureOverviewActivity" /> + + + + + handleMapClick(point) + } + + // # --8<-- [start:onCreate] + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_query_features_point) + + mapView = findViewById(R.id.mapView) as MapView + mapView.onCreate(savedInstanceState) + mapView.getMapAsync { map -> + maplibreMap = map + maplibreMap.moveCamera( + CameraUpdateFactory.newLatLngZoom(LatLng(42.619626, -103.523181), 3.0) + ) + maplibreMap.setStyle(TestStyles.getPredefinedStyleWithFallback("Streets")) { style -> + addStatesLayer(style) + maplibreMap.addOnMapClickListener(mapClickListener) + } + } + } + // # --8<-- [end:onCreate] + + // # --8<-- [start:addStatesLayer] + private fun addStatesLayer(style: Style) { + val source = GeoJsonSource(STATES_SOURCE_ID, URI(STATES_URL)) + style.addSource(source) + statesSource = source + + val selectedFill = Color.parseColor("#d94d41") + val defaultFill = Color.parseColor("#2f7de1") + val selectedBorder = Color.parseColor("#8f241f") + val defaultBorder = Color.parseColor("#2f7de1") + + style.addLayer( + FillLayer(FILL_LAYER_ID, STATES_SOURCE_ID).withProperties( + fillColor( + switchCase( + toBool(featureState("selected")), + color(selectedFill), + color(defaultFill) + ) + ), + fillOpacity( + switchCase( + toBool(featureState("selected")), + literal(0.7f), + literal(0.5f) + ) + ) + ) + ) + + style.addLayer( + LineLayer(LINE_LAYER_ID, STATES_SOURCE_ID).withProperties( + lineColor( + switchCase( + toBool(featureState("selected")), + color(selectedBorder), + color(defaultBorder) + ) + ), + lineWidth( + switchCase( + toBool(featureState("selected")), + literal(2.0f), + literal(1.0f) + ) + ) + ) + ) + } + // # --8<-- [end:addStatesLayer] + + // # --8<-- [start:handleMapClick] + private fun handleMapClick(point: LatLng): Boolean { + val screenPoint: PointF = maplibreMap.projection.toScreenLocation(point) + val features = maplibreMap.queryRenderedFeatures(screenPoint, FILL_LAYER_ID) + val feature = features.firstOrNull() ?: return false + val featureId = feature.id() ?: return false + + val currentState = statesSource.getFeatureState(featureId) + val isSelected = currentState + ?.get("selected") + ?.takeUnless { it.isJsonNull } + ?.asBoolean + ?: false + + val nextState = JsonObject().apply { addProperty("selected", !isSelected) } + statesSource.setFeatureState(featureId, nextState) + + val stateName = feature.getStringProperty("STATE_NAME") ?: "Unknown" + Toast.makeText( + this, + "$stateName ${if (!isSelected) "selected" else "deselected"}", + Toast.LENGTH_SHORT + ).show() + return true + } + // # --8<-- [end:handleMapClick] + + override fun onStart() { + super.onStart() + mapView.onStart() + } + + override fun onResume() { + super.onResume() + mapView.onResume() + } + + override fun onPause() { + super.onPause() + mapView.onPause() + } + + override fun onStop() { + super.onStop() + mapView.onStop() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + mapView.onSaveInstanceState(outState) + } + + override fun onDestroy() { + super.onDestroy() + if (this::maplibreMap.isInitialized) { + maplibreMap.removeOnMapClickListener(mapClickListener) + } + mapView.onDestroy() + } + + override fun onLowMemory() { + super.onLowMemory() + mapView.onLowMemory() + } +} diff --git a/platform/android/MapLibreAndroidTestApp/src/main/res/raw/test_feature_state_style.json b/platform/android/MapLibreAndroidTestApp/src/main/res/raw/test_feature_state_style.json new file mode 100644 index 000000000..49fe52c3d --- /dev/null +++ b/platform/android/MapLibreAndroidTestApp/src/main/res/raw/test_feature_state_style.json @@ -0,0 +1,43 @@ +{ + "version": 8, + "center": [0, 0], + "zoom": 3, + "sources": { + "style-source": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "feature-1", + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [0, 0] + } + } + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "#ffffff" + } + }, + { + "id": "selected-layer", + "type": "circle", + "source": "style-source", + "filter": ["==", ["feature-state", "selected"], true], + "paint": { + "circle-radius": 12, + "circle-color": "#ff0000" + } + } + ] +} diff --git a/platform/android/MapLibreAndroidTestApp/src/main/res/values/descriptions.xml b/platform/android/MapLibreAndroidTestApp/src/main/res/values/descriptions.xml index fc601258e..53faf92ba 100644 --- a/platform/android/MapLibreAndroidTestApp/src/main/res/values/descriptions.xml +++ b/platform/android/MapLibreAndroidTestApp/src/main/res/values/descriptions.xml @@ -50,6 +50,7 @@ Count all rendered symbols in box Highlight buildings in box Query source for features + Toggle feature state and style features interactively Shows a simple map Logs map change events to Logcat Changes visibility of map and view parent diff --git a/platform/android/MapLibreAndroidTestApp/src/main/res/values/titles.xml b/platform/android/MapLibreAndroidTestApp/src/main/res/values/titles.xml index f035033b4..c8c5a6e8c 100644 --- a/platform/android/MapLibreAndroidTestApp/src/main/res/values/titles.xml +++ b/platform/android/MapLibreAndroidTestApp/src/main/res/values/titles.xml @@ -49,6 +49,7 @@ Count symbols in box Highlight features in box Query source features + Feature state Symbols Add Custom Sprite Simple Map diff --git a/platform/android/docs/styling/feature-state.md b/platform/android/docs/styling/feature-state.md new file mode 100644 index 000000000..fa63df723 --- /dev/null +++ b/platform/android/docs/styling/feature-state.md @@ -0,0 +1,37 @@ +# Feature State + +{{ activity_source_note("FeatureStateActivity.kt") }} + +This example shows how to use [`feature-state`](https://maplibre.org/maplibre-style-spec/expressions/#feature-state) for interactive styling on Android. + +Feature state lets you attach runtime JSON values to individual features, then read those values back inside style expressions. This is useful for interaction-driven UI such as selection, highlighting, or temporary analysis state. + +## Load US states from a remote GeoJSON source + +The example loads the same US states GeoJSON used by the GLFW and iOS demos: + +```kotlin +--8<-- "MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/feature/FeatureStateActivity.kt:addStatesLayer" +``` + +## Style layers with feature-state expressions + +The fill color/opacity and border color/width respond to a `selected` boolean stored in feature state: + +- **Selected** — red fill, higher opacity, thicker red border +- **Default** — blue fill, semi-transparent, thin blue border + +## Toggle selection on tap + +Tapping a state toggles its `selected` feature-state key and shows a toast: + +```kotlin +--8<-- "MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/feature/FeatureStateActivity.kt:handleMapClick" +``` + +## Notes + +- Use `GeoJsonSource.setFeatureState()` or `VectorSource.setFeatureState()` to merge new keys into a feature's state. +- Use `getFeatureState()` on the source to inspect the current state for one feature. +- Use `removeFeatureState()` or `resetFeatureStates()` on the source to clear state. +- For `VectorSource`, all feature-state methods require a `sourceLayerId` parameter. diff --git a/src/mbgl/renderer/renderer.cpp b/src/mbgl/renderer/renderer.cpp index c321d9770..a3bae6a5a 100644 --- a/src/mbgl/renderer/renderer.cpp +++ b/src/mbgl/renderer/renderer.cpp @@ -124,6 +124,14 @@ void Renderer::getFeatureState(FeatureState& state, impl->orchestrator.getFeatureState(state, sourceID, sourceLayerID, featureID); } +FeatureState Renderer::getFeatureState(const std::string& sourceID, + const std::optional& sourceLayerID, + const std::string& featureID) const { + FeatureState state; + getFeatureState(state, sourceID, sourceLayerID, featureID); + return state; +} + void Renderer::removeFeatureState(const std::string& sourceID, const std::optional& sourceLayerID, const std::optional& featureID,