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 Markers by Text Input in Flutter

This tutorial shows how to filter map markers in real-time as the user types in a search field — great for location search, store finders, or point-of-interest lookup.

Prerequisites

Before you begin, ensure you have:

Basic Text Filter

Type to filter city markers on the map. Filtering happens entirely in Dart — the filtered list is passed straight into a MarkerLayer, and MapMetrics re-diffs the points on every rebuild:

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

class TextFilterScreen extends StatefulWidget {
  @override
  _TextFilterScreenState createState() => _TextFilterScreenState();
}

class _TextFilterScreenState extends State<TextFilterScreen> {
  MapController? mapController;
  String searchQuery = '';

  final List<Map<String, dynamic>> allPlaces = [
    {'name': 'Paris', 'lat': 48.8566, 'lng': 2.3522, 'country': 'France'},
    {'name': 'London', 'lat': 51.5074, 'lng': -0.1276, 'country': 'UK'},
    {'name': 'Berlin', 'lat': 52.52, 'lng': 13.405, 'country': 'Germany'},
    {'name': 'Rome', 'lat': 41.9028, 'lng': 12.4964, 'country': 'Italy'},
    {'name': 'Madrid', 'lat': 40.4168, 'lng': -3.7038, 'country': 'Spain'},
    {'name': 'Vienna', 'lat': 48.2082, 'lng': 16.3738, 'country': 'Austria'},
    {'name': 'Amsterdam', 'lat': 52.3676, 'lng': 4.9041, 'country': 'Netherlands'},
    {'name': 'Prague', 'lat': 50.0755, 'lng': 14.4378, 'country': 'Czech Republic'},
    {'name': 'Brussels', 'lat': 50.8503, 'lng': 4.3517, 'country': 'Belgium'},
    {'name': 'Lisbon', 'lat': 38.7223, 'lng': -9.1393, 'country': 'Portugal'},
    {'name': 'Barcelona', 'lat': 41.3851, 'lng': 2.1734, 'country': 'Spain'},
    {'name': 'Budapest', 'lat': 47.4979, 'lng': 19.0402, 'country': 'Hungary'},
  ];

