diff --git a/lib/src/map/controller/map_controller_impl.dart b/lib/src/map/controller/map_controller_impl.dart index 0ed0ed9b9..e12541f42 100644 --- a/lib/src/map/controller/map_controller_impl.dart +++ b/lib/src/map/controller/map_controller_impl.dart @@ -16,7 +16,14 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> implements MapController { final _mapEventStreamController = StreamController.broadcast(); - late MapInteractiveViewerState _interactiveViewerState; + /// The viewer of the map this controller is attached to, or `null` while it + /// does not exist yet. + /// + /// The viewer is created during layout, so a `FlutterMap` that has been + /// built but not laid out - for example one on a route that another route + /// covers from the first frame - has none, even though `onMapReady` has + /// already fired. + MapInteractiveViewerState? _interactiveViewerState; Animation? _moveAnimation; Animation? _zoomAnimation; @@ -336,7 +343,7 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> if (value.options != null && value.options!.interactionOptions != newOptions.interactionOptions) { - _interactiveViewerState.updateGestures( + _interactiveViewerState?.updateGestures( value.options!.interactionOptions, newOptions.interactionOptions, ); @@ -702,7 +709,7 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> void _emitMapEvent(MapEvent event) { if (event.source == MapEventSource.mapController && event is MapEventMove) { - _interactiveViewerState.interruptAnimatedMovement(event); + _interactiveViewerState?.interruptAnimatedMovement(event); } options.onMapEvent?.call(event); diff --git a/test/map/unlaid_out_map_test.dart b/test/map/unlaid_out_map_test.dart new file mode 100644 index 000000000..a8c8c0b58 --- /dev/null +++ b/test/map/unlaid_out_map_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +/// Builds [map] on a route that an opaque route covers from the very first +/// frame. +/// +/// The `Overlay` builds the covered route but never lays it out, so the +/// `LayoutBuilder` inside `FlutterMap` never runs and the map's +/// `MapInteractiveViewer` is never created - while `onMapReady`, scheduled +/// from `FlutterMap`'s `initState`, still fires after the first frame. +/// +/// This happens whenever an app is launched straight into a deeper screen, +/// for example from a deep link or a notification, with the map underneath. +Widget _coveredMap(Widget map) => MaterialApp( + // Without `onGenerateRoute`, `MaterialApp` builds no `Navigator` and + // ignores `onGenerateInitialRoutes`. + onGenerateRoute: (_) => null, + onGenerateInitialRoutes: (_) => [ + MaterialPageRoute(builder: (_) => Scaffold(body: map)), + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('cover')), + ), + ], + ); + +Future _uncover(WidgetTester tester) async { + tester.state(find.byType(Navigator)).pop(); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets( + 'moving the camera from onMapReady does not throw when the map has not ' + 'been laid out, and the move is kept', + (tester) async { + final controller = MapController(); + addTearDown(controller.dispose); + const destination = LatLng(52, 9); + var ready = false; + + await tester.pumpWidget( + _coveredMap( + FlutterMap( + mapController: controller, + options: MapOptions( + onMapReady: () { + ready = true; + controller.move(destination, 15); + }, + ), + children: const [], + ), + ), + ); + await tester.pump(); + + expect(ready, isTrue); + expect(tester.takeException(), isNull); + + await _uncover(tester); + + expect(tester.takeException(), isNull); + expect(controller.camera.center, destination); + expect(controller.camera.zoom, 15); + }, + ); + + testWidgets( + 'changing the interaction options before the map has been laid out does ' + 'not throw, and the new options apply once it is', + (tester) async { + final controller = MapController(); + addTearDown(controller.dispose); + final interactive = ValueNotifier(false); + addTearDown(interactive.dispose); + + await tester.pumpWidget( + _coveredMap( + ValueListenableBuilder( + valueListenable: interactive, + builder: (_, isInteractive, __) => FlutterMap( + mapController: controller, + options: MapOptions( + initialCenter: const LatLng(0, 0), + interactionOptions: InteractionOptions( + flags: isInteractive + ? InteractiveFlag.all + : InteractiveFlag.none, + ), + ), + children: const [], + ), + ), + ), + ); + await tester.pump(); + + interactive.value = true; + await tester.pump(); + + expect(tester.takeException(), isNull); + + await _uncover(tester); + await tester.drag(find.byType(FlutterMap), const Offset(-200, 0)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(controller.camera.center, isNot(const LatLng(0, 0))); + }, + ); +}