Customize Camera Animations in Flutter
This tutorial shows how to create custom camera animations — zooming, tilting, rotating, and combining multiple camera movements for cinematic map experiences.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
Basic Camera Animations
Different types of camera movements with buttons. The MapController exposes animateCamera (smooth transition) and moveCamera (instant jump), both taking the same named parameters — center, zoom, bearing, pitch:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class CameraAnimationsScreen extends StatefulWidget {
@override
_CameraAnimationsScreenState createState() =>
_CameraAnimationsScreenState();
}
class _CameraAnimationsScreenState extends State<CameraAnimationsScreen> {
MapController? mapController;
static const _eiffelTower = Position(2.2945, 48.8584);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Camera Animations')),
body: Column(
children: [
// Animation buttons
Container(
padding: EdgeInsets.all(8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
_animButton('Zoom In', Icons.zoom_in, _zoomIn),
_animButton('Zoom Out', Icons.zoom_out, _zoomOut),
_animButton('Tilt', Icons.panorama_horizontal, _tilt),
_animButton('Rotate', Icons.rotate_right, _rotate),
_animButton('Bird\'s Eye', Icons.flight, _birdsEye),
_animButton('Reset', Icons.refresh, _reset),
],
),
),
// Map
Expanded(
child: MapMetricsView(
options: MapOptions(
initCenter: _eiffelTower,
initZoom: 15.0,
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
),
onMapCreated: (MapController controller) {
mapController = controller;
},
),
),
],
),
);
}
Widget _animButton(String label, IconData icon, VoidCallback onPressed) {
return ElevatedButton.icon(
onPressed: onPressed,
icon: Icon(icon, size: 18),
label: Text(label),
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
);
}
void _zoomIn() {
mapController?.animateCamera(zoom: 18.0);
}
void _zoomOut() {
mapController?.animateCamera(zoom: 10.0);
}
void _tilt() {
mapController?.animateCamera(
center: _eiffelTower,
zoom: 16.0,
pitch: 60.0,
bearing: 0.0,
);
}
void _rotate() {
mapController?.animateCamera(
center: _eiffelTower,
zoom: 16.0,
pitch: 45.0,
bearing: 180.0,
);
}
void _birdsEye() {
mapController?.animateCamera(
center: _eiffelTower,
zoom: 17.0,
pitch: 75.0,
bearing: 45.0,
);
}
void _reset() {
mapController?.animateCamera(
center: _eiffelTower,
zoom: 15.0,
pitch: 0.0,
bearing: 0.0,
);
}
}Cinematic City Tour
Automatically fly through a sequence of locations with different camera angles. Each stop is just a bag of the same named parameters passed straight to animateCamera:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class CityTourScreen extends StatefulWidget {
@override
_CityTourScreenState createState() => _CityTourScreenState();
}
class _CityTourScreenState extends State<CityTourScreen> {
MapController? mapController;
bool isTouring = false;
int currentStop = 0;
final List<Map<String, dynamic>> tourStops = [
{
'name': 'Eiffel Tower',
'center': Position(2.2945, 48.8584),
'zoom': 17.0,
'pitch': 60.0,
'bearing': 45.0,
},
{
'name': 'Arc de Triomphe',
'center': Position(2.2950, 48.8738),
'zoom': 17.0,
'pitch': 55.0,
'bearing': 135.0,
},
{
'name': 'Louvre Museum',
'center': Position(2.3376, 48.8606),
'zoom': 16.5,
'pitch': 50.0,
'bearing': 220.0,
},
{
'name': 'Notre-Dame',
'center': Position(2.3499, 48.8530),
'zoom': 17.0,
'pitch': 65.0,
'bearing': 310.0,
},
{
'name': 'Sacre-Coeur',
'center': Position(2.3431, 48.8867),
'zoom': 16.0,
'pitch': 70.0,
'bearing': 180.0,
},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('City Tour')),
body: Stack(
children: [
MapMetricsView(
options: MapOptions(
initCenter: Position(2.3200, 48.8566),
initZoom: 12.0,
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
),
onMapCreated: (MapController controller) {
mapController = controller;
},
),
// Tour info bar
Positioned(
top: 16,
left: 16,
right: 16,
child: Card(
elevation: 4,
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isTouring
? tourStops[currentStop]['name'] as String
: 'Paris City Tour',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
if (isTouring)
Padding(
padding: EdgeInsets.only(top: 8),
child: LinearProgressIndicator(
value: (currentStop + 1) / tourStops.length,
),
),
],
),
),
),
),
// Tour control
Positioned(
bottom: 24,
left: 0,
right: 0,
child: Center(
child: ElevatedButton.icon(
onPressed: isTouring ? null : _startTour,
icon: Icon(isTouring ? Icons.pause : Icons.play_arrow),
label: Text(isTouring ? 'Touring...' : 'Start Tour'),
style: ElevatedButton.styleFrom(
padding:
EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
),
),
],
),
);
}
Future<void> _startTour() async {
setState(() {
isTouring = true;
currentStop = 0;
});
for (int i = 0; i < tourStops.length; i++) {
if (!mounted) return;
setState(() {
currentStop = i;
});
final stop = tourStops[i];
mapController?.animateCamera(
center: stop['center'] as Position,
zoom: stop['zoom'] as double,
pitch: stop['pitch'] as double,
bearing: stop['bearing'] as double,
nativeDuration: const Duration(seconds: 4),
);
// Wait at each stop
await Future.delayed(Duration(seconds: 4));
}
if (mounted) {
setState(() {
isTouring = false;
});
}
}
}Smooth Zoom with Duration
Control animation speed using moveCamera (instant) vs animateCamera (smooth). animateCamera accepts nativeDuration (used on iOS/Android) and webSpeed/webMaxDuration (used on web) to control transition length:
void _smoothZoomToLocation() {
// Smooth animated transition
mapController?.animateCamera(
center: Position(2.2945, 48.8584),
zoom: 18.0,
pitch: 60.0,
bearing: 30.0,
nativeDuration: const Duration(milliseconds: 800),
);
}
void _instantJumpToLocation() {
// Instant jump — no animation
mapController?.moveCamera(
center: Position(2.2945, 48.8584),
zoom: 18.0,
pitch: 60.0,
bearing: 30.0,
);
}Camera Control Methods
| Method | Animation | Use Case |
|---|---|---|
animateCamera({center, zoom, bearing, pitch, nativeDuration, webSpeed, webMaxDuration}) | Smooth transition | User-facing navigation |
moveCamera({center, zoom, bearing, pitch}) | Instant jump | Loading, resetting |
moveCameraSync({center, zoom, bearing, pitch}) | Instant jump, synchronous (Android JNI only) | Tight render loops |
fitBounds({bounds, bearing, pitch, padding, ...}) | Fit area | Show all markers |
All parameters are optional and named — pass only what you want to change; the camera keeps its current value for anything omitted.
Next Steps
- Fly to a Location — Basic fly-to animation
- Slowly Fly to Location — Slow cinematic flight
- Animate Camera Around Point — Orbit animation
Tip: Combine pitch (0-60) and bearing (0-360) for dramatic 3D views. Higher pitch values give a more ground-level perspective, which works best at zoom levels 15+.