Skip to content

LateInitializationError on _interactiveViewerState when a MapController is used after onMapReady but before the map has been laid out #2293

Description

@codeOfJannik

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 FlutterMap with different interactionOptions throws in the options setter → _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=: skipping updateGestures is safe because the viewer builds its gesture recognizers from the controller's current options in didChangeDependencies, so it starts out with the latest options when it is created;
  • _emitMapEvent: skipping interruptAnimatedMovement is 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.

Activity

  1. added theissue type on Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs triageThis new bug report needs reproducing and prioritizing

    Type

    Fields

    Priority

    None yet

    Effort

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions