iosfluttermapbox

How to add circle annotation to Flutter mapbox


I'm making a Flutter page and I want to have a Mapbox widget that shows a highlighted circle around the user's location. And there's a slider outside the map that can adjust the radius of the circle. The circle size should also be relative to the zoom level, so if I zoom all the way out, the size doesn't become really large.

Right now, my code works for adjusting the radius, but the map flashes every time the slider moves. The zoom is also hard to use, because the zoom level is reset every time I move the slider.

I also get an error message every time I move the slider

[ERROR:flutter/runtime/dart_vm_initializer.cc(40)] Unhandled Exception: PlatformException(channel-error, Unable to establish connection on channel: "dev.flutter.pigeon.mapbox_maps_flutter.ScaleBarSettingsInterface.updateSettings.2"., null, null)
#0      ScaleBarSettingsInterface.updateSettings (package:mapbox_maps_flutter/src/pigeons/settings.dart:1138:7)
<asynchronous suspension>
#1      _AreaCaptureScreenState._onMapCreated (package:sighttrack/screens/capture/area_capture.dart:37:5)
<asynchronous suspension>
flutter: Error updating circle: PlatformException(channel-error, Unable to establish connection on channel: "dev.flutter.pigeon.mapbox_maps_flutter._CameraManager.getCameraState.2"., null, null)
flutter: Error updating circle: PlatformException(channel-error, Unable to establish connection on channel: "dev.flutter.pigeon.mapbox_maps_flutter._CameraManager.getCameraState.2"., null, null)
flutter: Error updating circle: Null check operator used on a null value
flutter: Error updating circle: Null check operator used on a null value
flutter: Error updating circle: Null check operator used on a null value
flutter: Error updating circle: PlatformException(channel-error, Unable to establish connection on channel: "dev.flutter.pigeon.mapbox_maps_flutter._CircleAnnotationMessenger.deleteAll.1"., null, null)
flutter: Error updating circle: PlatformException(channel-error, Unable to establish connection on channel: "dev.flutter.pigeon.mapbox_maps_flutter._CircleAnnotationMessenger.deleteAll.1"., null, null)
flutter: Error updating circle: Null check operator used on a null value
flutter: Error updating circle: PlatformException(channel-error, Unable to establish connection on channel: "dev.flutter.pigeon.mapbox_maps_flutter._CameraManager.getCameraState.2"., null, null)
flutter: Error updating circle: Null check operator used on a null value
flutter: Error updating circle: PlatformException(channel-error, Unable to establish connection on channel: "dev.flutter.pigeon.mapbox_maps_flutter._CircleAnnotationMessenger.deleteAll.1"., null, null)
flutter: Error updating circle: PlatformException(channel-error, Unable to establish connection on channel: "dev.flutter.pigeon.mapbox_maps_flutter._CircleAnnotationMessenger.deleteAll.1"., null, null)
flutter: Error updating circle: Null check operator used on a null value
flutter: Error updating circle: Null check operator used on a null value

Here is the page code (API keys are set up in main.dart)

import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
import 'package:geolocator/geolocator.dart' as geo;

class AreaCaptureScreen extends StatefulWidget {
  const AreaCaptureScreen({super.key});

  @override
  State<AreaCaptureScreen> createState() => _AreaCaptureScreenState();
}

class _AreaCaptureScreenState extends State<AreaCaptureScreen> {
  MapboxMap? _mapboxMap;
  CircleAnnotationManager? _circleManager;
  CircleAnnotation? _circleAnnotation;
  bool _isDisposed = false;
  Point? _centerPoint;
  double _radiusMeters = 300.0; // Non-final

  @override
  void dispose() {
    _isDisposed = true;
    super.dispose();
  }

