Animate Camera Around a Point in Flutter
This tutorial shows how to smoothly rotate the camera around a fixed point on the map, creating a cinematic orbiting effect.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
Basic Rotation Animation
Use a Flutter AnimationController to continuously rotate the bearing around a point:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class AnimateCameraScreen extends StatefulWidget {
@override
_AnimateCameraScreenState createState() => _AnimateCameraScreenState();
}
class _AnimateCameraScreenState extends State<AnimateCameraScreen>
with SingleTickerProviderStateMixin {
MapController? mapController;
late AnimationController _animationController;
bool isRotating = false;
double currentBearing = 0.0;
final Position center = Position(-74.0060, 40.7128); // New York (lng, lat)
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: Duration(seconds: 60), // Full rotation in 60 seconds
);
_animationController.addListener(() {
if (mapController != null && isRotating) {
currentBearing = _animationController.value * 360;
// moveCameraSync is designed for tight render loops like this one —
// it avoids the microtask scheduling gaps that async moveCamera has.
mapController?.moveCameraSync(
center: center,
zoom: 15.0,
bearing: currentBearing,
pitch: 45.0,
);
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Orbiting Camera')),
body: Stack(
children: [
MapMetricsView(
options: MapOptions(
initCenter: center,
initZoom: 15.0,
initPitch: 45.0,
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
),
onMapCreated: (controller) => mapController = controller,
),
// Play/Stop button
Positioned(
bottom: 24,
left: 0,
right: 0,
child: Center(
child: FloatingActionButton.extended(
onPressed: _toggleRotation,
icon: Icon(isRotating ? Icons.stop : Icons.play_arrow),
label: Text(isRotating ? 'Stop' : 'Orbit'),
),
),
),
],
),
);
}
void _toggleRotation() {
setState(() {
isRotating = !isRotating;
});
if (isRotating) {
_animationController.repeat();
} else {
_animationController.stop();
}
}
@override
void dispose() {
_animationController.dispose();
// MapController has no dispose() of its own — it's owned by the
// MapMetricsView widget and torn down automatically.
super.dispose();
}
}Adjustable Speed and Tilt
Let users control the orbit speed and tilt (pitch) angle:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class CustomOrbitScreen extends StatefulWidget {
@override
_CustomOrbitScreenState createState() => _CustomOrbitScreenState();
}
class _CustomOrbitScreenState extends State<CustomOrbitScreen>
with SingleTickerProviderStateMixin {
MapController? mapController;
late AnimationController _animationController;
bool isRotating = false;
double tilt = 45.0;
double speed = 1.0; // rotations per minute
final Position center = Position(2.2945, 48.8584); // Eiffel Tower (lng, lat)
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: Duration(seconds: 60),
);
_animationController.addListener(_onAnimationTick);
}
void _onAnimationTick() {
if (mapController != null && isRotating) {
final bearing = (_animationController.value * 360 * speed) % 360;
mapController?.moveCameraSync(
center: center,
zoom: 16.0,
bearing: bearing,
pitch: tilt,
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Custom Orbit')),
body: Column(
children: [
// Controls
Container(
padding: EdgeInsets.all(12),
color: Colors.grey[100],
child: Column(
children: [
Row(
children: [
SizedBox(width: 60, child: Text('Tilt: ${tilt.toInt()}°')),
Expanded(
child: Slider(
value: tilt,
min: 0,
max: 60,
onChanged: (value) => setState(() => tilt = value),
),
),
],
),
Row(
children: [
SizedBox(width: 60, child: Text('Speed: ${speed.toStringAsFixed(1)}x')),
Expanded(
child: Slider(
value: speed,
min: 0.2,
max: 5.0,
onChanged: (value) => setState(() => speed = value),
),
),
],
),
],
),
),
// Map
Expanded(
child: Stack(
children: [
MapMetricsView(
options: MapOptions(
initCenter: center,
initZoom: 16.0,
initPitch: tilt,
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
),
onMapCreated: (controller) => mapController = controller,
),
Positioned(
bottom: 24,
left: 0,
right: 0,
child: Center(
child: FloatingActionButton.extended(
onPressed: _toggleRotation,
icon: Icon(isRotating ? Icons.stop : Icons.play_arrow),
label: Text(isRotating ? 'Stop' : 'Start Orbit'),
),
),
),
],
),
),
],
),
);
}
void _toggleRotation() {
setState(() {
isRotating = !isRotating;
});
if (isRotating) {
_animationController.repeat();
} else {
_animationController.stop();
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
}Key Concepts
| Concept | Details |
|---|---|
AnimationController | Drives the continuous rotation loop |
SingleTickerProviderStateMixin | Required mixin for AnimationController |
moveCameraSync | Used instead of animateCamera for frame-by-frame updates in tight render loops (Android JNI; falls back to async moveCamera on other platforms) |
repeat() | Makes the animation loop continuously |
bearing | Incremented each frame to create rotation |
pitch | The real API's name for camera tilt — passed directly to moveCamera/animateCamera, there is no separate tilt field |
Next Steps
- Set Pitch and Bearing — Manual pitch/bearing control
- Fly to a Location — Animated camera transitions
- Jump to Locations — Tour through multiple locations
Tip: Use moveCameraSync (not animateCamera) inside the animation listener for smooth frame-by-frame updates. animateCamera adds its own easing which conflicts with the animation controller, and the async moveCamera can introduce microtask scheduling gaps at high frame rates.