Skip to content

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:

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


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.