Add feature state functionality to Android (#4219)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user