flutter_map
flutter_map copied to clipboard
[BUG] Exception "`MapCamera` is no longer within the `cameraConstraint` after an option change" when using particular camera setups
What is the bug?
A FlutterMap with a cameraConstraint CameraConstraint.contain(bounds: widget.bounds) gives the assert error "MapCamera is no longer within the cameraConstraint after an option change"
How can we reproduce it?
Put a ValueListenableBuilder and a FlutterMap with a cameraConstraint. Change the ValueListenableBuilder value to trigger a new repaint.
Do you have a potential solution?
I've comment the assert and seems that it works
Platforms
Android
Severity
Erroneous: Prevents normal functioning and causes errors in the console
Hi @dumabg, Can I just double check what version you are running?
It happens the same to me.
pubspect.yaml:
flutter_map: ^6.1.0
flutter_map_cancellable_tile_provider: ^2.0.0
flutter_map_marker_cluster: ^1.3.4
My code:
MapOptions _buildMapOptions() => MapOptions(
initialCenter: widget._parametersMap.currentPosition.center,
initialZoom: widget._parametersMap.initialZoom,
maxZoom: widget._parametersMap.maxZoom,
minZoom: widget._parametersMap.minZoom,
initialRotation: widget._parametersMap.rotation,
cameraConstraint: widget._parametersMap.cameraConstraint,
initialCameraFit: markers.isNotEmpty &&
UserPreferences.getInstance().currentPosition == null
? markers.getCameraFit(
minZoom: widget._parametersMap.minZoom,
maxZoom: widget._parametersMap.initialZoom,
)
: null,
keepAlive: true,
onMapReady: () {
// logger.d('onMapReady !!');
},
onPositionChanged: (MapPosition position, bool hasGesture) {
// logger.d('onPositionChanged hasGesture: $hasGesture,
// position: $position');
if (hasGesture) {
ref.read(mapChangedProvider.notifier).changePosition();
}
},
onTap: (_, __) => widget._popupController.hideAllPopups(),
);
@override
Widget build(BuildContext context) {
final markersAsync = ref.watch(markersProvider);
return markersAsync.when(
data: (values) {
if (values.isNotEmpty) {
setState(() {
markers = List.from(values);
});
}
return PopupScope(
popupController: widget._popupController,
child: Stack(
children: [
FlutterMap(
mapController: widget._mapController,
options: _buildMapOptions(),
children: [
...
],
),
);
},
loading: () => const CustomProgress(),
error: (error, stackTrace) {
context.showSnackbar(
message: error.toString(),
type: MessageType.error,
);
return const SizedBox.shrink();
},
);
}
Error:
MapCamera is no longer within the cameraConstraint after an option change.
'package:flutter_map/src/map/controller/internal.dart':
Failed assertion: line 276 pos 7: 'newOptions.cameraConstraint.constrain(newCamera) == newCamera'
What is the reason for the error?
I can't tell exactly without a more minimal reproducible example, but there's a few things that could be causing issues:
- Building the map options in a function
- Not sure if
initialCameraFitchanges, but changing it will have no effect and could cause issues - Not sure what
PopupScopeis, but maybe that's causing issues?
We need an MRE to investigate this further (standalone, no state from other parts of app, no dependencies, etc.).
@JaffaKetchup @josxha here is a complete single file minimum reproducible example
// ignore_for_file: prefer_const_constructors, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int cnt = 0;
@override
void initState() {
super.initState();
Future(() async {
await Future.delayed(Duration(seconds: 1));
cnt++;
setState(() {});
});
}
@override
Widget build(BuildContext context) {
final camConstraint = cnt > 0
? CameraConstraint.contain(
bounds: LatLngBounds(LatLng(43.6884447292, 20.2201924985),
LatLng(48.2208812526, 29.62654341)))
: null;
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: FlutterMap(
options: MapOptions(
interactionOptions: InteractionOptions(
flags: InteractiveFlag.pinchZoom |
InteractiveFlag.drag |
InteractiveFlag.doubleTapZoom |
InteractiveFlag.scrollWheelZoom,
rotationWinGestures: MultiFingerGesture.none,
rotationThreshold: double.infinity,
cursorKeyboardRotationOptions:
CursorKeyboardRotationOptions.disabled()),
minZoom: 1,
maxZoom: 20,
initialZoom: 5.9,
initialCenter: LatLng(45.80565, 24.937853),
cameraConstraint: camConstraint),
children: []));
}
}
pubspec.yaml
name: flutter_map_constraint_issue
description: "A new Flutter project."
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ">=3.2.5 <4.0.0"
dependencies:
cupertino_icons: ^1.0.2
flutter:
sdk: flutter
flutter_map: ^6.1.0
dev_dependencies:
flutter_lints: ^2.0.0
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
the example above produces after 1 second the error
════════ Exception caught by widgets library ═══════════════════════════════════
The following assertion was thrown building KeyedSubtree-[GlobalKey#de288]:
MapCamera is no longer within the cameraConstraint after an option change.
'package:flutter_map/src/map/controller/internal.dart':
Failed assertion: line 276 pos 7: 'newOptions.cameraConstraint.constrain(newCamera) == newCamera'
The relevant error-causing widget was:
Scaffold Scaffold:file:///D:/temp/flutter_map_constraint_issue/lib/main.dart:56:12
let me know if i can assist or provide anything else.
Thanks @iulian0512, we'll look into this :)
any update on this ? In my case it's not just error in console, but map is not displayed at all (I'm using 6.1.0 as well)
EDIT:
using CameraConstraint.containCenter() instead of CameraConstraint.contain() fixed the issue for me (.containerCenter is slightly different than .contain, but in my case it doesn't matter)
The issue seems to occur because CameraConstraint.constrain returns null for some reason. Therefore, the new camera, and the constrained new camera don't match.
May return null if no appropriate camera could be generated by movement, for example because the camera was zoomed too far out.
Likely the issue is within the ContainCamera.constrain method, or we're setting options when we're not supposed to.
The issue can be tracked back to the initial implementation in 2c60d63b48d0a5d77305b4e00dae8f981549260a/#1551. It only occurs with contain - although it seems to happen in multiple other MapOptions configurations.
Here's some debug output (when running the MRE here: https://github.com/fleaflet/flutter_map/issues/1760#issuecomment-1912282417). Not sure if I can fix this!
nePixel: Point(8901.449086445922, 5299.8690412382875) swPixel: Point(8502.023744760078, 5576.9298207346355) left...: 9526.023744760078 right...: 7877.449086445922 top...: 5876.269041238287 botOkCenter: 5000.529820734636
The issue also occurs in the following configuration, assuming the constraint is always constrained to the same bounds as the initial fit:
but doesn't occur when using insideBounds.
@rorystephenson Do you have any insight into what could be happening here?
I think I found what is going on here. If you look at when you first create the camera when checking constraints it first looks for an existing camera, and if none then it creates a new camera based on the new options. The bug is that the initialCamera method uses the old initalCenter and initalZoom fields which have default data in them.
final newCamera = value.camera?.withOptions(newOptions) ??
MapCamera.initialCamera(newOptions);
/// Initializes [MapCamera] from the given [options] and with the
/// [nonRotatedSize] set to [kImpossibleSize].
MapCamera.initialCamera(MapOptions options)
: crs = options.crs,
minZoom = options.minZoom,
maxZoom = options.maxZoom,
center = options.initialCenter,
zoom = options.initialZoom,
rotation = options.initialRotation,
nonRotatedSize = kImpossibleSize;
Thanks for investigating @jetpeter!
I think what @jetpeter has found is a part of a much larger issue in that we treat the initialCameraFit somewhat seperately to how we treat initialCenter and initialZoom, and, as you said, we have these two last properties always defined, even if they perhaps shouldn't be.
This is because we have to wait for Flutter to give us some constraints, so we can fit the camera, which can take some time: https://github.com/flutter/flutter/issues/25827.
Therefore, what we try to do, is use the initial center and zoom, then re-fit once we get the constraints. However, this causes this issue indirectly - we re-set options which triggers this issue, as we try to fit the default center and zoom into the initialCameraFit the user actually wants. I believe that's whats causing this issue, although I'm not 100% sure.
Therefore, I propose that we change the way MapOptions works, and the way FlutterMap is built.
First, we make all the initial positioning options nullable, and use asserts to ensure that exactly one of the two 'modes' is used. Then, on build, if we aren't using initialCameraFit, we can build immediately.
Otherwise, we don't display any layers until we get the constraints from Flutter - we just display the background color (and perhaps a simple loading message). Although this does mean this is theoretically now visible to the user, it was happening before, they just couldn't see it, because the network time of the tile layer is larger than it takes to get constraints, so the map moves before tiles load in - I think (again, none of this is 100% certain).
This would mean that we don't have to continously workaround the constraints issue: we don't do anything until there are constraints.
@josxha wdyt?
I just realised that perhaps this is the intended behaviour. In @iulian0512's example above, his initial zoom lies outside the camera constraint - therefore the error is thrown, because the zoom needs to be higher to fit.
Equally, using CameraFit.bounds and CameraConstraint.contain with the same bounds would cause the same issue on many screen sizes: instead, the correct approach would be to use CameraFit.insideBounds.
Can anyone explain what else they would expect in this case?
@JaffaKetchup From your original thoughts, from what I can see the issue is the other way round:
"However, this causes this issue indirectly - we re-set options which triggers this issue,"
I think the issue is that the first time the options is set the exception is thrown. The map never gets an opportunity to use the initalCameraFit after sizing if the MapCamera.initailCamera is out of cameraConstraints due to initialZoom and initialCenter default values.
As it stands right now for usinger with dynamic constraints initialCameraFit is not usable, you must set initialZoom and initialCenter. To compute the initial values as a user just like the map you require widget size, so you have to use a LayoutBuilder to get the size then compute the center zoom from bounds. I have been doing this for a while since older versions of flutter map required this anyway. For new users it is really confusing that there is two different ways of doing initial placement with one being secretly required if constraints are provided, yet the documentation says that they are ignored if the initialCameraFit is provided.
It makes sense to throw an exception if the user requested bounds are outside of the initial position. The problem is if you just provided initalCameraFit and not initialCenter and initalZoom even if your initialCameraFit is inside the constraints you will always get an exception if your constraints are outside of the default center zoom of the map options. Flutter map should ideally have a single initalCamerFit field and not initialCenter initialZoom at all. initalCameraFit could have a centerZoom type instead. Having a few milliseconds of default background is probably acceptable, and honestly might not actually be any different than it is now since tiles should not be loaded until the correct position is computed. Hopefully for users providing initialCameraFit the map is not loading tiles for the default values while the widget is still sizing.
I think the issue is that the first time the options is set the exception is thrown. The map never gets an opportunity to use the initalCameraFit after sizing if the MapCamera.initailCamera is out of cameraConstraints due to initialZoom and initialCenter default values.
As far as I can see from my testing, it works perfectly fine, if CameraFit.insideBounds is used with a constraint, as it should.
For new users it is really confusing that there is two different ways of doing initial placement with one being secretly required if constraints are provided, yet the documentation says that they are ignored if the initialCameraFit is provided.
This shouldn't be the behaviour. Neither should be 'required'. I agree that having non-null defaults on the MapOptions initialCenter and initialZoom isn't the best style, but they SHOULD be ignored if the camera fit is provided.
It makes sense to throw an exception if the user requested bounds are outside of the initial position. The problem is if you just provided initalCameraFit and not initialCenter and initalZoom even if your initialCameraFit is inside the constraints you will always get an exception if your constraints are outside of the default center zoom of the map options.
I can certainly see this could be a possible issue. Can you post an MRE of your exact situation (MapOptions), so I can confirm this? I have been testing using the fixes(?) in #1902, so it's possible I 'accidentally' fixed that issue along the way.
When making a test project I realized that the camera fit issue only happens when you provide your own MapController. The below code works if you comment out the controller def. With the controller you get the exceiption:
======== Exception caught by widgets library =======================================================
The following assertion was thrown building KeyedSubtree-[GlobalKey#f3ab2]:
Assertion failed: file:///Users/user/.pub-cache/hosted/pub.dev/flutter_map-7.0.0/lib/src/map/controller/map_controller_impl.dart:350:7
newOptions.cameraConstraint.constrain(newCamera) == newCamera
"MapCamera is no longer within the cameraConstraint after an option change."
The relevant error-causing widget was:
Scaffold Scaffold:file:///Users/user/src/map_test/lib/main.dart:52:12
When the exception was thrown, this was the stack:
dart-sdk/lib/_internal/js_dev_runtime/private/ddc_runtime/errors.dart 296:3 throw_
dart-sdk/lib/_internal/js_dev_runtime/private/ddc_runtime/errors.dart 29:3 assertFailed
packages/flutter_map/src/map/controller/map_controller_impl.dart 350:68 set options
packages/flutter_map/src/map/widget.dart 188:7 [_setMapController]
packages/flutter_map/src/map/widget.dart 61:5 initState
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Map Bounds Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
late final MapController controller;
@override
void initState() {
controller = MapController();
super.initState();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final bounds = LatLngBounds(const LatLng(46.028731, -123.896535), const LatLng(42.004317, -117.057532));
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Center(
child: FlutterMap(
// Map works without controller
mapController: controller,
options: MapOptions(
// If not using a controller you don't actually need initial camera fit.
// The map will build to the default bounds, but if you try to move it
// the map will jump to your bonds with no exception.
initialCameraFit: CameraFit.insideBounds(bounds: bounds),
cameraConstraint: CameraConstraint.contain(bounds: bounds)
),
children: [
TileLayer(
urlTemplate: "tile_provider_url",
userAgentPackageName: 'com.example.bounds_test',
tileProvider: NetworkTileProvider(),
)
],
)
));
}
}
pubspec.yaml
flutter_map: ^7.0.0
latlong2: ^0.9.1
#1920 suggests it could be fixed by also providing an initialCenter inside the specified coordinates.
#1920 suggests it could be fixed by also providing an
initialCenterinside the specified coordinates.
The bug still exists even if the initialCenter is inside the specified camera constraint with the latest versions below.
flutter_map: ^7.0.2 latlong2: ^0.9.1
error msg: MapCamera is no longer within the cameraConstraint after an option change. 'package:flutter_map/src/map/controller/map_controller_impl.dart': Failed assertion: line 345 pos 7: 'newOptions.cameraConstraint.constrain(newCamera) == newCamera'
The bug still exists even if the initialCenter is inside the specified camera constraint and not init MapController with the latest versions below. flutter_map: ^7.0.2 latlong2: ^0.9.1
I've been experimenting with the issue when crashes occur even if initialCenter is within the constraint bounds.
If you are zoomed out enough, the constraints may mean that the camera's initial center actually ends up outside the constraint bounds and the crash will occur.
The map will try to use the specified initialCenter, but may not be able to do so if this means exposing areas outside the cameraConstraint settings given the zoom level. It will instead center itself the best it can, triggering the crash if the resulting centre of the map is outside the constraint.
This is probably the same issue described by jetpeter above.
I've worked around the issue to setting initialCameraFit to the same bounds as cameraConstraint. This is much safer than setting an initialZoom and initialCenter, as you know the map's resulting centre will be within the same bounds.
We would appriciate any PRs! This will require some level of rewrite of the camera system, particularly initialisation. It has outgrown its initial requirements, which I believe is why this is happening. A combinination of race conditions and complicated initialisation/setup.
@MatKershaw3708
Seemingly doing what you said, but still having the crash
#1920 suggests it could be fixed by also providing an
initialCenterinside the specified coordinates.The bug still exists even if the
initialCenteris inside the specified camera constraint with the latest versions below.flutter_map: ^7.0.2 latlong2: ^0.9.1
error msg: MapCamera is no longer within the cameraConstraint after an option change. 'package:flutter_map/src/map/controller/map_controller_impl.dart': Failed assertion: line 345 pos 7: 'newOptions.cameraConstraint.constrain(newCamera) == newCamera'
Have you tried it with an actual device? I'm developing my application with a real device instead of an emulator and I haven't had that problem occur since applying the initialCenter. And Yes, if I use an emulator the problem would still occur.
Actually, the bound stop working when I started using the emulator. But I'm not sure if I'm using flutter_map 7.0.2 at the time.
I was getting the same exception, i had to adjust the initialCenter with respect to the cameraConstraints
Like,
initialZoom: 2.3, cameraConstraint: CameraConstraint.contain( bounds: LatLngBounds( const LatLng(-90, -180), const LatLng(90, 180), ), ),
For this above bounds it works perfectly with initialZoom of 2.3, if I go any less than this, its throws me again MapCamera, is not within cameraConstraint error.
Here is my full code
FlutterMap(
options: MapOptions(
minZoom: 1,
maxZoom: 19,
initialCenter: const LatLng(0, 0),
initialZoom: 2.3,
cameraConstraint: CameraConstraint.contain(
bounds: LatLngBounds(
const LatLng(-90, -180),
const LatLng(90, 180),
),
),
onTap: (tapPosition, point) {
//Tap logic
},
onPointerHover: (event, point) {
//Hover logic },
),
mapController: mapProvider.mapController,
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.example.app',
tileProvider: CancellableNetworkTileProvider(),
),
//other layers
]
),
@DevTello - here is the relevant part of my current options:
That's all I use, which avoids the crash. You may find the inclusion of the min zoom settings are causing the crash by making the resulting camera center end up outside the cameraConstraint bounds. The cameraConstraint by itself will cap the min zoom level to whatever is required to make the bounding box visible as a whole, while preventing you from being able to zoom out further. Setting max zoom should not be an issue.
I didn't know you could inset the initialCameraFit, thanks for that!