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:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
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
- Filter Markers — Filter by category toggles
- Add Clusters — Group many markers
- Popup on Click — Show details on tap
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.