Create a Draggable Point in Flutter
This tutorial shows how to create a draggable point that draws a line or shape as you drag it — useful for route planning, area selection, or interactive drawing tools.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
How this is built: draggable points are widget-based
Markers inside aWidgetLayer(see Draggable Marker for the drag mechanics —GestureDetector.onPanUpdate+MapController.toLngLat). The trail, measurement line, or polygon shape they draw is a separatePolylineLayer/PolygonLayerpassed vialayers:, rebuilt from the current point positions on every drag update.
Draggable Point with Trail
Drag a point on the map and leave a dashed trail line behind:
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class DraggablePointTrailScreen extends StatefulWidget {
@override
_DraggablePointTrailScreenState createState() =>
_DraggablePointTrailScreenState();
}
class _DraggablePointTrailScreenState
extends State<DraggablePointTrailScreen> {
late final MapController mapController;
final _mapKey = GlobalKey();
Position currentPosition = Position(2.3522, 48.8566);
List<Position> trail = [Position(2.3522, 48.8566)];
MapGestures _gestures = const MapGestures.all();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Draggable Point with Trail'),
actions: [
IconButton(
icon: Icon(Icons.clear),
tooltip: 'Clear trail',
onPressed: () {
setState(() {
trail = [currentPosition];
});
},
),
],
),
body: MapMetricsView(
key: _mapKey,
options: MapOptions(
initCenter: Position(2.3522, 48.8566),
initZoom: 13.0,
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
gestures: _gestures,
),
onMapCreated: (MapController controller) {
mapController = controller;
},
layers: [
if (trail.length > 1)
PolylineLayer(
polylines: [LineString(coordinates: trail)],
color: Colors.blue,
width: 3,
dashArray: const [10, 5],
),
],
mapChildren: [
WidgetLayer(
allowInteraction: true,
markers: [
Marker(
point: currentPosition,
size: const Size.square(40),
alignment: Alignment.bottomCenter,
child: GestureDetector(
onPanStart: (_) {
setState(() => _gestures = const MapGestures.all(pan: false));
},
onPanUpdate: (details) async {
final newPosition = await _toLngLat(details.globalPosition);
setState(() => currentPosition = newPosition);
},
onPanEnd: (_) {
setState(() {
trail.add(currentPosition);
_gestures = const MapGestures.all();
});
},
child: const Icon(Icons.location_on, color: Colors.blue, size: 40),
),
),
],
),
],
),
);
}
Future<Position> _toLngLat(Offset eventOffset) async {
final pixelRatio = (!kIsWeb && Platform.isAndroid)
? MediaQuery.devicePixelRatioOf(context)
: 1.0;
final mapRenderBox = _mapKey.currentContext?.findRenderObject() as RenderBox?;
final mapOffset = mapRenderBox!.localToGlobal(Offset.zero);
final offset = Offset(
eventOffset.dx - mapOffset.dx,
eventOffset.dy - mapOffset.dy,
);
return mapController.toLngLat(offset.scale(pixelRatio, pixelRatio));
}
}Two-Point Distance Measurer
Drag two points to measure the distance between them:
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class TwoPointDragScreen extends StatefulWidget {
@override
_TwoPointDragScreenState createState() => _TwoPointDragScreenState();
}
class _TwoPointDragScreenState extends State<TwoPointDragScreen> {
late final MapController mapController;
final _mapKey = GlobalKey();
Position pointA = Position(2.2945, 48.8584); // Eiffel Tower
Position pointB = Position(2.3376, 48.8606); // Louvre
MapGestures _gestures = const MapGestures.all();
/// Haversine distance in km
double _distanceKm(Position a, Position b) {
const R = 6371.0;
final dLat = _toRad(b.lat - a.lat);
final dLon = _toRad(b.lng - a.lng);
final sinLat = sin(dLat / 2);
final sinLon = sin(dLon / 2);
final h = sinLat * sinLat +
cos(_toRad(a.lat)) * cos(_toRad(b.lat)) * sinLon * sinLon;
return R * 2 * atan2(sqrt(h), sqrt(1 - h));
}
double _toRad(double deg) => deg * pi / 180;
@override
Widget build(BuildContext context) {
final distance = _distanceKm(pointA, pointB);
return Scaffold(
appBar: AppBar(title: Text('Distance Measurer')),
body: Stack(
children: [
MapMetricsView(
key: _mapKey,
options: MapOptions(
initCenter: Position(2.3160, 48.8590),
initZoom: 14.0,
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
gestures: _gestures,
),
onMapCreated: (MapController controller) {
mapController = controller;
},
layers: [
PolylineLayer(
polylines: [
LineString(coordinates: [pointA, pointB]),
],
color: Colors.orange,
width: 3,
),
],
mapChildren: [
WidgetLayer(
allowInteraction: true,
markers: [
_dragMarker(
point: pointA,
color: Colors.green,
onMoved: (p) => setState(() => pointA = p),
),
_dragMarker(
point: pointB,
color: Colors.red,
onMoved: (p) => setState(() => pointB = p),
),
],
),
],
),
// Distance display
Positioned(
top: 16,
left: 16,
right: 16,
child: Card(
elevation: 4,
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${distance.toStringAsFixed(2)} km',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
Text(
'(${(distance * 1000).toStringAsFixed(0)} meters)',
style: TextStyle(color: Colors.grey[600]),
),
SizedBox(height: 4),
Text(
'Drag the markers to measure',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
),
),
],
),
);
}
Marker _dragMarker({
required Position point,
required Color color,
required ValueChanged<Position> onMoved,
}) {
return Marker(
point: point,
size: const Size.square(36),
alignment: Alignment.bottomCenter,
child: GestureDetector(
onPanStart: (_) {
setState(() => _gestures = const MapGestures.all(pan: false));
},
onPanUpdate: (details) async {
onMoved(await _toLngLat(details.globalPosition));
},
onPanEnd: (_) {
setState(() => _gestures = const MapGestures.all());
},
child: Icon(Icons.location_on, color: color, size: 36),
),
);
}
Future<Position> _toLngLat(Offset eventOffset) async {
final pixelRatio = (!kIsWeb && Platform.isAndroid)
? MediaQuery.devicePixelRatioOf(context)
: 1.0;
final mapRenderBox = _mapKey.currentContext?.findRenderObject() as RenderBox?;
final mapOffset = mapRenderBox!.localToGlobal(Offset.zero);
final offset = Offset(
eventOffset.dx - mapOffset.dx,
eventOffset.dy - mapOffset.dy,
);
return mapController.toLngLat(offset.scale(pixelRatio, pixelRatio));
}
}Polygon Drawing with Draggable Vertices
Create a polygon from a fixed set of points, then adjust vertices by dragging:
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class DraggablePolygonScreen extends StatefulWidget {
@override
_DraggablePolygonScreenState createState() =>
_DraggablePolygonScreenState();
}
class _DraggablePolygonScreenState extends State<DraggablePolygonScreen> {
late final MapController mapController;
final _mapKey = GlobalKey();
List<Position> vertices = [
Position(2.330, 48.860),
Position(2.360, 48.860),
Position(2.360, 48.845),
Position(2.330, 48.845),
];
MapGestures _gestures = const MapGestures.all();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Draggable Polygon')),
body: MapMetricsView(
key: _mapKey,
options: MapOptions(
initCenter: Position(2.345, 48.852),
initZoom: 14.0,
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
gestures: _gestures,
),
onMapCreated: (MapController controller) {
mapController = controller;
},
layers: [
// Polygon shape — closing the ring by repeating the first vertex
PolygonLayer(
polygons: [
Polygon(coordinates: [[...vertices, vertices.first]]),
],
color: Colors.blue.withValues(alpha: 0.2),
outlineColor: Colors.blue,
),
],
mapChildren: [
// Draggable vertex markers
WidgetLayer(
allowInteraction: true,
markers: List.generate(vertices.length, (i) {
return Marker(
point: vertices[i],
size: const Size.square(32),
alignment: Alignment.center,
child: GestureDetector(
onPanStart: (_) {
setState(() => _gestures = const MapGestures.all(pan: false));
},
onPanUpdate: (details) async {
final newPos = await _toLngLat(details.globalPosition);
setState(() => vertices[i] = newPos);
},
onPanEnd: (_) {
setState(() => _gestures = const MapGestures.all());
},
child: const Icon(Icons.circle, color: Colors.blue, size: 20),
),
);
}),
),
],
),
);
}
Future<Position> _toLngLat(Offset eventOffset) async {
final pixelRatio = (!kIsWeb && Platform.isAndroid)
? MediaQuery.devicePixelRatioOf(context)
: 1.0;
final mapRenderBox = _mapKey.currentContext?.findRenderObject() as RenderBox?;
final mapOffset = mapRenderBox!.localToGlobal(Offset.zero);
final offset = Offset(
eventOffset.dx - mapOffset.dx,
eventOffset.dy - mapOffset.dy,
);
return mapController.toLngLat(offset.scale(pixelRatio, pixelRatio));
}
}Drag Handling Building Blocks
| Piece | Role |
|---|---|
GestureDetector.onPanStart | Begin drag — good place to disable the map's pan gesture |
GestureDetector.onPanUpdate | Marker position updates during drag — recompute derived layers here for live feedback |
GestureDetector.onPanEnd | User releases the marker — good place to re-enable pan and commit final state |
Next Steps
- Draggable Marker — Basic draggable marker
- Measure Distances — Full measurement tool
- Get Coordinates on Tap — Tap for coordinates
Tip: Use onPanUpdate for live updates during dragging (like showing a live distance measurement) and onPanEnd for final actions (like saving the position or re-enabling map panning). Be mindful that onPanUpdate fires very frequently — debounce expensive operations.