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

Draw a Circle in Flutter

This tutorial shows how to draw circles on your MapMetrics Flutter map. Circles are useful for showing a radius around a point — like a search area, delivery zone, or coverage range.

Prerequisites

Before you begin, ensure you have:

CircleLayer radius is in screen pixels, not meters. Unlike a geographic circle that represents a fixed real-world distance and grows/shrinks on screen as you zoom, CircleLayer.radius is a constant pixel size — it stays the same visual size on screen at every zoom level. To draw a circle that represents an actual radius in meters (e.g. "2 km delivery zone"), convert meters to pixels with MapController.getMetersPerPixelAtLatitude(lat) and recompute whenever the camera zoom changes.

Basic Circle

Draw a circle whose on-screen size tracks a real 2 km radius, recomputing the pixel radius whenever the camera moves:

dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class CircleExampleScreen extends StatefulWidget {
  @override
  _CircleExampleScreenState createState() => _CircleExampleScreenState();
}

class _CircleExampleScreenState extends State<CircleExampleScreen> {
  MapController? mapController;

  static const _center = Position(2.3522, 48.8566);
  static const _radiusMeters = 2000.0; // 2 km radius
  double _radiusPixels = 40;

  Future<void> _updateRadiusPixels() async {
    final controller = mapController;
    if (controller == null) return;
    final metersPerPixel =
        await controller.getMetersPerPixelAtLatitude(_center.lat);
    if (!mounted) return;
    setState(() {
      _radiusPixels = _radiusMeters / metersPerPixel;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Circle Example')),
      body: MapMetricsView(
        options: MapOptions(
          initCenter: _center,
          initZoom: 13.0,
          initStyle:
              'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
        ),
        onMapCreated: (MapController controller) async {
          mapController = controller;
          await _updateRadiusPixels();
        },
        onEvent: (event) {
          if (event is MapEventMoveCamera) {
            _updateRadiusPixels();
          }
        },
        layers: [
          CircleLayer(
            points: [Point(coordinates: _center)],
            radius: _radiusPixels.round(),
            strokeWidth: 2,
            strokeColor: Colors.blue,
            color: Colors.blue.withValues(alpha: 0.15),
          ),
        ],
      ),
    );
  }
}

Multiple Circles with Different Radii

Show concentric circles for multiple zones — since each ring has its own fixed pixel radius, each is its own CircleLayer, and each is converted from meters independently:

dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class MultipleCirclesScreen extends StatefulWidget {
  @override
  _MultipleCirclesScreenState createState() => _MultipleCirclesScreenState();
}

class _MultipleCirclesScreenState extends State<MultipleCirclesScreen> {
  MapController? mapController;

  static const _center = Position(-74.0060, 40.7128);
  static const _zonesMeters = [1000.0, 3000.0, 5000.0]; // inner, middle, outer
  List<double> _zonesPixels = [20, 60, 100];

