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 a Marker in Flutter

This tutorial shows how to smoothly animate a marker's position along a path or between waypoints.

Prerequisites

Before you begin, ensure you have:

Basic Marker Animation

There is no Marker / Set<Marker> / markers: / polylines: widget API. A marker that needs to move every animation frame is modeled as a one-feature GeoJSON source rendered with a CircleStyleLayer (or SymbolStyleLayer if you want an icon), and updated every frame with StyleController.updateGeoJsonSource. The static route is a second GeoJSON source rendered with a LineStyleLayer. This mirrors how the SDK's own animated-path example works.

dart
import 'dart:convert';

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

class AnimateMarkerScreen extends StatefulWidget {
  @override
  _AnimateMarkerScreenState createState() => _AnimateMarkerScreenState();
}

const _routeSourceId = 'route';
const _markerSourceId = 'moving-marker';
const _markerLayerId = 'moving-marker-layer';

class _AnimateMarkerScreenState extends State<AnimateMarkerScreen>
    with SingleTickerProviderStateMixin {
  MapController? mapController;
  StyleController? styleController;
  late AnimationController _animationController;
  Position currentPosition = Position(2.2945, 48.8584);
  bool isAnimating = false;

  // Route waypoints (Paris landmarks), lng/lat order.
  final List<Position> route = [
    Position(2.2945, 48.8584), // Eiffel Tower
    Position(2.3376, 48.8606), // Louvre
    Position(2.3499, 48.8530), // Notre-Dame
    Position(2.3431, 48.8867), // Sacré-Cœur
    Position(2.2950, 48.8738), // Arc de Triomphe
    Position(2.2945, 48.8584), // Back to Eiffel Tower
  ];

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: Duration(seconds: 15), // Total animation time
    );

    _animationController.addListener(_updateMarkerPosition);
    _animationController.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        setState(() => isAnimating = false);
      }
    });
  }

  Future<void> _onStyleLoaded(StyleController style) async {
    styleController = style;

    // Static route line.
    await style.addSource(
      GeoJsonSource(id: _routeSourceId, data: jsonEncode(_routeGeoJson())),
    );
    await style.addLayer(
      const LineStyleLayer(
        id: 'route-line',
        sourceId: _routeSourceId,
        paint: {
          'line-color': '#1E88E5',
          'line-width': 3,
          'line-opacity': 0.5,
          'line-dasharray': [2, 1.5],
        },
      ),
    );

    // Moving marker as a single-point GeoJSON source.
    await style.addSource(
      GeoJsonSource(
        id: _markerSourceId,
        data: jsonEncode(_markerGeoJson(currentPosition)),
      ),
    );
    await style.addLayer(
      const CircleStyleLayer(
        id: _markerLayerId,
        sourceId: _markerSourceId,
        paint: {
          'circle-radius': 8,
          'circle-color': '#1565C0',
          'circle-stroke-color': '#FFFFFF',
          'circle-stroke-width': 2,
        },
      ),
    );
  }

  Map<String, Object?> _routeGeoJson() => {
    'type': 'Feature',
    'properties': <String, Object?>{},
    'geometry': {
      'type': 'LineString',
      'coordinates': route.map((p) => [p.lng, p.lat]).toList(),
    },
  };

  Map<String, Object?> _markerGeoJson(Position position) => {
    'type': 'Feature',
    'properties': <String, Object?>{},
    'geometry': {
      'type': 'Point',
      'coordinates': [position.lng, position.lat],
    },
  };

  void _updateMarkerPosition() {
    final progress = _animationController.value;
    final totalSegments = route.length - 1;
    final segmentProgress = progress * totalSegments;
    final segmentIndex = segmentProgress.floor().clamp(0, totalSegments - 1);
    final t = segmentProgress - segmentIndex;

    final from = route[segmentIndex];
    final to = route[segmentIndex + 1];

    final next = Position(
      from.lng + (to.lng - from.lng) * t,
      from.lat + (to.lat - from.lat) * t,
    );

    setState(() {
      currentPosition = next;
    });

    styleController?.updateGeoJsonSource(
      id: _markerSourceId,
      data: jsonEncode(_markerGeoJson(next)),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Animate Marker')),
      body: Stack(
        children: [
          MapMetricsView(
            options: MapOptions(
              initCenter: Position(2.3200, 48.8620),
              initZoom: 13.0,
              initStyle:
                  'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
            ),
            onMapCreated: (controller) => mapController = controller,
            onStyleLoaded: _onStyleLoaded,
          ),
          // Controls
          Positioned(
            bottom: 24,
            left: 0,
            right: 0,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                FloatingActionButton.extended(
                  heroTag: 'play',
                  onPressed: isAnimating ? _stopAnimation : _startAnimation,
                  icon: Icon(isAnimating ? Icons.stop : Icons.play_arrow),
                  label: Text(isAnimating ? 'Stop' : 'Start'),
                ),
                SizedBox(width: 12),
                FloatingActionButton.small(
                  heroTag: 'reset',
                  onPressed: _resetAnimation,
                  child: Icon(Icons.replay),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  void _startAnimation() {
    setState(() => isAnimating = true);
    _animationController.forward();
  }

  void _stopAnimation() {
    _animationController.stop();
    setState(() => isAnimating = false);
  }

  void _resetAnimation() {
    _animationController.reset();
    setState(() {
      isAnimating = false;
      currentPosition = route.first;
    });
    styleController?.updateGeoJsonSource(
      id: _markerSourceId,
      data: jsonEncode(_markerGeoJson(route.first)),
    );
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }
}

Looping Animation

Make the marker loop continuously along the route:

dart
void _startLoopAnimation() {
  setState(() => isAnimating = true);
  _animationController.repeat(); // Loops forever
}

Camera Follows Marker

Keep the camera centered on the moving marker. moveCamera (or the synchronous moveCameraSync, better suited to a per-frame listener) takes the target center directly — there is no CameraUpdate.newLatLng builder:

dart
void _updateMarkerPosition() {
  // ... calculate `next` as above ...

  setState(() {
    currentPosition = next;
  });

  styleController?.updateGeoJsonSource(
    id: _markerSourceId,
    data: jsonEncode(_markerGeoJson(next)),
  );

  // Camera follows the marker.
  mapController?.moveCameraSync(center: next);
}

Easing Curves

Use different animation curves for natural movement:

dart
// In initState, wrap with a CurvedAnimation:
final curvedAnimation = CurvedAnimation(
  parent: _animationController,
  curve: Curves.easeInOut, // Smooth start and end
);

curvedAnimation.addListener(() {
  final progress = curvedAnimation.value;
  // ... use progress for interpolation
});
CurveEffect
Curves.linearConstant speed (default)
Curves.easeInOutSlow start and end, fast middle
Curves.easeInSlow start, fast end
Curves.easeOutFast start, slow end
Curves.bounceOutBounce effect at the end

Next Steps


Tip: For smooth marker animation, use SingleTickerProviderStateMixin and keep the AnimationController duration proportional to the route length. Shorter routes need shorter durations. Because there's no built-in Marker widget with a mutable position, moving a marker always means pushing new GeoJSON through updateGeoJsonSource on every tick.