Skip to content

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 duration 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> {
  MapMetricsController? mapController;
  String currentDestination = '';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Cinematic Flight')),
      body: Stack(
        children: [
          MapMetrics(
            styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
            onMapCreated: (controller) => mapController = controller,
            initialCameraPosition: CameraPosition(
              target: LatLng(48.8584, 2.2945),
              zoom: 5.0,
            ),
          ),
          // 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'];
    });

    mapController?.animateCamera(
      CameraUpdate.newCameraPosition(
        CameraPosition(
          target: destination['position'],
          zoom: destination['zoom'],
          bearing: destination['bearing'],
          tilt: destination['tilt'],
        ),
      ),
      duration: 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> {
  MapMetricsController? 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': LatLng(48.8566, 2.3522),
      'zoom': 12.0,
      'bearing': 0.0,
      'tilt': 0.0,
    },
    {
      'title': 'The Eiffel Tower',
      'subtitle': 'Built in 1889 for the World\'s Fair',
      'position': LatLng(48.8584, 2.2945),
      'zoom': 16.0,
      'bearing': 30.0,
      'tilt': 55.0,
    },
    {
      'title': 'Along the Seine',
      'subtitle': 'The river that divides Paris',
      'position': LatLng(48.8580, 2.3200),
      'zoom': 15.0,
      'bearing': 90.0,
      'tilt': 45.0,
    },
    {
      'title': 'Notre-Dame Cathedral',
      'subtitle': 'A masterpiece of Gothic architecture',
      'position': LatLng(48.8530, 2.3499),
      'zoom': 17.0,
      'bearing': -30.0,
      'tilt': 50.0,
    },
    {
      'title': 'Sacré-Cœur',
      'subtitle': 'The highest point in Paris',
      'position': LatLng(48.8867, 2.3431),
      'zoom': 16.0,
      'bearing': 180.0,
      'tilt': 55.0,
    },
  ];

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

    return Scaffold(
      body: Stack(
        children: [
          MapMetrics(
            styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
            onMapCreated: (controller) => mapController = controller,
            initialCameraPosition: CameraPosition(
              target: story[0]['position'],
              zoom: story[0]['zoom'],
            ),
          ),
          // 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(
      CameraUpdate.newCameraPosition(
        CameraPosition(
          target: step['position'],
          zoom: step['zoom'],
          bearing: step['bearing'],
          tilt: step['tilt'],
        ),
      ),
      duration: 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();
    mapController?.dispose();
    super.dispose();
  }
}

Next Steps


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