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();
});
},
),
],
),
),
),
);
}
}
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.
CircleAnnotation
instead of deleting and recreating it. Cache the CircleAnnotationManager
and reuse it.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.
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.
_updateCircle
call on every slider change triggers map redraws, even if the annotation update fails._circleAnnotationId
or _circleManager
may be null or invalid when update
is called, possibly due to concurrent updates or manager corruption._updateCircle
calls during rapid slider changes using a debounce mechanism to reduce redraws and channel errors._circleManager
and _circleAnnotationId
before updates, and reinitialize if invalid.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.