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 Line in Flutter

This tutorial shows how to animate a line being drawn on the map step by step, as if tracing a route in real time.

Prerequisites

Before you begin, ensure you have:

There is no Polyline/Marker(markerId:...)/polylines: set on the map widget in this SDK. A line is a GeoJsonSource + LineStyleLayer, and animating it means calling StyleController.updateGeoJsonSource with a new set of coordinates on every animation tick — exactly the pattern used in the SDK's own example/lib/animation_page.dart. Note MapLibre Native requires a LineString to have at least 2 points, so the animated line always starts as a zero-length 2-point line rather than a 1-point line.

Basic Line Animation

Draw a route one segment at a time using an AnimationController, feeding the growing coordinate list into updateGeoJsonSource on every tick. Start/end/current markers are real Flutter widgets rendered through WidgetLayer in mapChildren:

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

class AnimateLineScreen extends StatefulWidget {
  @override
  _AnimateLineScreenState createState() => _AnimateLineScreenState();
}

class _AnimateLineScreenState extends State<AnimateLineScreen>
    with SingleTickerProviderStateMixin {
  MapController? mapController;
  StyleController? styleController;
  late AnimationController _animationController;
  bool isAnimating = false;

  static const _fullRouteSourceId = 'full-route';
  static const _animatedRouteSourceId = 'animated-route';

  // Full route coordinates (Paris walking route), Position(lng, lat)
  final List<Position> fullRoute = [
    Position(2.2945, 48.8584), // Eiffel Tower
    Position(2.3050, 48.8575),
    Position(2.3150, 48.8560),
    Position(2.3250, 48.8580),
    Position(2.3376, 48.8606), // Louvre
    Position(2.3420, 48.8590),
    Position(2.3460, 48.8560),
    Position(2.3499, 48.8530), // Notre-Dame
  ];

  List<Position> get visibleRoute {
    final progress = _animationController.value;
    final totalPoints = fullRoute.length;
    final visibleCount =
        (progress * totalPoints).ceil().clamp(1, totalPoints);
    final visible = fullRoute.sublist(0, visibleCount);
    // MapLibre Native requires a LineString to have at least 2 points.
    return visible.length >= 2 ? visible : [visible.first, visible.first];
  }

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

  List<Marker> get markers => [
    // Start marker
    Marker(
      point: fullRoute.first,
      size: const Size(28, 28),
      child: const Icon(Icons.trip_origin, color: Colors.green, size: 28),
    ),
    // End marker (only show when animation is complete)
    if (_animationController.value >= 1.0)
      Marker(
        point: fullRoute.last,
        size: const Size(28, 28),
        child: const Icon(Icons.flag, color: Colors.red, size: 28),
      ),
    // Current position marker
    if (visibleRoute.length >= 2 && _animationController.value < 1.0)
      Marker(
        point: visibleRoute.last,
        size: const Size(20, 20),
        child: const Icon(Icons.circle, color: Colors.blue, size: 20),
      ),
  ];

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: Duration(seconds: 5),
    );

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

  Future<void> _setupLayers(StyleController style) async {
    // Faded full route (background)
    await style.addSource(
      GeoJsonSource(
        id: _fullRouteSourceId,
        data: jsonEncode(_lineStringFeature(fullRoute)),
      ),
    );
    await style.addLayer(
      const LineStyleLayer(
        id: 'full-route-layer',
        sourceId: _fullRouteSourceId,
        layout: {'line-cap': 'round', 'line-join': 'round'},
        paint: {
          'line-color': '#3b82f6',
          'line-opacity': 0.25,
          'line-width': 3.0,
          'line-dasharray': [2.0, 1.5],
        },
      ),
    );

    // Animated route (foreground) — starts as a zero-length line
    await style.addSource(
      GeoJsonSource(
        id: _animatedRouteSourceId,
        data: jsonEncode(_lineStringFeature([fullRoute.first, fullRoute.first])),
      ),
    );
    await style.addLayer(
      const LineStyleLayer(
        id: 'animated-route-layer',
        sourceId: _animatedRouteSourceId,
        layout: {'line-cap': 'round', 'line-join': 'round'},
        paint: {'line-color': '#3b82f6', 'line-width': 4.0},
      ),
    );
  }

  Future<void> _updateAnimatedRoute() async {
    final style = styleController;
    if (style == null) return;
    await style.updateGeoJsonSource(
      id: _animatedRouteSourceId,
      data: jsonEncode(_lineStringFeature(visibleRoute)),
    );
  }

  @override
  Widget build(BuildContext context) {
    final progress = (_animationController.value * 100).toInt();

    return Scaffold(
      appBar: AppBar(title: Text('Animate a Line')),
      body: Column(
        children: [
          // Progress bar
          Container(
            padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            color: Colors.grey[100],
            child: Row(
              children: [
                Text('Progress: $progress%'),
                SizedBox(width: 12),
                Expanded(
                  child: LinearProgressIndicator(
                    value: _animationController.value,
                    backgroundColor: Colors.grey[300],
                  ),
                ),
              ],
            ),
          ),
          // Map
          Expanded(
            child: MapMetricsView(
              options: MapOptions(
                initCenter: Position(2.3200, 48.8570), // lng, lat
                initZoom: 14.0,
                initStyle:
                    'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
              ),
              onMapCreated: (controller) => mapController = controller,
              onStyleLoaded: (style) async {
                styleController = style;
                await _setupLayers(style);
              },
              mapChildren: [WidgetLayer(markers: markers)],
            ),
          ),
        ],
      ),
      floatingActionButton: Row(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton.small(
            heroTag: 'reset',
            onPressed: _reset,
            child: Icon(Icons.replay),
          ),
          SizedBox(width: 8),
          FloatingActionButton.extended(
            heroTag: 'play',
            onPressed: isAnimating ? _pause : _play,
            icon: Icon(isAnimating ? Icons.pause : Icons.play_arrow),
            label: Text(isAnimating ? 'Pause' : 'Draw'),
          ),
        ],
      ),
    );
  }

  void _play() {
    setState(() => isAnimating = true);
    if (_animationController.value >= 1.0) {
      _animationController.reset();
    }
    _animationController.forward();
  }

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

  void _reset() {
    _animationController.reset();
    setState(() => isAnimating = false);
  }

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

Smooth Interpolated Animation

For smoother line drawing, interpolate between points. Position exposes .lng/.lat getters, and the constructor is Position(lng, lat):

dart
List<Position> get smoothRoute {
  final progress = _animationController.value;
  final totalSegments = fullRoute.length - 1;
  final exactPosition = progress * totalSegments;
  final segmentIndex = exactPosition.floor().clamp(0, totalSegments - 1);
  final t = exactPosition - segmentIndex;

  // All completed segments plus interpolated current segment
  final result = fullRoute.sublist(0, segmentIndex + 1);
  if (segmentIndex < totalSegments) {
    final from = fullRoute[segmentIndex];
    final to = fullRoute[segmentIndex + 1];
    result.add(Position(
      from.lng + (to.lng - from.lng) * t,
      from.lat + (to.lat - from.lat) * t,
    ));
  }
  return result;
}

Swap visibleRoute for smoothRoute inside _updateAnimatedRoute to use it.

Next Steps


Tip: Show a faded version of the full route as a background layer so users can see where the line is heading, while the solid animated line shows progress. Because updateGeoJsonSource re-sends the whole feature on every tick, keep the point count reasonable (a few hundred, not tens of thousands) or throttle updates for very dense routes.