Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

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:

How this is built: draggable points are widget-based Markers inside a WidgetLayer (see Draggable Marker for the drag mechanics — GestureDetector.onPanUpdate + MapController.toLngLat). The trail, measurement line, or polygon shape they draw is a separate PolylineLayer/PolygonLayer passed via layers:, 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:

dart
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:

dart
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:

dart
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

PieceRole
GestureDetector.onPanStartBegin drag — good place to disable the map's pan gesture
GestureDetector.onPanUpdateMarker position updates during drag — recompute derived layers here for live feedback
GestureDetector.onPanEndUser releases the marker — good place to re-enable pan and commit final state

Next Steps


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.