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:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
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:
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:
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
| Approach | Speed | Smoothness | Best For |
|---|---|---|---|
Timer.periodic 50ms | Fast | Very smooth | Visual demos |
Timer.periodic 200ms | Medium | Smooth | Tracking UIs |
Timer.periodic 500ms | Slow | Step-by-step | Delivery tracking |
| Interpolate waypoints | — | Extra smooth | Long routes with few waypoints |
Next Steps
- Animate a Marker — Bounce and pulse animations
- Animate a Line — Draw a line progressively
- Fly to a Location — Smooth camera transitions
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.