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

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:

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:

dart
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:

dart
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:

dart
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

MethodAnimationUse Case
animateCamera({center, zoom, bearing, pitch, nativeDuration, webSpeed, webMaxDuration})Smooth transitionUser-facing navigation
moveCamera({center, zoom, bearing, pitch})Instant jumpLoading, resetting
moveCameraSync({center, zoom, bearing, pitch})Instant jump, synchronous (Android JNI only)Tight render loops
fitBounds({bounds, bearing, pitch, padding, ...})Fit areaShow 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


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+.