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 Point Along a Route in Flutter

This tutorial shows how to smoothly animate a marker moving along a defined route path — great for delivery tracking, ride-hailing, or tour animations.

Prerequisites

Before you begin, ensure you have:

There is no markers: / polylines: / Marker / Polyline widget API on MapMetricsView. Every example below models the route as a GeoJsonSource + LineStyleLayer, and the moving point as a second, single-feature GeoJsonSource + CircleStyleLayer that gets refreshed with StyleController.updateGeoJsonSource on every tick — the same pattern the SDK's own animated-path example uses.

Basic Point Animation Along Route

Move a marker along a European city route with Start/Stop/Reset controls:

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

class AnimatePointAlongRouteScreen extends StatefulWidget {
  @override
  _AnimatePointAlongRouteScreenState createState() =>
      _AnimatePointAlongRouteScreenState();
}

const _routeSourceId = 'route';
const _pointSourceId = 'moving-point';

class _AnimatePointAlongRouteScreenState
    extends State<AnimatePointAlongRouteScreen> {
  MapController? mapController;
  StyleController? styleController;
  Timer? animationTimer;
  int currentIndex = 0;
  bool isAnimating = false;

  // Route waypoints (European capitals), lng/lat order.
  final List<Position> waypoints = [
    Position(-3.7038, 40.4168),  // Madrid
    Position(2.3522, 48.8566),   // Paris
    Position(-0.1276, 51.5074),  // London
    Position(13.405, 52.52),     // Berlin
    Position(16.3738, 48.2082),  // Vienna
    Position(12.4964, 41.9028),  // Rome
  ];

  // Interpolated points for smooth animation
  late List<Position> smoothRoute;

  @override
  void initState() {
    super.initState();
    smoothRoute = _interpolateRoute(waypoints, 50);
  }

  /// Create smooth intermediate points between waypoints
  List<Position> _interpolateRoute(List<Position> points, int stepsPerSegment) {
    final result = <Position>[];
    for (int i = 0; i < points.length - 1; i++) {
      final from = points[i];
      final to = points[i + 1];
      for (int s = 0; s < stepsPerSegment; s++) {
        final t = s / stepsPerSegment;
        result.add(Position(
          from.lng + (to.lng - from.lng) * t,
          from.lat + (to.lat - from.lat) * t,
        ));
      }
    }
    result.add(points.last);
    return result;
  }

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

    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.4},
      ),
    );

    await style.addSource(
      GeoJsonSource(
        id: _pointSourceId,
        data: jsonEncode(_pointGeoJson(smoothRoute[currentIndex])),
      ),
    );
    await style.addLayer(
      const CircleStyleLayer(
        id: 'moving-point-layer',
        sourceId: _pointSourceId,
        paint: {
          'circle-radius': 7,
          'circle-color': '#1565C0',
          'circle-stroke-color': '#FFFFFF',
          'circle-stroke-width': 2,
        },
      ),
    );
  }

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

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Animate Point Along Route')),
      body: Column(
        children: [
          // Controls
          Container(
            padding: EdgeInsets.all(12),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton.icon(
                  onPressed: isAnimating ? null : _startAnimation,
                  icon: Icon(Icons.play_arrow),
                  label: Text('Start'),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.blue,
                    foregroundColor: Colors.white,
                  ),
                ),
                SizedBox(width: 8),
                ElevatedButton.icon(
                  onPressed: isAnimating ? _stopAnimation : null,
                  icon: Icon(Icons.stop),
                  label: Text('Stop'),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.red,
                    foregroundColor: Colors.white,
                  ),
                ),
                SizedBox(width: 8),
                ElevatedButton.icon(
                  onPressed: _resetAnimation,
                  icon: Icon(Icons.replay),
                  label: Text('Reset'),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.grey,
                    foregroundColor: Colors.white,
                  ),
                ),
              ],
            ),
          ),
          // Map
          Expanded(
            child: MapMetricsView(
              options: MapOptions(
                initCenter: Position(5.0, 48.0),
                initZoom: 4.0,
                initStyle:
                    'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
              ),
              onMapCreated: (MapController controller) {
                mapController = controller;
              },
              onStyleLoaded: _onStyleLoaded,
            ),
          ),
        ],
      ),
    );
  }

  void _startAnimation() {
    setState(() {
      isAnimating = true;
    });

    animationTimer = Timer.periodic(Duration(milliseconds: 50), (timer) {
      if (currentIndex >= smoothRoute.length - 1) {
        _stopAnimation();
        return;
      }

      setState(() {
        currentIndex++;
      });

      final next = smoothRoute[currentIndex];
      styleController?.updateGeoJsonSource(
        id: _pointSourceId,
        data: jsonEncode(_pointGeoJson(next)),
      );

      // Optionally follow the marker with the camera
      mapController?.animateCamera(center: next);
    });
  }

  void _stopAnimation() {
    animationTimer?.cancel();
    setState(() {
      isAnimating = false;
    });
  }

  void _resetAnimation() {
    _stopAnimation();
    setState(() {
      currentIndex = 0;
    });
    styleController?.updateGeoJsonSource(
      id: _pointSourceId,
      data: jsonEncode(_pointGeoJson(smoothRoute.first)),
    );
    mapController?.animateCamera(center: Position(5.0, 48.0), zoom: 4.0);
  }

  @override
  void dispose() {
    animationTimer?.cancel();
    super.dispose();
  }
}

Delivery Tracker Example

A practical example showing a delivery moving along a path with status updates:

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

class DeliveryTrackerScreen extends StatefulWidget {
  @override
  _DeliveryTrackerScreenState createState() => _DeliveryTrackerScreenState();
}

const _routeSourceId = 'delivery-route';
const _stopsSourceId = 'delivery-stops';
const _driverSourceId = 'delivery-driver';