  Future<void> _updateZonePixels() async {
    final controller = mapController;
    if (controller == null) return;
    final metersPerPixel =
        await controller.getMetersPerPixelAtLatitude(_center.lat);
    if (!mounted) return;
    setState(() {
      _zonesPixels =
          _zonesMeters.map((m) => m / metersPerPixel).toList();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Coverage Zones')),
      body: MapMetricsView(
        options: MapOptions(
          initCenter: _center,
          initZoom: 12.0,
          initStyle:
              'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
        ),
        onMapCreated: (controller) async {
          mapController = controller;
          await _updateZonePixels();
        },
        onEvent: (event) {
          if (event is MapEventMoveCamera) {
            _updateZonePixels();
          }
        },
        layers: [
          // Outer zone - 5 km (drawn first so it sits behind the others)
          CircleLayer(
            points: [Point(coordinates: _center)],
            radius: _zonesPixels[2].round(),
            strokeWidth: 2,
            strokeColor: Colors.red,
            color: Colors.red.withValues(alpha: 0.05),
          ),
          // Middle zone - 3 km
          CircleLayer(
            points: [Point(coordinates: _center)],
            radius: _zonesPixels[1].round(),
            strokeWidth: 2,
            strokeColor: Colors.orange,
            color: Colors.orange.withValues(alpha: 0.1),
          ),
          // Inner zone - 1 km
          CircleLayer(
            points: [Point(coordinates: _center)],
            radius: _zonesPixels[0].round(),
            strokeWidth: 2,
            strokeColor: Colors.green,
            color: Colors.green.withValues(alpha: 0.2),
          ),
          // Center label
          MarkerLayer(
            points: [Point(coordinates: _center)],
            textField: '1 km / 3 km / 5 km zones',
            textOffset: const [0, 1],
          ),
        ],
      ),
    );
  }
}

Dynamic Circle: Tap to Place

Let users tap the map to place a circle at any location. Because a CircleLayer applies one radius to every point it contains, all circles placed with this screen share the current slider value — moving the slider resizes every circle already on the map, not just the next one you place:

dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class DynamicCircleScreen extends StatefulWidget {
  @override
  _DynamicCircleScreenState createState() => _DynamicCircleScreenState();
}

class _DynamicCircleScreenState extends State<DynamicCircleScreen> {
  MapController? mapController;
  List<Point> circlePoints = [];
  double radiusInMeters = 1000;
  double _radiusPixels = 20;

  Future<void> _updateRadiusPixels() async {
    final controller = mapController;
    if (controller == null || circlePoints.isEmpty) return;
    final lat = circlePoints.last.coordinates.lat;
    final metersPerPixel = await controller.getMetersPerPixelAtLatitude(lat);
    if (!mounted) return;
    setState(() {
      _radiusPixels = radiusInMeters / metersPerPixel;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Tap to Draw Circle')),
      body: Column(
        children: [
          // Radius slider
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            child: Row(
              children: [
                Text('Radius: ${radiusInMeters.toInt()} m'),
                Expanded(
                  child: Slider(
                    value: radiusInMeters,
                    min: 200,
                    max: 5000,
                    divisions: 24,
                    onChanged: (value) {
                      setState(() {
                        radiusInMeters = value;
                      });
                      _updateRadiusPixels();
                    },
                  ),
                ),
              ],
            ),
          ),
          // Map
          Expanded(
            child: MapMetricsView(
              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',
              ),
              onMapCreated: (controller) => mapController = controller,
              onEvent: (event) {
                if (event is MapEventClick) {
                  setState(() {
                    circlePoints.add(Point(coordinates: event.point));
                  });
                  _updateRadiusPixels();
                }
              },
              layers: [
                CircleLayer(
                  points: circlePoints,
                  radius: _radiusPixels.round(),
                  strokeWidth: 2,
                  strokeColor: Colors.blue,
                  color: Colors.blue.withValues(alpha: 0.15),
                ),
              ],
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          setState(() {
            circlePoints.clear();
          });
        },
        child: Icon(Icons.clear_all),
      ),
    );
  }
}

Circle Layer Properties

PropertyTypeDescription
pointsList<Point>Centers of every circle drawn by this layer (they all share the properties below)
radiusintRadius in screen pixels — constant size on screen regardless of zoom
strokeWidthintBorder width in pixels
strokeColorColorBorder color
colorColorFill color (use Color.withValues(alpha: ...) for transparency)
blurdoubleBlur amount; 1 blurs so only the centerpoint stays fully opaque

To make a circle represent a fixed geographic radius (in meters) instead of a fixed pixel radius, convert with MapController.getMetersPerPixelAtLatitude(lat) and recompute on every MapEventMoveCamera event, as shown above.

Next Steps


Tip: Debounce _updateRadiusPixels() calls on MapEventMoveCamera (it fires continuously during a pan/zoom gesture) if you have many circles or a heavy computation — recompute at the end of a gesture rather than on every frame.