Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

Slowly Fly to a Location in Flutter

This tutorial shows how to create a slow, cinematic camera flight across the map — great for storytelling, presentations, or showcasing a journey.

Prerequisites

Before you begin, ensure you have:

Basic Slow Flight

Use a long nativeDuration on animateCamera for a cinematic effect:

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

class SlowFlyScreen extends StatefulWidget {
  @override
  _SlowFlyScreenState createState() => _SlowFlyScreenState();
}

class _SlowFlyScreenState extends State<SlowFlyScreen> {
  MapController? mapController;
  String currentDestination = '';

  final List<Map<String, dynamic>> destinations = [
    {
      'name': 'Eiffel Tower',
      'position': Position(2.2945, 48.8584), // lng, lat
      'zoom': 16.0,
      'bearing': 30.0,
      'pitch': 55.0,
    },
    {
      'name': 'Colosseum, Rome',
      'position': Position(12.4922, 41.8902),
      'zoom': 16.0,
      'bearing': -20.0,
      'pitch': 50.0,
    },
    {
      'name': 'Santorini, Greece',
      'position': Position(25.4615, 36.3932),
      'zoom': 14.0,
      'bearing': 60.0,
      'pitch': 45.0,
    },
    {
      'name': 'Grand Canyon',
      'position': Position(-112.1129, 36.1069),
      'zoom': 14.0,
      'bearing': -40.0,
      'pitch': 50.0,
    },
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Cinematic Flight')),
      body: Stack(
        children: [
          MapMetricsView(
            options: MapOptions(
              initStyle:
                  'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
              initCenter: Position(2.2945, 48.8584),
              initZoom: 5.0,
            ),
            onMapCreated: (controller) => mapController = controller,
          ),
          // Destination buttons
          Positioned(
            bottom: 24,
            left: 16,
            right: 16,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                // Current destination label
                if (currentDestination.isNotEmpty)
                  Container(
                    margin: EdgeInsets.only(bottom: 12),
                    padding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
                    decoration: BoxDecoration(
                      color: Colors.black.withOpacity(0.7),
                      borderRadius: BorderRadius.circular(8),
                    ),
                    child: Text(
                      'Flying to $currentDestination...',
                      style: TextStyle(color: Colors.white, fontSize: 15),
                    ),
                  ),
                // Buttons
                SingleChildScrollView(
                  scrollDirection: Axis.horizontal,
                  child: Row(
                    children: destinations.map((dest) {
                      return Padding(
                        padding: EdgeInsets.only(right: 8),
                        child: ElevatedButton(
                          onPressed: () => _slowFlyTo(dest),
                          style: ElevatedButton.styleFrom(
                            backgroundColor: Colors.white,
                            foregroundColor: Colors.black87,
                            elevation: 4,
                          ),
                          child: Text(dest['name']),
                        ),
                      );
                    }).toList(),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  void _slowFlyTo(Map<String, dynamic> destination) {
    setState(() {
      currentDestination = destination['name'] as String;
    });

    mapController?.animateCamera(
      center: destination['position'] as Position,
      zoom: destination['zoom'] as double,
      bearing: destination['bearing'] as double,
      pitch: destination['pitch'] as double,
      nativeDuration: Duration(seconds: 5), // Slow cinematic flight
    );

    // Clear label after flight completes
    Future.delayed(Duration(seconds: 6), () {
      if (mounted) {
        setState(() => currentDestination = '');
      }
    });
  }
}

Storytelling Sequence

Play a sequence of slow flights with text narration:

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

class StoryMapScreen extends StatefulWidget {
  @override
  _StoryMapScreenState createState() => _StoryMapScreenState();
}

class _StoryMapScreenState extends State<StoryMapScreen> {
  MapController? mapController;
  int currentStep = 0;
  bool isPlaying = false;
  Timer? storyTimer;

  final List<Map<String, dynamic>> story = [
    {
      'title': 'Our journey begins in Paris',
      'subtitle': 'The City of Light',
      'position': Position(2.3522, 48.8566), // lng, lat
      'zoom': 12.0,
      'bearing': 0.0,
      'pitch': 0.0,
    },
    {
      'title': 'The Eiffel Tower',
      'subtitle': 'Built in 1889 for the World\'s Fair',
      'position': Position(2.2945, 48.8584),
      'zoom': 16.0,
      'bearing': 30.0,
      'pitch': 55.0,
    },
    {
      'title': 'Along the Seine',
      'subtitle': 'The river that divides Paris',
      'position': Position(2.3200, 48.8580),
      'zoom': 15.0,
      'bearing': 90.0,
      'pitch': 45.0,
    },
    {
      'title': 'Notre-Dame Cathedral',
      'subtitle': 'A masterpiece of Gothic architecture',
      'position': Position(2.3499, 48.8530),
      'zoom': 17.0,
      'bearing': -30.0,
      'pitch': 50.0,
    },
    {
      'title': 'Sacré-Cœur',
      'subtitle': 'The highest point in Paris',
      'position': Position(2.3431, 48.8867),
      'zoom': 16.0,
      'bearing': 180.0,
      'pitch': 55.0,
    },
  ];

  @override
  Widget build(BuildContext context) {
    final step = story[currentStep];

    return Scaffold(
      body: Stack(
        children: [
          MapMetricsView(
            options: MapOptions(
              initStyle:
                  'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
              initCenter: story[0]['position'] as Position,
              initZoom: story[0]['zoom'] as double,
            ),
            onMapCreated: (controller) => mapController = controller,
          ),
          // Story overlay
          Positioned(
            top: 60,
            left: 20,
            right: 20,
            child: Container(
              padding: EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: Colors.black.withOpacity(0.7),
                borderRadius: BorderRadius.circular(12),
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    step['title'],
                    style: TextStyle(
                      color: Colors.white,
                      fontSize: 20,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  SizedBox(height: 4),
                  Text(
                    step['subtitle'],
                    style: TextStyle(color: Colors.white70, fontSize: 14),
                  ),
                  SizedBox(height: 8),
                  Text(
                    '${currentStep + 1} / ${story.length}',
                    style: TextStyle(color: Colors.white54, fontSize: 12),
                  ),
                ],
              ),
            ),
          ),
          // Controls
          Positioned(
            bottom: 40,
            left: 0,
            right: 0,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                FloatingActionButton.small(
                  heroTag: 'prev',
                  onPressed: currentStep > 0 ? _previous : null,
                  child: Icon(Icons.skip_previous),
                ),
                SizedBox(width: 12),
                FloatingActionButton(
                  heroTag: 'play',
                  onPressed: _toggleAutoPlay,
                  child: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
                ),
                SizedBox(width: 12),
                FloatingActionButton.small(
                  heroTag: 'next',
                  onPressed: currentStep < story.length - 1 ? _next : null,
                  child: Icon(Icons.skip_next),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  void _navigateToStep(int index) {
    final step = story[index];
    mapController?.animateCamera(
      center: step['position'] as Position,
      zoom: step['zoom'] as double,
      bearing: step['bearing'] as double,
      pitch: step['pitch'] as double,
      nativeDuration: Duration(seconds: 4),
    );
  }

  void _next() {
    if (currentStep < story.length - 1) {
      setState(() => currentStep++);
      _navigateToStep(currentStep);
    }
  }

  void _previous() {
    if (currentStep > 0) {
      setState(() => currentStep--);
      _navigateToStep(currentStep);
    }
  }

  void _toggleAutoPlay() {
    if (isPlaying) {
      storyTimer?.cancel();
      setState(() => isPlaying = false);
    } else {
      setState(() => isPlaying = true);
      _navigateToStep(currentStep);
      storyTimer = Timer.periodic(Duration(seconds: 6), (_) {
        if (currentStep < story.length - 1) {
          _next();
        } else {
          storyTimer?.cancel();
          setState(() => isPlaying = false);
        }
      });
    }
  }

  @override
  void dispose() {
    storyTimer?.cancel();
    super.dispose();
  }
}

Next Steps


Tip: Combine slow flights with changing bearing and pitch at each stop for a truly cinematic experience. Give each flight 4–6 seconds (nativeDuration) for the best visual effect.