Animated live-tracking marker
Smoothly glide and rotate a courier marker between GPS updates using a tween with shortest-path bearing interpolation.
FlutterAnimationfluttertrackinganimationmarker
Code
import 'dart:math' as math;
import 'package:flutter/material.dart';
class GeoPoint {
const GeoPoint(this.lat, this.lng);
final double lat, lng;
/// Initial compass bearing in degrees from this point to [o].
double bearingTo(GeoPoint o) {
final p1 = lat * math.pi / 180, p2 = o.lat * math.pi / 180;
final dl = (o.lng - lng) * math.pi / 180;
final y = math.sin(dl) * math.cos(p2);
final x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl);
return (math.atan2(y, x) * 180 / math.pi + 360) % 360;
}
}
class _Pose {
const _Pose(this.point, this.bearing);
final GeoPoint point;
final double bearing;
}
class _PoseTween extends Tween<_Pose> {
_PoseTween({required _Pose begin, required _Pose end}) : super(begin: begin, end: end);
@override
_Pose lerp(double t) {
final a = begin!, b = end!;
// Rotate along the shortest arc (350 -> 10 turns 20 degrees, not 340).
final diff = ((b.bearing - a.bearing + 540) % 360) - 180;
return _Pose(
GeoPoint(a.point.lat + (b.point.lat - a.point.lat) * t,
a.point.lng + (b.point.lng - a.point.lng) * t),
a.bearing + diff * t,
);
}
}
/// Animates between successive positions; [project] maps a point to pixels.
class TrackingMarker extends StatefulWidget {
const TrackingMarker({super.key, required this.position, required this.project, this.size = 36});
final GeoPoint position;
final Offset Function(GeoPoint) project;
final double size;
@override
State<TrackingMarker> createState() => _TrackingMarkerState();
}
class _TrackingMarkerState extends State<TrackingMarker> {
late _Pose _from = _Pose(widget.position, 0);
late _Pose _to = _from;
@override
void didUpdateWidget(TrackingMarker old) {
super.didUpdateWidget(old);
if (old.position.lat == widget.position.lat && old.position.lng == widget.position.lng) return;
_from = _to; // start from the last target; TweenAnimationBuilder eases the rest
_to = _Pose(widget.position, old.position.bearingTo(widget.position));
}
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<_Pose>(
key: const ValueKey('marker'),
tween: _PoseTween(begin: _from, end: _to),
duration: const Duration(seconds: 2), // roughly the GPS update interval
curve: Curves.linear,
builder: (context, pose, _) {
final o = widget.project(pose.point);
return Positioned(
left: o.dx - widget.size / 2,
top: o.dy - widget.size / 2,
child: Transform.rotate(
angle: pose.bearing * math.pi / 180,
child: Icon(Icons.navigation, size: widget.size),
),
);
},
);
}
}
How it works
What it solves
Live tracking feeds arrive every few seconds. Moving a marker straight to each new coordinate looks like teleporting, and naive rotation spins the long way round when the heading crosses north.
How it works
TrackingMarker receives the newest position as a plain widget property. In didUpdateWidget it records the previous target as the start and the new coordinates as the end, computing the heading with the standard initial-bearing formula. A custom Tween interpolates latitude, longitude and bearing together, and TweenAnimationBuilder runs it for roughly one GPS interval with a linear curve so speed feels constant.
The bearing difference is normalised into the range minus 180 to 180, so rotating from 350 to 10 degrees turns 20 degrees rather than 340.
Adapting it
The project callback converts coordinates to screen pixels; with a map package use its camera projection, or plug a simple linear mapping for a custom canvas. Place the widget inside a Stack. Replace the icon with an image of a vehicle and set the duration from the real gap between updates.
Pitfalls
- The widget returns
Positioned, so it must be a direct child of aStack. - A very long gap between updates makes the glide look slow; clamp the duration.
- Skip tiny movements (under a metre) before updating, otherwise GPS jitter makes the heading flicker.
How to use it
Copy the file to lib/widgets/ (no packages) and place the marker in a Stack.
Stack(children: [
mapView,
TrackingMarker(position: courierPos, project: (p) => camera.toScreen(p)),
])
Call setState with each new GeoPoint from your location stream.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.