class _DeliveryTrackerScreenState extends State<DeliveryTrackerScreen> {
  MapController? mapController;
  StyleController? styleController;
  Timer? animationTimer;
  int currentIndex = 0;
  bool isDelivering = false;

  // Delivery route through Paris streets, lng/lat order.
  final List<Position> deliveryRoute = [
    Position(2.2950, 48.8738),  // Arc de Triomphe (pickup)
    Position(2.3050, 48.8700),
    Position(2.3150, 48.8650),
    Position(2.3250, 48.8620),
    Position(2.3350, 48.8600),
    Position(2.3400, 48.8580),
    Position(2.3522, 48.8566),  // City center
    Position(2.3550, 48.8550),
    Position(2.3499, 48.8530),  // Notre-Dame (delivery)
  ];

  String get statusText {
    final progress = currentIndex / (deliveryRoute.length - 1);
    if (progress == 0) return 'Ready for pickup';
    if (progress < 0.3) return 'Picked up — on the way';
    if (progress < 0.7) return 'In transit';
    if (progress < 1.0) return 'Almost there!';
    return 'Delivered!';
  }

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

    await style.addSource(
      GeoJsonSource(id: _routeSourceId, data: jsonEncode(_routeGeoJson())),
    );
    await style.addLayer(
      const LineStyleLayer(
        id: 'delivery-route-line',
        sourceId: _routeSourceId,
        paint: {'line-color': '#1E88E5', 'line-width': 4},
      ),
    );

    // Pickup (green) and delivery (red) endpoints, drawn from feature
    // properties so a single layer can style both.
    await style.addSource(
      GeoJsonSource(id: _stopsSourceId, data: jsonEncode(_stopsGeoJson())),
    );
    await style.addLayer(
      const CircleStyleLayer(
        id: 'delivery-stops-layer',
        sourceId: _stopsSourceId,
        paint: {
          'circle-radius': 8,
          'circle-color': [
            'match',
            ['get', 'kind'],
            'pickup', '#2E7D32',
            'delivery', '#C62828',
            '#757575',
          ],
          'circle-stroke-color': '#FFFFFF',
          'circle-stroke-width': 2,
        },
      ),
    );

    await style.addSource(
      GeoJsonSource(
        id: _driverSourceId,
        data: jsonEncode(_driverGeoJson(deliveryRoute[currentIndex])),
      ),
    );
    await style.addLayer(
      const CircleStyleLayer(
        id: 'delivery-driver-layer',
        sourceId: _driverSourceId,
        paint: {
          'circle-radius': 7,
          'circle-color': '#1565C0',
          'circle-stroke-color': '#FFFFFF',
          'circle-stroke-width': 2,
        },
      ),
    );
  }

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

  Map<String, Object?> _stopsGeoJson() => {
    'type': 'FeatureCollection',
    'features': [
      {
        'type': 'Feature',
        'properties': {'kind': 'pickup'},
        'geometry': {
          'type': 'Point',
          'coordinates': [deliveryRoute.first.lng, deliveryRoute.first.lat],
        },
      },
      {
        'type': 'Feature',
        'properties': {'kind': 'delivery'},
        'geometry': {
          'type': 'Point',
          'coordinates': [deliveryRoute.last.lng, deliveryRoute.last.lat],
        },
      },
    ],
  };

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

  @override
  Widget build(BuildContext context) {
    final progress = currentIndex / (deliveryRoute.length - 1);

    return Scaffold(
      appBar: AppBar(title: Text('Delivery Tracker')),
      body: Column(
        children: [
          // Status bar
          Container(
            padding: EdgeInsets.all(16),
            color: Colors.blue[50],
            child: Column(
              children: [
                Text(statusText,
                    style:
                        TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
                SizedBox(height: 8),
                LinearProgressIndicator(
                  value: progress,
                  backgroundColor: Colors.grey[300],
                  valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),
                ),
              ],
            ),
          ),
          // Map
          Expanded(
            child: MapMetricsView(
              options: MapOptions(
                initCenter: Position(2.3250, 48.8620),
                initZoom: 14.0,
                initStyle:
                    'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
              ),
              onMapCreated: (MapController controller) {
                mapController = controller;
              },
              onStyleLoaded: _onStyleLoaded,
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: isDelivering ? null : _startDelivery,
        backgroundColor: isDelivering ? Colors.grey : Colors.blue,
        child: Icon(isDelivering ? Icons.local_shipping : Icons.play_arrow),
      ),
    );
  }

  void _startDelivery() {
    setState(() {
      isDelivering = true;
      currentIndex = 0;
    });

    animationTimer = Timer.periodic(Duration(milliseconds: 500), (timer) {
      if (currentIndex >= deliveryRoute.length - 1) {
        timer.cancel();
        setState(() {
          isDelivering = false;
        });
        return;
      }

      setState(() {
        currentIndex++;
      });

      styleController?.updateGeoJsonSource(
        id: _driverSourceId,
        data: jsonEncode(_driverGeoJson(deliveryRoute[currentIndex])),
      );
    });
  }

  @override
  void dispose() {
    animationTimer?.cancel();
    super.dispose();
  }
}

Animation Tips

ApproachSpeedSmoothnessBest For
Timer.periodic 50msFastVery smoothVisual demos
Timer.periodic 200msMediumSmoothTracking UIs
Timer.periodic 500msSlowStep-by-stepDelivery tracking
Interpolate waypointsExtra smoothLong routes with few waypoints

Next Steps


Tip: For production tracking apps, receive real GPS coordinates from a backend and call StyleController.updateGeoJsonSource with the new point — the same pattern works with live data from a WebSocket or polling API, there's no setState-driven Marker.position to update.