Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

Filter Features Within a Layer in Flutter

This tutorial shows how to filter features within a single GeoJSON layer using expressions — showing or hiding features based on their properties without removing and re-adding the source data itself.

Prerequisites

Before you begin, ensure you have:

Filter by Property Value

Show only features matching a specific property value using a layer filter expression. StyleLayer (and every layer type built on it, like CircleStyleLayer and SymbolStyleLayer) takes a filter in its constructor, but the SDK has no method to change a filter on a layer that's already added — so applying a new filter means calling removeLayer and addLayer again with the same id and the new filter:

dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class FilterWithinLayerScreen extends StatefulWidget {
  @override
  _FilterWithinLayerScreenState createState() =>
      _FilterWithinLayerScreenState();
}

class _FilterWithinLayerScreenState extends State<FilterWithinLayerScreen> {
  MapController? mapController;
  StyleController? styleController;
  String selectedType = 'all';

  final List<String> filterOptions = ['all', 'capital', 'city', 'town'];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Filter Within Layer')),
      body: Column(
        children: [
          // Filter chips
          Container(
            padding: EdgeInsets.all(12),
            child: Wrap(
              spacing: 8,
              children: filterOptions.map((option) {
                return ChoiceChip(
                  label: Text(option == 'all'
                      ? 'Show All'
                      : option[0].toUpperCase() + option.substring(1)),
                  selected: selectedType == option,
                  onSelected: (selected) {
                    if (selected) {
                      setState(() => selectedType = option);
                      _applyFilter();
                    }
                  },
                );
              }).toList(),
            ),
          ),
          Expanded(
            child: MapMetricsView(
              options: MapOptions(
                initCenter: Position(8.0, 48.0), // lng, lat
                initZoom: 4,
                initStyle:
                    'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
              ),
              onMapCreated: (MapController controller) {
                mapController = controller;
              },
              onStyleLoaded: (StyleController style) async {
                styleController = style;
                await _addCitiesLayer();
              },
            ),
          ),
        ],
      ),
    );
  }

  Future<void> _addCitiesLayer() async {
    final style = styleController;
    if (style == null) return;

    final geoJson = {
      'type': 'FeatureCollection',
      'features': [
        {'type': 'Feature', 'properties': {'name': 'Paris', 'type': 'capital', 'pop': 2161}, 'geometry': {'type': 'Point', 'coordinates': [2.3522, 48.8566]}},
        {'type': 'Feature', 'properties': {'name': 'London', 'type': 'capital', 'pop': 8982}, 'geometry': {'type': 'Point', 'coordinates': [-0.1276, 51.5074]}},
        {'type': 'Feature', 'properties': {'name': 'Berlin', 'type': 'capital', 'pop': 3645}, 'geometry': {'type': 'Point', 'coordinates': [13.405, 52.52]}},
        {'type': 'Feature', 'properties': {'name': 'Rome', 'type': 'capital', 'pop': 2873}, 'geometry': {'type': 'Point', 'coordinates': [12.4964, 41.9028]}},
        {'type': 'Feature', 'properties': {'name': 'Barcelona', 'type': 'city', 'pop': 1621}, 'geometry': {'type': 'Point', 'coordinates': [2.1734, 41.3851]}},
        {'type': 'Feature', 'properties': {'name': 'Munich', 'type': 'city', 'pop': 1472}, 'geometry': {'type': 'Point', 'coordinates': [11.582, 48.1351]}},
        {'type': 'Feature', 'properties': {'name': 'Milan', 'type': 'city', 'pop': 1352}, 'geometry': {'type': 'Point', 'coordinates': [9.19, 45.4642]}},
        {'type': 'Feature', 'properties': {'name': 'Lyon', 'type': 'city', 'pop': 516}, 'geometry': {'type': 'Point', 'coordinates': [4.8357, 45.764]}},
        {'type': 'Feature', 'properties': {'name': 'Bruges', 'type': 'town', 'pop': 118}, 'geometry': {'type': 'Point', 'coordinates': [3.2247, 51.2093]}},
        {'type': 'Feature', 'properties': {'name': 'Salzburg', 'type': 'town', 'pop': 155}, 'geometry': {'type': 'Point', 'coordinates': [13.055, 47.8095]}},
        {'type': 'Feature', 'properties': {'name': 'Siena', 'type': 'town', 'pop': 54}, 'geometry': {'type': 'Point', 'coordinates': [11.3308, 43.3188]}},
      ],
    };

    await style.addSource(
      GeoJsonSource(id: 'cities', data: jsonEncode(geoJson)),
    );

    await style.addLayer(
      const CircleStyleLayer(
        id: 'cities-layer',
        sourceId: 'cities',
        paint: {
          'circle-radius': 8.0,
          'circle-color': '#3b82f6',
          'circle-stroke-color': '#ffffff',
          'circle-stroke-width': 2.0,
        },
      ),
    );

    await style.addLayer(
      const SymbolStyleLayer(
        id: 'cities-labels',
        sourceId: 'cities',
        layout: {
          'text-field': ['get', 'name'],
          'text-size': 12.0,
          'text-offset': [0.0, 1.5],
          'text-anchor': 'top',
        },
      ),
    );
  }

  Future<void> _applyFilter() async {
    final style = styleController;
    if (style == null) return;

    final filter =
        selectedType == 'all' ? null : ['==', ['get', 'type'], selectedType];

    // There's no setFilter — re-adding the layer with the new `filter` is
    // how filter changes are applied on an already-loaded style.
    await style.removeLayer('cities-layer');
    await style.removeLayer('cities-labels');

    await style.addLayer(
      CircleStyleLayer(
        id: 'cities-layer',
        sourceId: 'cities',
        filter: filter,
        paint: const {
          'circle-radius': 8.0,
          'circle-color': '#3b82f6',
          'circle-stroke-color': '#ffffff',
          'circle-stroke-width': 2.0,
        },
      ),
    );

    await style.addLayer(
      SymbolStyleLayer(
        id: 'cities-labels',
        sourceId: 'cities',
        filter: filter,
        layout: const {
          'text-field': ['get', 'name'],
          'text-size': 12.0,
          'text-offset': [0.0, 1.5],
          'text-anchor': 'top',
        },
      ),
    );
  }
}

