Fly to Location on List Scroll in Flutter
This tutorial shows how to sync the map with a scrollable list — as the user scrolls through locations, the map automatically flies to each one. Perfect for property listings, tour guides, and story maps.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
Scroll-Synced Map
Map flies to each location card as it becomes the active card:
dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class ScrollSyncMapScreen extends StatefulWidget {
@override
_ScrollSyncMapScreenState createState() => _ScrollSyncMapScreenState();
}
class _ScrollSyncMapScreenState extends State<ScrollSyncMapScreen> {
MapMetricsController? mapController;
final PageController _pageController = PageController(viewportFraction: 0.85);
int activePage = 0;
final List<Map<String, dynamic>> locations = [
{
'name': 'Eiffel Tower',
'description': 'Iconic iron lattice tower built in 1889.',
'lat': 48.8584,
'lng': 2.2945,
'zoom': 16.0,
'color': Colors.blue,
},
{
'name': 'Louvre Museum',
'description': 'World\'s largest art museum, home of the Mona Lisa.',
'lat': 48.8606,
'lng': 2.3376,
'zoom': 16.0,
'color': Colors.purple,
},
{
'name': 'Notre-Dame',
'description': 'Medieval Catholic cathedral, a masterpiece of Gothic architecture.',
'lat': 48.8530,
'lng': 2.3499,
'zoom': 16.5,
'color': Colors.orange,
},
{
'name': 'Sacre-Coeur',
'description': 'White-domed basilica atop Montmartre hill.',
'lat': 48.8867,
'lng': 2.3431,
'zoom': 16.0,
'color': Colors.red,
},
{
'name': 'Arc de Triomphe',
'description': 'Monumental arch honoring those who fought for France.',
'lat': 48.8738,
'lng': 2.2950,
'zoom': 16.5,
'color': Colors.green,
},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Scroll-Synced Map')),
body: Stack(
children: [
// Map
MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(
locations[0]['lat'],
locations[0]['lng'],
),
zoom: locations[0]['zoom'],
),
markers: locations.asMap().entries.map((entry) {
final i = entry.key;
final loc = entry.value;
return Marker(
markerId: MarkerId(loc['name']),
position: LatLng(loc['lat'], loc['lng']),
icon: BitmapDescriptor.defaultMarkerWithHue(
i == activePage
? BitmapDescriptor.hueBlue
: BitmapDescriptor.hueRed,
),
infoWindow: InfoWindow(title: loc['name']),
);
}).toSet(),
),
// Progress indicator
Positioned(
top: 16,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(locations.length, (i) {
return Container(
width: i == activePage ? 24 : 8,
height: 8,
margin: EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: i == activePage ? Colors.blue : Colors.grey[400],
borderRadius: BorderRadius.circular(4),
),
);
}),
),
),
// Scrollable cards at bottom
Positioned(
bottom: 24,
left: 0,
right: 0,
height: 160,
child: PageView.builder(
controller: _pageController,
itemCount: locations.length,
onPageChanged: (index) {
setState(() => activePage = index);
final loc = locations[index];
mapController?.animateCamera(
CameraUpdate.newLatLngZoom(
LatLng(loc['lat'], loc['lng']),
loc['zoom'],
),
);
},
itemBuilder: (context, index) {
final loc = locations[index];
final isActive = index == activePage;
return AnimatedContainer(
duration: Duration(milliseconds: 300),
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: isActive ? 0 : 12,
),
child: Card(
elevation: isActive ? 8 : 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: loc['color'],
shape: BoxShape.circle,
),
),
SizedBox(width: 8),
Text(
loc['name'],
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
SizedBox(height: 8),
Expanded(
child: Text(
loc['description'],
style: TextStyle(color: Colors.grey[600]),
overflow: TextOverflow.ellipsis,
maxLines: 3,
),
),
Text(
'${index + 1} of ${locations.length}',
style: TextStyle(
color: Colors.grey, fontSize: 12),
),
],
),
),
),
);
},
),
),
],
),
);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
}Vertical List Scroll Sync
Use a vertical scrollable list instead of horizontal cards:
dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class VerticalScrollMapScreen extends StatefulWidget {
@override
_VerticalScrollMapScreenState createState() =>
_VerticalScrollMapScreenState();
}
class _VerticalScrollMapScreenState extends State<VerticalScrollMapScreen> {
MapMetricsController? mapController;
int activeIndex = 0;
final List<Map<String, dynamic>> chapters = [
{
'title': 'Chapter 1: Arrival',
'text': 'Our journey begins at the Eiffel Tower, the symbol of Paris.',
'lat': 48.8584, 'lng': 2.2945, 'zoom': 16.0, 'bearing': 30.0,
},
{
'title': 'Chapter 2: Art',
'text': 'Next, we visit the Louvre to see the world\'s greatest art collection.',
'lat': 48.8606, 'lng': 2.3376, 'zoom': 16.0, 'bearing': 120.0,
},
{
'title': 'Chapter 3: History',
'text': 'Notre-Dame stands as a testament to medieval architecture.',
'lat': 48.8530, 'lng': 2.3499, 'zoom': 16.5, 'bearing': 220.0,
},
{
'title': 'Chapter 4: Heights',
'text': 'We climb to Montmartre and Sacre-Coeur for a panoramic view.',
'lat': 48.8867, 'lng': 2.3431, 'zoom': 15.5, 'bearing': 0.0,
},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Story Map')),
body: Row(
children: [
// Story panel
Container(
width: MediaQuery.of(context).size.width * 0.4,
child: ListView.builder(
padding: EdgeInsets.all(16),
itemCount: chapters.length,
itemBuilder: (context, i) {
final ch = chapters[i];
final isActive = i == activeIndex;
return GestureDetector(
onTap: () {
setState(() => activeIndex = i);
_flyToChapter(i);
},
child: Container(
margin: EdgeInsets.only(bottom: 16),
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: isActive ? Colors.blue[50] : Colors.white,
border: Border.all(
color: isActive ? Colors.blue : Colors.grey[300]!,
width: isActive ? 2 : 1,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(ch['title'],
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: isActive ? Colors.blue : Colors.black,
)),
SizedBox(height: 8),
Text(ch['text'],
style: TextStyle(color: Colors.grey[700])),
],
),
),
);
},
),
),
// 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(chapters[0]['lat'], chapters[0]['lng']),
zoom: chapters[0]['zoom'],
bearing: chapters[0]['bearing'],
tilt: 45.0,
),
markers: chapters.asMap().entries.map((e) {
return Marker(
markerId: MarkerId('ch_${e.key}'),
position: LatLng(e.value['lat'], e.value['lng']),
infoWindow: InfoWindow(title: e.value['title']),
);
}).toSet(),
),
),
],
),
);
}
void _flyToChapter(int index) {
final ch = chapters[index];
mapController?.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: LatLng(ch['lat'], ch['lng']),
zoom: ch['zoom'],
bearing: ch['bearing'],
tilt: 45.0,
),
),
);
}
}Next Steps
- Jump to Locations — Quick location switching
- Fly to a Location — Smooth camera transitions
- Customize Camera Animations — Camera control
Tip: Use PageView with viewportFraction: 0.85 for horizontal cards — it shows a peek of the next card, hinting that users can swipe. For story-driven maps, use a vertical list with tilt and bearing changes for each chapter to create a cinematic experience.