  void _onMapCreated(MapboxMap mapboxMap) async {
    if (_isDisposed || !mounted) return;
    _mapboxMap = mapboxMap;

    // Hide UI elements
    await mapboxMap.logo.updateSettings(LogoSettings(enabled: false));
    await mapboxMap.attribution.updateSettings(
      AttributionSettings(enabled: false),
    );
    await mapboxMap.scaleBar.updateSettings(ScaleBarSettings(enabled: false));

    // Enable location puck
    await _mapboxMap!.location.updateSettings(
      LocationComponentSettings(
        enabled: true,
        pulsingEnabled: true,
        puckBearingEnabled: true,
      ),
    );

    try {
      final geo.Position pos = await _determinePosition();
      if (_isDisposed || !mounted) return;

      _centerPoint = Point(coordinates: Position(pos.longitude, pos.latitude));
      await _mapboxMap!.setCamera(
        CameraOptions(center: _centerPoint, zoom: 15.0, bearing: pos.heading),
      );

      // Draw initial circle
      await _updateCircle();
    } catch (e) {
      debugPrint('Error setting up map: $e');
    }
  }

  Future<void> _updateCircle() async {
    if (_mapboxMap == null || _centerPoint == null || _isDisposed || !mounted)
      return;

    try {
      // Initialize manager if not already set
      _circleManager ??=
          await _mapboxMap!.annotations.createCircleAnnotationManager();

      // Calculate pixel radius
      final cameraState = await _mapboxMap!.getCameraState();
      final double groundResolution =
          156543.03392 *
          math.cos(_centerPoint!.coordinates.lat * math.pi / 180) /
          math.pow(2, cameraState.zoom);
      final double pixelRadius = _radiusMeters / groundResolution;

      // Always create a new annotation to avoid ID issues
      await _circleManager!.deleteAll(); // Clear existing annotations
      _circleAnnotation = await _circleManager!.create(
        CircleAnnotationOptions(
          geometry: _centerPoint!,
          circleRadius: pixelRadius,
          circleColor: Color(0xFF33AFFF).toARGB32(),
          circleOpacity: 0.3,
          circleStrokeWidth: 1.5,
          circleStrokeColor: Color(0xFF0077FF).toARGB32(),
        ),
      );
    } catch (e) {
      debugPrint('Error updating circle: $e');
      // Recreate manager on error to handle potential corruption
      _circleManager = null;
      if (mounted && !_isDisposed) {
        _circleManager =
            await _mapboxMap!.annotations.createCircleAnnotationManager();
        await _updateCircle(); // Retry once
      }
    }
  }

