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. Filtering happens entirely in Dart — the filtered list is passed straight into a MarkerLayer, and MapMetrics re-diffs the points on every rebuild:
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
MarkerLayerwidget 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 fictionalMarker/InfoWindowAPI did. For per-place names and tap-to-select behavior, pair the map with a results list (see below) or use aGeoJsonSource+SymbolStyleLayerwith atext-fieldexpression, 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:
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
- 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.