Add feature state functionality to Android (#4219)

This commit is contained in:
Brandon Roeder
2026-07-13 13:53:52 -05:00
committed by GitHub
parent 764accc8f5
commit c58080b491
23 changed files with 797 additions and 7 deletions

View File

@@ -37,6 +37,7 @@ class SequentialActivityTest(private val activity: Class<out Activity>) {
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,

View File

@@ -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

View File

@@ -831,6 +831,18 @@
android:name="android.support.PARENT_ACTIVITY"
android:value=".activity.FeatureOverviewActivity" />
</activity>
<activity
android:name=".activity.feature.FeatureStateActivity"
android:description="@string/description_feature_state"
android:exported="true"
android:label="@string/activity_feature_state">
<meta-data
android:name="@string/category"
android:value="@string/category_features" />
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".activity.FeatureOverviewActivity" />
</activity>
<activity
android:name=".activity.style.SymbolGeneratorActivity"
android:description="@string/description_symbol_generator"

View File

@@ -0,0 +1,185 @@
package org.maplibre.android.testapp.activity.feature
import android.graphics.Color
import android.graphics.PointF
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.google.gson.JsonObject
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.Style
import org.maplibre.android.style.expressions.Expression.*
import org.maplibre.android.style.layers.FillLayer
import org.maplibre.android.style.layers.LineLayer
import org.maplibre.android.style.layers.PropertyFactory.fillColor
import org.maplibre.android.style.layers.PropertyFactory.fillOpacity
import org.maplibre.android.style.layers.PropertyFactory.lineColor
import org.maplibre.android.style.layers.PropertyFactory.lineWidth
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.android.testapp.R
import org.maplibre.android.testapp.styles.TestStyles
import java.net.URI
/**
* Test activity showcasing interactive styling with feature state.
*
* Loads US states from a remote GeoJSON source and uses feature-state
* expressions to highlight tapped states.
*/
class FeatureStateActivity : AppCompatActivity() {
companion object {
private const val STATES_SOURCE_ID = "states"
private const val FILL_LAYER_ID = "state-fills"
private const val LINE_LAYER_ID = "state-borders"
private const val STATES_URL =
"https://maplibre.org/maplibre-gl-js/docs/assets/us_states.geojson"
}
private lateinit var mapView: MapView
private lateinit var maplibreMap: MapLibreMap
private lateinit var statesSource: GeoJsonSource
private val mapClickListener = MapLibreMap.OnMapClickListener { point ->
handleMapClick(point)
}
// # --8<-- [start:onCreate]
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_query_features_point)
mapView = findViewById<View>(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()
}
}

View File

@@ -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"
}
}
]
}

View File

@@ -50,6 +50,7 @@
<string name="description_query_rendered_features_box_symbol_count">Count all rendered symbols in box</string>
<string name="description_query_rendered_features_box_highlight">Highlight buildings in box</string>
<string name="description_query_source_features">Query source for features</string>
<string name="description_feature_state">Toggle feature state and style features interactively</string>
<string name="description_simple_map">Shows a simple map</string>
<string name="description_map_change">Logs map change events to Logcat</string>
<string name="description_visibility_map">Changes visibility of map and view parent</string>

View File

@@ -49,6 +49,7 @@
<string name="activity_query_rendered_features_box_symbol_count">Count symbols in box</string>
<string name="activity_query_rendered_features_box_highlight">Highlight features in box</string>
<string name="activity_query_source_features">Query source features</string>
<string name="activity_feature_state">Feature state</string>
<string name="activity_symbol_layer">Symbols</string>
<string name="activity_add_sprite">Add Custom Sprite</string>
<string name="activity_simple_map">Simple Map</string>