DEV3 Technologies home

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 a Stack.
  • 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.

Related snippets