Skip to content

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:

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

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

class _TextFilterScreenState extends State<TextFilterScreen> {
  MapMetricsController? 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: MapMetrics(
              styleUrl:
                  'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
              onMapCreated: (MapMetricsController controller) {
                mapController = controller;
              },
              initialCameraPosition: CameraPosition(
                target: LatLng(48.0, 8.0),
                zoom: 4.0,
              ),
              markers: filteredPlaces.map((place) {
                return Marker(
                  markerId: MarkerId(place['name']),
                  position: LatLng(place['lat'], place['lng']),
                  infoWindow: InfoWindow(
                    title: place['name'],
                    snippet: place['country'],
                  ),
                );
              }).toSet(),
            ),
          ),
        ],
      ),
    );
  }
}

Search with Results List

Show a scrollable list below the search that also highlights on the map:

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

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

class _SearchWithListScreenState extends State<SearchWithListScreen> {
  MapMetricsController? 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();
  }

  @override
  Widget build(BuildContext context) {
    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: MapMetrics(
              styleUrl:
                  'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
              onMapCreated: (MapMetricsController controller) {
                mapController = controller;
              },
              initialCameraPosition: CameraPosition(
                target: LatLng(46.0, 6.0),
                zoom: 4.0,
              ),
              markers: filtered.map((place) {
                final isSelected = place['id'] == selectedId;
                return Marker(
                  markerId: MarkerId(place['id']),
                  position: LatLng(place['lat'], place['lng']),
                  icon: BitmapDescriptor.defaultMarkerWithHue(
                    isSelected
                        ? BitmapDescriptor.hueBlue
                        : BitmapDescriptor.hueRed,
                  ),
                  infoWindow: InfoWindow(title: place['name']),
                );
              }).toSet(),
            ),
          ),
          // 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(
                      CameraUpdate.newLatLngZoom(
                        LatLng(place['lat'], place['lng']),
                        10.0,
                      ),
                    );
                  },
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

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.