  List<Map<String, dynamic>> get filteredPlaces {
    if (searchQuery.isEmpty) return allPlaces;
    final query = searchQuery.toLowerCase();
    return allPlaces.where((place) {
      return place['name'].toString().toLowerCase().contains(query) ||
          place['country'].toString().toLowerCase().contains(query);
    }).toList();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Filter by Search')),
      body: Column(
        children: [
          // Search bar
          Container(
            padding: EdgeInsets.all(12),
            color: Colors.white,
            child: TextField(
              decoration: InputDecoration(
                hintText: 'Search cities or countries...',
                prefixIcon: Icon(Icons.search),
                suffixIcon: searchQuery.isNotEmpty
                    ? IconButton(
                        icon: Icon(Icons.clear),
                        onPressed: () {
                          setState(() {
                            searchQuery = '';
                          });
                        },
                      )
                    : null,
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(12),
                ),
                contentPadding: EdgeInsets.symmetric(horizontal: 16),
              ),
              onChanged: (value) {
                setState(() {
                  searchQuery = value;
                });
              },
            ),
          ),
          // Results count
          Container(
            padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
            color: Colors.grey[100],
            width: double.infinity,
            child: Text(
              '${filteredPlaces.length} of ${allPlaces.length} places shown',
              style: TextStyle(color: Colors.grey[600], fontSize: 12),
            ),
          ),
          // Map
          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;
              },
              layers: [
                MarkerLayer(
                  points: filteredPlaces
                      .map(
                        (place) => Point(
                          coordinates: Position(place['lng'], place['lat']),
                        ),
                      )
                      .toList(),
                  iconAnchor: IconAnchor.bottom,
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

The MarkerLayer widget applies one set of icon/text styling to every point it renders — it doesn't carry per-marker labels or popups the way the old fictional Marker/InfoWindow API did. For per-place names and tap-to-select behavior, pair the map with a results list (see below) or use a GeoJsonSource + SymbolStyleLayer with a text-field expression, as shown in Filter Features Within a Layer.

Search with Results List

Show a scrollable list below the search that also highlights on the map. The selected place is rendered with a second, small CircleLayer on top of the marker layer:

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

class SearchWithListScreen extends StatefulWidget {
  @override
  _SearchWithListScreenState createState() => _SearchWithListScreenState();
}

class _SearchWithListScreenState extends State<SearchWithListScreen> {
  MapController? mapController;
  String searchQuery = '';
  String? selectedId;

  final List<Map<String, dynamic>> places = [
    {'id': 'paris', 'name': 'Paris', 'lat': 48.8566, 'lng': 2.3522, 'type': 'Capital'},
    {'id': 'london', 'name': 'London', 'lat': 51.5074, 'lng': -0.1276, 'type': 'Capital'},
    {'id': 'berlin', 'name': 'Berlin', 'lat': 52.52, 'lng': 13.405, 'type': 'Capital'},
    {'id': 'rome', 'name': 'Rome', 'lat': 41.9028, 'lng': 12.4964, 'type': 'Capital'},
    {'id': 'madrid', 'name': 'Madrid', 'lat': 40.4168, 'lng': -3.7038, 'type': 'Capital'},
    {'id': 'barcelona', 'name': 'Barcelona', 'lat': 41.3851, 'lng': 2.1734, 'type': 'City'},
    {'id': 'munich', 'name': 'Munich', 'lat': 48.1351, 'lng': 11.582, 'type': 'City'},
    {'id': 'milan', 'name': 'Milan', 'lat': 45.4642, 'lng': 9.19, 'type': 'City'},
    {'id': 'lyon', 'name': 'Lyon', 'lat': 45.764, 'lng': 4.8357, 'type': 'City'},
    {'id': 'porto', 'name': 'Porto', 'lat': 41.1579, 'lng': -8.6291, 'type': 'City'},
  ];

  List<Map<String, dynamic>> get filtered {
    if (searchQuery.isEmpty) return places;
    final q = searchQuery.toLowerCase();
    return places.where((p) =>
        p['name'].toString().toLowerCase().contains(q) ||
        p['type'].toString().toLowerCase().contains(q)).toList();
  }

  Map<String, dynamic>? get selectedPlace {
    for (final p in places) {
      if (p['id'] == selectedId) return p;
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
    final selected = selectedPlace;

    return Scaffold(
      appBar: AppBar(title: Text('Search & List')),
      body: Column(
        children: [
          // Search field
          Padding(
            padding: EdgeInsets.all(12),
            child: TextField(
              decoration: InputDecoration(
                hintText: 'Search places...',
                prefixIcon: Icon(Icons.search),
                border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(12)),
              ),
              onChanged: (v) => setState(() => searchQuery = v),
            ),
          ),
          // Map (takes 60% of space)
          Expanded(
            flex: 6,
            child: MapMetricsView(
              options: MapOptions(
                initCenter: Position(6.0, 46.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;
              },
              layers: [
                MarkerLayer(
                  points: filtered
                      .map(
                        (place) => Point(
                          coordinates: Position(place['lng'], place['lat']),
                        ),
                      )
                      .toList(),
                  iconAnchor: IconAnchor.bottom,
                ),
                if (selected != null)
                  CircleLayer(
                    points: [
                      Point(
                        coordinates:
                            Position(selected['lng'], selected['lat']),
                      ),
                    ],
                    radius: 10,
                    color: Colors.blue,
                    strokeColor: Colors.white,
                    strokeWidth: 2,
                  ),
              ],
            ),
          ),
          // Results list (takes 40% of space)
          Expanded(
            flex: 4,
            child: ListView.builder(
              itemCount: filtered.length,
              itemBuilder: (context, index) {
                final place = filtered[index];
                final isSelected = place['id'] == selectedId;
                return ListTile(
                  leading: Icon(
                    Icons.location_on,
                    color: isSelected ? Colors.blue : Colors.grey,
                  ),
                  title: Text(
                    place['name'],
                    style: TextStyle(
                      fontWeight:
                          isSelected ? FontWeight.bold : FontWeight.normal,
                    ),
                  ),
                  subtitle: Text(place['type']),
                  selected: isSelected,
                  selectedTileColor: Colors.blue[50],
                  onTap: () {
                    setState(() {
                      selectedId = place['id'];
                    });
                    mapController?.animateCamera(
                      center: Position(place['lng'], place['lat']),
                      zoom: 10,
                    );
                  },
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

Next Steps


Tip: For large datasets (100+ places), debounce the search input using a Timer so the map doesn't rebuild on every keystroke. A 300ms delay feels responsive while avoiding unnecessary work.