Skip to content

Filter Map Features by Properties

This tutorial shows how to dynamically filter what's visible on your MapMetrics Android map based on data properties — useful for category filtering, search, and interactive data exploration.

Prerequisites

Filter by Category

Show/hide features based on a category property:

kotlin
import android.graphics.Color
import android.os.Bundle
import android.widget.ToggleButton
import androidx.appcompat.app.AppCompatActivity
import com.google.gson.JsonObject
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.expressions.Expression
import org.maplibre.android.style.expressions.Expression.*
import org.maplibre.android.style.layers.CircleLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.Point

class FilterActivity : AppCompatActivity() {

    private lateinit var mapView: MapView
    private lateinit var map: MapMetricsMap

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_filter)

        mapView = findViewById(R.id.mapView)
        mapView.onCreate(savedInstanceState)
        mapView.getMapAsync { mapMetricsMap ->
            map = mapMetricsMap

            map.setStyle(
                Style.Builder().fromUri(
                    "https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
                )
            ) { style ->
                addData(style)
                setupFilters(style)
            }
        }
    }

    private fun addData(style: Style) {
        // Sample data with categories
        val features = listOf(
            createFeature(2.3522, 48.8566, "restaurant", "Le Petit Bistro"),
            createFeature(2.3400, 48.8600, "restaurant", "Café de Flore"),
            createFeature(2.3376, 48.8606, "museum", "Louvre Museum"),
            createFeature(2.3266, 48.8600, "museum", "Musée d'Orsay"),
            createFeature(2.3464, 48.8462, "park", "Luxembourg Gardens"),
            createFeature(2.3131, 48.8601, "park", "Tuileries Garden"),
            createFeature(2.3350, 48.8550, "hotel", "Hôtel Lutetia"),
            createFeature(2.3280, 48.8680, "hotel", "Le Meurice"),
        )

        style.addSource(
            GeoJsonSource("places", FeatureCollection.fromFeatures(features))
        )

        // Color circles by category
        style.addLayer(
            CircleLayer("places-layer", "places")
                .withProperties(
                    circleRadius(8f),
                    circleColor(
                        match(
                            get("category"),
                            color(Color.GRAY),  // default
                            stop("restaurant", color(Color.parseColor("#FF6B35"))),
                            stop("museum", color(Color.parseColor("#4285F4"))),
                            stop("park", color(Color.parseColor("#34A853"))),
                            stop("hotel", color(Color.parseColor("#9C27B0")))
                        )
                    ),
                    circleStrokeColor(Color.WHITE),
                    circleStrokeWidth(2f)
                )
        )

        map.cameraPosition = CameraPosition.Builder()
            .target(LatLng(48.857, 2.335))
            .zoom(13.0)
            .build()
    }

    private fun createFeature(lng: Double, lat: Double, category: String, name: String): Feature {
        val props = JsonObject().apply {
            addProperty("category", category)
            addProperty("name", name)
        }
        return Feature.fromGeometry(Point.fromLngLat(lng, lat), props)
    }

    private fun setupFilters(style: Style) {
        val layer = style.getLayer("places-layer") as? CircleLayer

        val categories = mapOf(
            R.id.btnRestaurants to "restaurant",
            R.id.btnMuseums to "museum",
            R.id.btnParks to "park",
            R.id.btnHotels to "hotel",
        )

        val activeCategories = mutableSetOf("restaurant", "museum", "park", "hotel")

        for ((btnId, category) in categories) {
            findViewById<ToggleButton>(btnId).apply {
                isChecked = true
                setOnCheckedChangeListener { _, checked ->
                    if (checked) activeCategories.add(category)
                    else activeCategories.remove(category)
                    applyFilter(layer, activeCategories)
                }
            }
        }
    }

    private fun applyFilter(layer: CircleLayer?, categories: Set<String>) {
        if (categories.isEmpty()) {
            // Hide all
            layer?.setFilter(literal(false))
        } else if (categories.size == 4) {
            // Show all — clear filter
            layer?.setFilter(literal(true))
        } else {
            // Show only selected categories
            val conditions = categories.map { cat ->
                eq(get("category"), literal(cat))
            }
            layer?.setFilter(any(*conditions.toTypedArray()))
        }
    }

    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 onDestroy() { super.onDestroy(); mapView.onDestroy() }
    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        mapView.onSaveInstanceState(outState)
    }
}

Filter by Numeric Range

Show features within a value range:

kotlin
// Show only features with "rating" >= 4.0
layer?.setFilter(
    gte(get("rating"), literal(4.0))
)

// Show features with "price" between 10 and 50
layer?.setFilter(
    all(
        gte(get("price"), literal(10)),
        lte(get("price"), literal(50))
    )
)

Filter features whose name contains a search query:

kotlin
import android.text.Editable
import android.text.TextWatcher
import android.widget.EditText

private fun setupSearch(style: Style) {
    val layer = style.getLayer("places-layer") as? CircleLayer

    findViewById<EditText>(R.id.searchInput).addTextChangedListener(
        object : TextWatcher {
            override fun afterTextChanged(s: Editable?) {
                val query = s.toString().lowercase()
                if (query.isEmpty()) {
                    layer?.setFilter(literal(true))
                } else {
                    // Note: Expression-based text search is limited.
                    // For full text search, filter the GeoJSON source instead.
                    filterSourceByName(style, query)
                }
            }
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
        }
    )
}

private fun filterSourceByName(style: Style, query: String) {
    val allFeatures = /* keep a reference to all features */  listOf<Feature>()

    val filtered = allFeatures.filter { feature ->
        val name = feature.getStringProperty("name") ?: ""
        name.lowercase().contains(query)
    }

    val source = style.getSource("places") as? GeoJsonSource
    source?.setGeoJson(FeatureCollection.fromFeatures(filtered))
}

Common Filter Expressions

ExpressionDescriptionExample
eq(a, b)Equalseq(get("type"), literal("park"))
neq(a, b)Not equalsneq(get("status"), literal("closed"))
gt(a, b)Greater thangt(get("rating"), literal(3))
gte(a, b)Greater or equalgte(get("price"), literal(10))
lt(a, b)Less thanlt(get("age"), literal(5))
lte(a, b)Less or equallte(get("distance"), literal(100))
has("key")Property existshas("phone")
all(...)AND — all must matchall(gt(...), lt(...))
any(...)OR — at least one must matchany(eq(...), eq(...))
not(expr)Negatenot(has("archived"))

Next Steps


Tip: For better search performance with large datasets, filter at the source level (source.setGeoJson()) instead of the layer level (layer.setFilter()). Source-level filtering prevents features from being rendered at all, while layer filters still process them on the GPU.