flutter_map icon indicating copy to clipboard operation
flutter_map copied to clipboard

[BUG] Exception "`MapCamera` is no longer within the `cameraConstraint` after an option change" when using particular camera setups

Open dumabg opened this issue 1 year ago • 26 comments
trafficstars

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

dumabg avatar Dec 06 '23 12:12 dumabg

Hi @dumabg, Can I just double check what version you are running?

JaffaKetchup avatar Dec 06 '23 12:12 JaffaKetchup

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?

thorito avatar Jan 09 '24 08:01 thorito

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 initialCameraFit changes, but changing it will have no effect and could cause issues
  • Not sure what PopupScope is, 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 avatar Jan 09 '24 09:01 JaffaKetchup

@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.

iulian0512 avatar Jan 26 '24 15:01 iulian0512

Thanks @iulian0512, we'll look into this :)

JaffaKetchup avatar Jan 31 '24 21:01 JaffaKetchup

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)

palicka avatar Feb 26 '24 18:02 palicka

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.

JaffaKetchup avatar May 23 '24 17:05 JaffaKetchup

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

JaffaKetchup avatar May 23 '24 18:05 JaffaKetchup

The issue also occurs in the following configuration, assuming the constraint is always constrained to the same bounds as the initial fit: image

but doesn't occur when using insideBounds.

@rorystephenson Do you have any insight into what could be happening here?

JaffaKetchup avatar May 23 '24 19:05 JaffaKetchup

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;

jetpeter avatar May 29 '24 20:05 jetpeter

Thanks for investigating @jetpeter!

JaffaKetchup avatar May 30 '24 09:05 JaffaKetchup

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?

JaffaKetchup avatar Jun 01 '24 13:06 JaffaKetchup

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 avatar Jun 01 '24 15:06 JaffaKetchup

@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.

jetpeter avatar Jun 01 '24 16:06 jetpeter

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.

JaffaKetchup avatar Jun 03 '24 08:06 JaffaKetchup

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

jetpeter avatar Jun 03 '24 15:06 jetpeter

#1920 suggests it could be fixed by also providing an initialCenter inside the specified coordinates.

JaffaKetchup avatar Jun 23 '24 09:06 JaffaKetchup

#1920 suggests it could be fixed by also providing an initialCenter inside 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'

jamesho86 avatar Jul 06 '24 00:07 jamesho86

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

nathanwang-comp avatar Jul 14 '24 23:07 nathanwang-comp

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.

MatKershaw3708 avatar Jul 22 '24 08:07 MatKershaw3708

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.

MatKershaw3708 avatar Jul 22 '24 14:07 MatKershaw3708

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.

JaffaKetchup avatar Jul 22 '24 21:07 JaffaKetchup

@MatKershaw3708

Seemingly doing what you said, but still having the crash image

DevTello avatar Jul 25 '24 14:07 DevTello

#1920 suggests it could be fixed by also providing an initialCenter inside 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'

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.

IcyTempest avatar Jul 26 '24 05:07 IcyTempest

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
              
            ]
         ), 

priyanshi-pandya avatar Aug 14 '24 11:08 priyanshi-pandya

@DevTello - here is the relevant part of my current options:

Screenshot 2024-08-16 134519

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!

MatKershaw3708 avatar Aug 16 '24 12:08 MatKershaw3708