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:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
There is no
Polyline/Marker(markerId:...)/polylines:set on the map widget in this SDK. A line is aGeoJsonSource+LineStyleLayer, and animating it means callingStyleController.updateGeoJsonSourcewith a new set of coordinates on every animation tick — exactly the pattern used in the SDK's ownexample/lib/animation_page.dart. Note MapLibre Native requires aLineStringto 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:
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):
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
- Animate a Marker — Move a marker along a route
- Add a GeoJSON Line — Static line styling
- Fly to a Location — Animate the camera along with the line
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.