Skip to content

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:

dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class CameraAnimationsScreen extends StatefulWidget {
  @override
  _CameraAnimationsScreenState createState() =>
      _CameraAnimationsScreenState();
}

class _CameraAnimationsScreenState extends State<CameraAnimationsScreen> {
  MapMetricsController? mapController;

  @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: MapMetrics(
              styleUrl:
                  'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
              onMapCreated: (MapMetricsController controller) {
                mapController = controller;
              },
              initialCameraPosition: CameraPosition(
                target: LatLng(48.8584, 2.2945),
                zoom: 15.0,
              ),
            ),
          ),
        ],
      ),
    );
  }

  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(
      CameraUpdate.zoomTo(18.0),
    );
  }

  void _zoomOut() {
    mapController?.animateCamera(
      CameraUpdate.zoomTo(10.0),
    );
  }

  void _tilt() {
    mapController?.animateCamera(
      CameraUpdate.newCameraPosition(
        CameraPosition(
          target: LatLng(48.8584, 2.2945),
          zoom: 16.0,
          tilt: 60.0,
          bearing: 0.0,
        ),
      ),
    );
  }

  void _rotate() {
    mapController?.animateCamera(
      CameraUpdate.newCameraPosition(
        CameraPosition(
          target: LatLng(48.8584, 2.2945),
          zoom: 16.0,
          tilt: 45.0,
          bearing: 180.0,
        ),
      ),
    );
  }

  void _birdsEye() {
    mapController?.animateCamera(
      CameraUpdate.newCameraPosition(
        CameraPosition(
          target: LatLng(48.8584, 2.2945),
          zoom: 17.0,
          tilt: 75.0,
          bearing: 45.0,
        ),
      ),
    );
  }

  void _reset() {
    mapController?.animateCamera(
      CameraUpdate.newCameraPosition(
        CameraPosition(
          target: LatLng(48.8584, 2.2945),
          zoom: 15.0,
          tilt: 0.0,
          bearing: 0.0,
        ),
      ),
    );
  }
}

Cinematic City Tour

Automatically fly through a sequence of locations with different camera angles:

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> {
  MapMetricsController? mapController;
  bool isTouring = false;
  int currentStop = 0;

  final List<Map<String, dynamic>> tourStops = [
    {
      'name': 'Eiffel Tower',
      'position': CameraPosition(
        target: LatLng(48.8584, 2.2945),
        zoom: 17.0,
        tilt: 60.0,
        bearing: 45.0,
      ),
    },
    {
      'name': 'Arc de Triomphe',
      'position': CameraPosition(
        target: LatLng(48.8738, 2.2950),
        zoom: 17.0,
        tilt: 55.0,
        bearing: 135.0,
      ),
    },
    {
      'name': 'Louvre Museum',
      'position': CameraPosition(
        target: LatLng(48.8606, 2.3376),
        zoom: 16.5,
        tilt: 50.0,
        bearing: 220.0,
      ),
    },
    {
      'name': 'Notre-Dame',
      'position': CameraPosition(
        target: LatLng(48.8530, 2.3499),
        zoom: 17.0,
        tilt: 65.0,
        bearing: 310.0,
      ),
    },
    {
      'name': 'Sacre-Coeur',
      'position': CameraPosition(
        target: LatLng(48.8867, 2.3431),
        zoom: 16.0,
        tilt: 70.0,
        bearing: 180.0,
      ),
    },
  ];

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

      mapController?.animateCamera(
        CameraUpdate.newCameraPosition(
          tourStops[i]['position'] as CameraPosition,
        ),
      );

      // 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):

dart
void _smoothZoomToLocation() {
  // Smooth animated transition (default ~300ms)
  mapController?.animateCamera(
    CameraUpdate.newCameraPosition(
      CameraPosition(
        target: LatLng(48.8584, 2.2945),
        zoom: 18.0,
        tilt: 60.0,
        bearing: 30.0,
      ),
    ),
  );
}

void _instantJumpToLocation() {
  // Instant jump — no animation
  mapController?.moveCamera(
    CameraUpdate.newCameraPosition(
      CameraPosition(
        target: LatLng(48.8584, 2.2945),
        zoom: 18.0,
        tilt: 60.0,
        bearing: 30.0,
      ),
    ),
  );
}

Camera Update Methods

MethodAnimationUse Case
animateCameraSmooth transitionUser-facing navigation
moveCameraInstant jumpLoading, resetting
CameraUpdate.zoomTo(level)Zoom onlyQuick zoom change
CameraUpdate.newLatLng(pos)Pan onlyMove without zoom change
CameraUpdate.newLatLngZoom(pos, zoom)Pan + zoomNavigate to a location
CameraUpdate.newCameraPosition(...)Full controlTilt, bearing, zoom, pan
CameraUpdate.newLatLngBounds(bounds, padding)Fit areaShow all markers

Next Steps


Tip: Combine tilt (0-60) and bearing (0-360) for dramatic 3D views. Higher tilt values give a more ground-level perspective, which works best at zoom levels 15+.