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

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:

Basic Rotation Animation

Use a Flutter AnimationController to continuously rotate the bearing around a point:

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

dart
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

ConceptDetails
AnimationControllerDrives the continuous rotation loop
SingleTickerProviderStateMixinRequired mixin for AnimationController
moveCameraSyncUsed 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
bearingIncremented each frame to create rotation
pitchThe real API's name for camera tilt — passed directly to moveCamera/animateCamera, there is no separate tilt field

Next Steps


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.