Filter by Numeric Range

Filter features by a numeric property like population, using the same remove-and-re-add-with-filter pattern:

dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class NumericFilterScreen extends StatefulWidget {
  @override
  _NumericFilterScreenState createState() => _NumericFilterScreenState();
}

class _NumericFilterScreenState extends State<NumericFilterScreen> {
  MapController? mapController;
  StyleController? styleController;
  RangeValues populationRange = RangeValues(0, 10000);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Population Filter')),
      body: Column(
        children: [
          // Range slider
          Container(
            padding: EdgeInsets.all(16),
            child: Column(
              children: [
                Text(
                  'Population: ${populationRange.start.toInt()}K - ${populationRange.end.toInt()}K',
                  style: TextStyle(fontWeight: FontWeight.bold),
                ),
                RangeSlider(
                  values: populationRange,
                  min: 0,
                  max: 10000,
                  divisions: 100,
                  labels: RangeLabels(
                    '${populationRange.start.toInt()}K',
                    '${populationRange.end.toInt()}K',
                  ),
                  onChanged: (values) {
                    setState(() => populationRange = values);
                    _applyPopulationFilter();
                  },
                ),
              ],
            ),
          ),
          Expanded(
            child: MapMetricsView(
              options: MapOptions(
                initCenter: Position(8.0, 48.0), // lng, lat
                initZoom: 4,
                initStyle:
                    'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
              ),
              onMapCreated: (MapController controller) {
                mapController = controller;
              },
              onStyleLoaded: (StyleController style) async {
                styleController = style;
                await _addCitiesWithPopulation();
              },
            ),
          ),
        ],
      ),
    );
  }

  Future<void> _addCitiesWithPopulation() async {
    final style = styleController;
    if (style == null) return;

    final geoJson = {
      'type': 'FeatureCollection',
      'features': [
        {'type': 'Feature', 'properties': {'name': 'London', 'pop': 8982}, 'geometry': {'type': 'Point', 'coordinates': [-0.1276, 51.5074]}},
        {'type': 'Feature', 'properties': {'name': 'Berlin', 'pop': 3645}, 'geometry': {'type': 'Point', 'coordinates': [13.405, 52.52]}},
        {'type': 'Feature', 'properties': {'name': 'Madrid', 'pop': 3223}, 'geometry': {'type': 'Point', 'coordinates': [-3.7038, 40.4168]}},
        {'type': 'Feature', 'properties': {'name': 'Rome', 'pop': 2873}, 'geometry': {'type': 'Point', 'coordinates': [12.4964, 41.9028]}},
        {'type': 'Feature', 'properties': {'name': 'Paris', 'pop': 2161}, 'geometry': {'type': 'Point', 'coordinates': [2.3522, 48.8566]}},
        {'type': 'Feature', 'properties': {'name': 'Vienna', 'pop': 1897}, 'geometry': {'type': 'Point', 'coordinates': [16.3738, 48.2082]}},
        {'type': 'Feature', 'properties': {'name': 'Barcelona', 'pop': 1621}, 'geometry': {'type': 'Point', 'coordinates': [2.1734, 41.3851]}},
        {'type': 'Feature', 'properties': {'name': 'Munich', 'pop': 1472}, 'geometry': {'type': 'Point', 'coordinates': [11.582, 48.1351]}},
        {'type': 'Feature', 'properties': {'name': 'Amsterdam', 'pop': 873}, 'geometry': {'type': 'Point', 'coordinates': [4.9041, 52.3676]}},
        {'type': 'Feature', 'properties': {'name': 'Bruges', 'pop': 118}, 'geometry': {'type': 'Point', 'coordinates': [3.2247, 51.2093]}},
      ],
    };

    await style.addSource(
      GeoJsonSource(id: 'pop-cities', data: jsonEncode(geoJson)),
    );

    await style.addLayer(
      const CircleStyleLayer(
        id: 'pop-cities-layer',
        sourceId: 'pop-cities',
        paint: {
          'circle-radius': 8.0,
          'circle-color': '#ef4444',
          'circle-stroke-color': '#ffffff',
          'circle-stroke-width': 2.0,
        },
      ),
    );

    await style.addLayer(
      const SymbolStyleLayer(
        id: 'pop-cities-labels',
        sourceId: 'pop-cities',
        layout: {
          'text-field': ['get', 'name'],
          'text-size': 11.0,
          'text-offset': [0.0, 1.5],
          'text-anchor': 'top',
        },
      ),
    );
  }

  Future<void> _applyPopulationFilter() async {
    final style = styleController;
    if (style == null) return;

    final filter = [
      'all',
      ['>=', ['get', 'pop'], populationRange.start.toInt()],
      ['<=', ['get', 'pop'], populationRange.end.toInt()],
    ];

    await style.removeLayer('pop-cities-layer');
    await style.removeLayer('pop-cities-labels');

    await style.addLayer(
      CircleStyleLayer(
        id: 'pop-cities-layer',
        sourceId: 'pop-cities',
        filter: filter,
        paint: const {
          'circle-radius': 8.0,
          'circle-color': '#ef4444',
          'circle-stroke-color': '#ffffff',
          'circle-stroke-width': 2.0,
        },
      ),
    );

    await style.addLayer(
      SymbolStyleLayer(
        id: 'pop-cities-labels',
        sourceId: 'pop-cities',
        filter: filter,
        layout: const {
          'text-field': ['get', 'name'],
          'text-size': 11.0,
          'text-offset': [0.0, 1.5],
          'text-anchor': 'top',
        },
      ),
    );
  }
}

Common Filter Expressions

ExpressionDescription
['==', ['get', 'type'], 'capital']Equals
['!=', ['get', 'type'], 'town']Not equals
['>=', ['get', 'pop'], 1000]Greater than or equal
['in', 'capital', ['get', 'type']]Value in list
['all', filter1, filter2]AND (both must match)
['any', filter1, filter2]OR (either matches)
nullRemove filter (show all)

These are standard MapLibre style spec filter expressions — they get passed straight to a StyleLayer's filter field.

Next Steps


Tip: Because there's no setFilter, keep the source untouched and only removeLayer/addLayer the style layers when the filter changes — the GeoJSON data stays parsed and cached, so this is still cheaper than calling updateGeoJsonSource with a smaller feature collection.