  Future<geo.Position> _determinePosition() async {
    bool serviceEnabled = await geo.Geolocator.isLocationServiceEnabled();
    if (!serviceEnabled) throw Exception('Location services are disabled.');

    geo.LocationPermission permission = await geo.Geolocator.checkPermission();
    if (permission == geo.LocationPermission.denied) {
      permission = await geo.Geolocator.requestPermission();
      if (permission == geo.LocationPermission.denied) {
        throw Exception('Location permissions are denied.');
      }
    }
    if (permission == geo.LocationPermission.deniedForever) {
      throw Exception('Location permissions are permanently denied.');
    }

    return await geo.Geolocator.getCurrentPosition();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        foregroundColor: Colors.white,
      ),
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            children: [
              Container(
                height: 300,
                decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(12),
                ),
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(10),
                  child: MapWidget(
                    styleUri:
                        '<REMOVED>',
                    cameraOptions: CameraOptions(
                      center: Point(coordinates: Position(-122.4194, 37.7749)),
                      zoom: 15.0,
                    ),
                    onMapCreated: _onMapCreated,
                    onCameraChangeListener: (event) {
                      if (_circleAnnotation != null) {
                        _updateCircle();
                      }
                    },
                    key: UniqueKey(),
                  ),
                ),
              ),
              Slider(
                value: _radiusMeters,
                min: 100.0,
                max: 1000.0,
                onChanged: (newRadius) {
                  setState(() {
                    _radiusMeters = newRadius;
                    _updateCircle();
                  });
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}


Solution

  • Answer

    The flashing map and errors when moving the slider are caused by recreating the CircleAnnotationManager and annotations on every slider change, which disrupts the map's state. The zoom reset issue occurs because _updateCircle is triggered on camera changes, creating a feedback loop. The PlatformException errors suggest issues with Mapbox's Pigeon channel communication, possibly due to rapid or concurrent updates.

    Fixes

    1. Prevent Flashing: Update the existing CircleAnnotation instead of deleting and recreating it. Cache the CircleAnnotationManager and reuse it.
    2. Fix Zoom Reset: Debounce camera change updates to avoid feedback loops, and only update the circle when necessary (e.g., on zoom or radius change).
    3. Handle Errors: Add checks to ensure the map and manager are ready before updates, and handle exceptions gracefully.
    4. Scale Circle with Zoom: Your existing ground resolution calculation is correct, but ensure it’s applied consistently without resetting zoom.

    Updated Code

    import 'dart:math' as math;
    import 'package:flutter/material.dart';
    import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
    import 'package:geolocator/geolocator.dart' as geo;
    
    class AreaCaptureScreen extends StatefulWidget {
      const AreaCaptureScreen({super.key});
    
      @override
      State<AreaCaptureScreen> createState() => _AreaCaptureScreenState();
    }
    
    class _AreaCaptureScreenState extends State<AreaCaptureScreen> {
      MapboxMap? _mapboxMap;
      CircleAnnotationManager? _circleManager;
      String? _circleAnnotationId;
      bool _isDisposed = false;
      Point? _centerPoint;
      double _radiusMeters = 300.0;
      double _lastZoom = 15.0;
    
      @override
      void dispose() {
        _isDisposed = true;
        _circleManager?.deleteAll();
        super.dispose();
      }
    
      void _onMapCreated(MapboxMap mapboxMap) async {
        if (_isDisposed || !mounted) return;
        _mapboxMap = mapboxMap;
    
        // Hide UI elements
        await mapboxMap.logo.updateSettings(LogoSettings(enabled: false));
        await mapboxMap.attribution.updateSettings(AttributionSettings(enabled: false));
        await mapboxMap.scaleBar.updateSettings(ScaleBarSettings(enabled: false));
    
        // Enable location puck
        await mapboxMap.location.updateSettings(
          LocationComponentSettings(enabled: true, pulsingEnabled: true, puckBearingEnabled: true),
        );
    
        try {
          final geo.Position pos = await _determinePosition();
          if (_isDisposed || !mounted) return;
    
          _centerPoint = Point(coordinates: Position(pos.longitude, pos.latitude));
          await mapboxMap.setCamera(CameraOptions(center: _centerPoint, zoom: 15.0, bearing: pos.heading));
    
          // Initialize circle manager
          _circleManager = await mapboxMap.annotations.createCircleAnnotationManager();
          await _updateCircle();
        } catch (e) {
          debugPrint('Error setting up map: $e');
        }
      }
    
      Future<void> _updateCircle({bool forceUpdate = false}) async {
        if (_mapboxMap == null || _centerPoint == null || _isDisposed || !mounted) return;
    
        try {
          final cameraState = await _mapboxMap!.getCameraState();
          if (!forceUpdate && cameraState.zoom == _lastZoom && _circleAnnotationId != null) return;
          _lastZoom = cameraState.zoom;
    
          final double groundResolution = 156543.03392 *
              math.cos(_centerPoint!.coordinates.lat * math.pi / 180) /
              math.pow(2, cameraState.zoom);
          final double pixelRadius = _radiusMeters / groundResolution;
    
          if (_circleAnnotationId == null) {
            final annotation = await _circleManager!.create(
              CircleAnnotationOptions(
                geometry: _centerPoint!,
                circleRadius: pixelRadius,
                circleColor: Color(0xFF33AFFF).toARGB32(),
                circleOpacity: 0.3,
                circleStrokeWidth: 1.5,
                circleStrokeColor: Color(0xFF0077FF).toARGB32(),
              ),
            );
            _circleAnnotationId = annotation.id;
          } else {
            await _circleManager!.update(
              CircleAnnotation(
                id: _circleAnnotationId!,
                geometry: _centerPoint!,
                circleRadius: pixelRadius,
                circleColor: Color(0xFF33AFFF).toARGB32(),
                circleOpacity: 0.3,
                circleStrokeWidth: 1.5,
                circleStrokeColor: Color(0xFF0077FF).toARGB32(),
              ),
            );
          }
        } catch (e) {
          debugPrint('Error updating circle: $e');
        }
      }
    
      Future<geo.Position> _determinePosition() async {
        bool serviceEnabled = await geo.Geolocator.isLocationServiceEnabled();
        if (!serviceEnabled) throw Exception('Location services are disabled.');
    
        geo.LocationPermission permission = await geo.Geolocator.checkPermission();
        if (permission == geo.LocationPermission.denied) {
          permission = await geo.Geolocator.requestPermission();
          if (permission == geo.LocationPermission.denied) throw Exception('Location permissions are denied.');
        }
        if (permission == geo.LocationPermission.deniedForever) {
          throw Exception('Location permissions are permanently denied.');
        }
        return await geo.Geolocator.getCurrentPosition();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          backgroundColor: Colors.black,
          appBar: AppBar(backgroundColor: Colors.transparent, foregroundColor: Colors.white),
          body: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                children: [
                  Container(
                    height: 300,
                    decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)),
                    child: ClipRRect(
                      borderRadius: BorderRadius.circular(10),
                      child: MapWidget(
                        styleUri: '<REMOVED>',
                        cameraOptions: CameraOptions(center: Point(coordinates: Position(-122.4194, 37.7749)), zoom: 15.0),
                        onMapCreated: _onMapCreated,
                        onCameraChangeListener: (_) => _updateCircle(),
                        key: UniqueKey(),
                      ),
                    ),
                  ),
                  Slider(
                    value: _radiusMeters,
                    min: 100.0,
                    max: 1000.0,
                    onChanged: (newRadius) {
                      setState(() {
                        _radiusMeters = newRadius;
                        _updateCircle(forceUpdate: true);
                      });
                    },
                  ),
                ],
              ),
            ),
          ),
        );
      }
    }
    

    Explanation

    No Flashing: Reuses the same CircleAnnotation by updating its properties instead of deleting/recreating, reducing map redraws. Stable Zoom: Tracks _lastZoom to skip unnecessary updates unless zoom or radius changes, preventing feedback loops. Error Handling: Checks for nulls and retries only when necessary, avoiding Pigeon channel errors. Zoom Scaling: Maintains your ground resolution formula for consistent circle sizing relative to zoom.

    Recommendations

    Debounce Slider: Add a debounce mechanism (e.g., using Timer) to limit _updateCircle calls during rapid slider changes. Upgrade Mapbox: Ensure you're using the latest mapbox_maps_flutter version to avoid Pigeon channel bugs. Test Edge Cases: Verify behavior at extreme zoom levels and rapid slider movements.

    This should resolve the flashing, zoom reset, and error issues while keeping the circle size zoom-relative.

    Follow-Up Answer

    Thanks for the feedback! The map refreshing and new errors (Null check operator used on a null value, PlatformException with No manager or annotation found, and Pigeon channel issues) suggest that the CircleAnnotationManager or annotation ID is becoming invalid during rapid slider updates, likely due to timing issues or state mismatches in the Mapbox plugin. The refresh is caused by excessive map updates when the slider changes.

    Root Causes

    1. Map Refreshing: The _updateCircle call on every slider change triggers map redraws, even if the annotation update fails.
    2. Null Check Error: The _circleAnnotationId or _circleManager may be null or invalid when update is called, possibly due to concurrent updates or manager corruption.
    3. PlatformException: The Mapbox plugin's Pigeon channel fails to communicate when updates are too frequent, or the annotation manager is in an inconsistent state.

    Solution

    1. Debounce Slider Updates: Limit _updateCircle calls during rapid slider changes using a debounce mechanism to reduce redraws and channel errors.
    2. Validate Manager and Annotation: Check _circleManager and _circleAnnotationId before updates, and reinitialize if invalid.
    3. Reuse Annotations Safely: Ensure the annotation is created only once and updated correctly without redundant manager creation.
    4. Avoid Redraws: Update only the necessary properties to minimize map refreshes.

    Updated Code

    import 'dart:async';
    import 'dart:math' as math;
    import 'package:flutter/material.dart';
    import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
    import 'package:geolocator/geolocator.dart' as geo;
    
    class AreaCaptureScreen extends StatefulWidget {
      const AreaCaptureScreen({super.key});
    
      @override
      State<AreaCaptureScreen> createState() => _AreaCaptureScreenState();
    }
    
    class _AreaCaptureScreenState extends State<AreaCaptureScreen> {
      MapboxMap? _mapboxMap;
      CircleAnnotationManager? _circleManager;
      String? _circleAnnotationId;
      bool _isDisposed = false;
      Point? _centerPoint;
      double _radiusMeters = 300.0;
      double _lastZoom = 15.0;
      Timer? _debounceTimer;
    
      @override
      void dispose() {
        _isDisposed = true;
        _debounceTimer?.cancel();
        _circleManager?.deleteAll();
        super.dispose();
      }
    
      void _onMapCreated(MapboxMap mapboxMap) async {
        if (_isDisposed || !mounted) return;
        _mapboxMap = mapboxMap;
    
        // Hide UI elements
        await mapboxMap.logo.updateSettings(LogoSettings(enabled: false));
        await mapboxMap.attribution.updateSettings(AttributionSettings(enabled: false));
        await mapboxMap.scaleBar.updateSettings(ScaleBarSettings(enabled: false));
    
        // Enable location puck
        await mapboxMap.location.updateSettings(
          LocationComponentSettings(enabled: true, pulsingEnabled: true, puckBearingEnabled: true),
        );
    
        try {
          final geo.Position pos = await _determinePosition();
          if (_isDisposed || !mounted) return;
    
          _centerPoint = Point(coordinates: Position(pos.longitude, pos.latitude));
          await mapboxMap.setCamera(CameraOptions(center: _centerPoint, zoom: 15.0, bearing: pos.heading));
    
          // Initialize circle manager
          _circleManager = await mapboxMap.annotations.createCircleAnnotationManager();
          await _updateCircle(forceUpdate: true);
        } catch (e) {
          debugPrint('Error setting up map: $e');
        }
      }
    
      Future<void> _updateCircle({bool forceUpdate = false}) async {
        if (_mapboxMap == null || _centerPoint == null || _isDisposed || !mounted) return;
    
        try {
          // Validate manager
          if (_circleManager == null) {
            _circleManager = await _mapboxMap!.annotations.createCircleAnnotationManager();
            _circleAnnotationId = null; // Reset annotation ID if manager is recreated
          }
    
          final cameraState = await _mapboxMap!.getCameraState();
          if (!forceUpdate && cameraState.zoom == _lastZoom && _circleAnnotationId != null) return;
          _lastZoom = cameraState.zoom;
    
          final double groundResolution = 156543.03392 *
              math.cos(_centerPoint!.coordinates.lat * math.pi / 180) /
              math.pow(2, cameraState.zoom);
          final double pixelRadius = _radiusMeters / groundResolution;
    
          if (_circleAnnotationId == null) {
            final annotation = await _circleManager!.create(
              CircleAnnotationOptions(
                geometry: _centerPoint!,
                circleRadius: pixelRadius,
                circleColor: Color(0xFF33AFFF).toARGB32(),
                circleOpacity: 0.3,
                circleStrokeWidth: 1.5,
                circleStrokeColor: Color(0xFF0077FF).toARGB32(),
              ),
            );
            _circleAnnotationId = annotation.id;
          } else {
            await _circleManager!.update(
              CircleAnnotation(
                id: _circleAnnotationId!,
                geometry: _centerPoint!,
                circleRadius: pixelRadius,
                circleColor: Color(0xFF33AFFF).toARGB32(),
                circleOpacity: 0.3,
                circleStrokeWidth: 1.5,
                circleStrokeColor: Color(0xFF0077FF).toARGB32(),
              ),
            );
          }
        } catch (e) {
          debugPrint('Error updating circle: $e');
          if (e.toString().contains('No manager or annotation found') && !_isDisposed && mounted) {
            _circleManager = null;
            _circleAnnotationId = null;
            await _updateCircle(forceUpdate: true); // Retry with new manager
          }
        }
      }
    
      Future<geo.Position> _determinePosition() async {
        bool serviceEnabled = await geo.Geolocator.isLocationServiceEnabled();
        if (!serviceEnabled) throw Exception('Location services are disabled.');
    
        geo.LocationPermission permission = await geo.Geolocator.checkPermission();
        if (permission == geo.LocationPermission.denied) {
          permission = await geo.Geolocator.requestPermission();
          if (permission == geo.LocationPermission.denied) throw Exception('Location permissions are denied.');
        }
        if (permission == geo.LocationPermission.deniedForever) {
          throw Exception('Location permissions are permanently denied.');
        }
        return await geo.Geolocator.getCurrentPosition();
      }
    
      void _debouncedUpdateCircle(double newRadius) {
        _debounceTimer?.cancel();
        _debounceTimer = Timer(const Duration(milliseconds: 100), () {
          if (!_isDisposed && mounted) {
            setState(() {
              _radiusMeters = newRadius;
              _updateCircle(forceUpdate: true);
            });
          }
        });
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          backgroundColor: Colors.black,
          appBar: AppBar(backgroundColor: Colors.transparent, foregroundColor: Colors.white),
          body: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                children: [
                  Container(
                    height: 300,
                    decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)),
                    child: ClipRRect(
                      borderRadius: BorderRadius.circular(10),
                      child: MapWidget(
                        styleUri: '<REMOVED>',
                        cameraOptions: CameraOptions(center: Point(coordinates: Position(-122.4194, 37.7749)), zoom: 15.0),
                        onMapCreated: _onMapCreated,
                        onCameraChangeListener: (_) => _updateCircle(),
                        key: UniqueKey(),
                      ),
                    ),
                  ),
                  Slider(
                    value: _radiusMeters,
                    min: 100.0,
                    max: 1000.0,
                    onChanged: _debouncedUpdateCircle,
                  ),
                ],
              ),
            ),
          ),
        );
      }
    }
    

    Explanation

    Debouncing: The _debouncedUpdateCircle method delays _updateCircle calls by 100ms, reducing rapid updates and preventing map redraws during slider drags. Manager Validation: Checks and reinitializes _circleManager if null or invalid, addressing PlatformException errors. Annotation Reuse: Updates the existing annotation with _circleManager!.update, avoiding deletion/recreation to prevent flickering. Error Recovery: Retries with a new manager if the annotation ID is invalid, handling No manager or annotation found errors. Zoom Stability: Maintains _lastZoom to skip redundant updates unless zoom or radius changes.

    Additional Recommendations

    Update Mapbox Plugin: Use the latest mapbox_maps_flutter version (e.g., ^2.0.0 or higher) to fix potential Pigeon channel bugs. Test Rapid Changes: Test with fast slider movements to ensure debouncing works. Log for Debugging: Add more detailed logs in _updateCircle to trace errors if they persist.

    This should eliminate the map refreshing, null check errors, and PlatformException issues while keeping the circle size zoom-relative.