Repository navigation
Description
What is the bug?
MapControllerImpl keeps the map's viewer in late MapInteractiveViewerState _interactiveViewerState. Only MapInteractiveViewerState.initState assigns it, and FlutterMap creates the viewer inside a LayoutBuilder, so the viewer only exists once the map has been laid out.
onMapReady, however, is scheduled as a post-frame callback from FlutterMap's initState, so it fires after the first frame whether or not the map was laid out. A FlutterMap that is built but not laid out has no viewer even though onMapReady has fired. That happens, for example, on a route that another opaque route covers from the very first frame: the Overlay builds covered routes but does not lay them out.
In that state, two controller paths throw:
- any camera move through the controller (
move,rotate,fitCamera, …) throws in_emitMapEvent→_interactiveViewerState.interruptAnimatedMovement; - a rebuild of the
FlutterMapwith differentinteractionOptionsthrows in theoptionssetter →_interactiveViewerState.updateGestures, called from_FlutterMapStateContainer.didUpdateWidget.
LateInitializationError: Field '_interactiveViewerState@…' has not been initialized.
#0 MapControllerImpl._emitMapEvent
#1 MapControllerImpl.moveRaw
#2 MapControllerImpl.move
LateInitializationError: Field '_interactiveViewerState@…' has not been initialized.
#0 MapControllerImpl.options=
#1 _FlutterMapStateContainer.didUpdateWidget
Following the documented guidance (only use the controller once onMapReady has fired) does not avoid it, because onMapReady itself fires too early here.
We see both in production on Android and iOS, when an app is cold-started from a deep link or a notification straight into a screen that sits above the map screen.
Expected: the controller can be used once onMapReady has fired, and changes made before the viewer exists apply once it does.
Version: reproduced on master (34457c1) and on 8.3.2. Not platform-specific.
How can we reproduce it?
Minimal app — the map route is covered by an opaque route from the first frame:
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
void main() => runApp(const App());
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
// Needed for MaterialApp to create a Navigator at all.
onGenerateRoute: (_) => null,
// Start with two routes, as a deep link into a deeper screen does:
// the map route is built but covered (not laid out) from frame one.
onGenerateInitialRoutes: (_) => [
MaterialPageRoute<void>(builder: (_) => const MapPage()),
MaterialPageRoute<void>(
builder: (context) => Scaffold(
body: Center(
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Show map'),
),
),
),
),
],
);
}
class MapPage extends StatefulWidget {
const MapPage({super.key});
@override
State<MapPage> createState() => _MapPageState();
}
class _MapPageState extends State<MapPage> {
final _controller = MapController();
@override
Widget build(BuildContext context) => Scaffold(
body: FlutterMap(
mapController: _controller,
options: MapOptions(
// Throws: the map has not been laid out yet, so it has no viewer.
onMapReady: () => _controller.move(const LatLng(52, 9), 15),
),
children: const [],
),
);
}Running it logs the first LateInitializationError above as soon as the app starts. Two widget tests reproducing both errors are in the linked PR (test/map/unlaid_out_map_test.dart); both fail on master with these errors.
Do you have a potential solution?
Yes, a PR follows: make the field nullable and skip the two viewer calls while there is no viewer:
options=: skippingupdateGesturesis safe because the viewer builds its gesture recognizers from the controller's current options indidChangeDependencies, so it starts out with the latest options when it is created;_emitMapEvent: skippinginterruptAnimatedMovementis safe because without a viewer there is no viewer-driven gesture animation to interrupt.
An alternative would be to change when onMapReady fires (only once the viewer exists), but that changes a public callback's timing for everyone, so the PR keeps to the controller.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Effort
Activity