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:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
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.
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:
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:
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:
// 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
});| Curve | Effect |
|---|---|
Curves.linear | Constant speed (default) |
Curves.easeInOut | Slow start and end, fast middle |
Curves.easeIn | Slow start, fast end |
Curves.easeOut | Fast start, slow end |
Curves.bounceOut | Bounce effect at the end |
Next Steps
- Animate Camera Around Point — Orbiting camera animation
- Fly to a Location — Animated camera transitions
- Markers and Annotations — Static marker features
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.