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:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
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
- Fly to a Location — Faster fly-to animations
- Jump to Locations — Instant jumps between stops
- Animate Camera Around Point — Orbiting camera
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.