diff --git a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md index c165987e87b..eb60690a37d 100644 --- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.14.0 + +* Adds a check that raises a `StateError` + when map controller is used after its widget has been disposed. + ## 2.13.1 * Fixes exception when dispose is called while asynchronous update from diff --git a/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart b/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart index b4c9e098524..80f019d5a00 100644 --- a/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart +++ b/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart @@ -264,6 +264,7 @@ class GoogleMapController { /// in-memory cache of tiles. If you want to cache tiles for longer, you /// should implement an on-disk cache. Future clearTileCache(TileOverlayId tileOverlayId) async { + _checkWidgetMountedOrThrow(); return GoogleMapsFlutterPlatform.instance.clearTileCache( tileOverlayId, mapId: mapId, @@ -278,6 +279,7 @@ class GoogleMapController { /// The returned [Future] completes after the change has been started on the /// platform side. Future animateCamera(CameraUpdate cameraUpdate, {Duration? duration}) { + _checkWidgetMountedOrThrow(); return GoogleMapsFlutterPlatform.instance.animateCameraWithConfiguration( cameraUpdate, CameraUpdateAnimationConfiguration(duration: duration), @@ -290,6 +292,7 @@ class GoogleMapController { /// The returned [Future] completes after the change has been made on the /// platform side. Future moveCamera(CameraUpdate cameraUpdate) { + _checkWidgetMountedOrThrow(); return GoogleMapsFlutterPlatform.instance.moveCamera( cameraUpdate, mapId: mapId, @@ -311,6 +314,7 @@ class GoogleMapController { /// style reference for more information regarding the supported styles. @Deprecated('Use GoogleMap.style instead.') Future setMapStyle(String? mapStyle) { + _checkWidgetMountedOrThrow(); return GoogleMapsFlutterPlatform.instance.setMapStyle( mapStyle, mapId: mapId, @@ -319,11 +323,13 @@ class GoogleMapController { /// Returns the last style error, if any. Future getStyleError() { + _checkWidgetMountedOrThrow(); return GoogleMapsFlutterPlatform.instance.getStyleError(mapId: mapId); } /// Return [LatLngBounds] defining the region that is visible in a map. Future getVisibleRegion() { + _checkWidgetMountedOrThrow(); return GoogleMapsFlutterPlatform.instance.getVisibleRegion(mapId: mapId); } @@ -333,6 +339,7 @@ class GoogleMapController { /// Screen location is in screen pixels (not display pixels) with respect to the top left corner /// of the map, not necessarily of the whole screen. Future getScreenCoordinate(LatLng latLng) { + _checkWidgetMountedOrThrow(); return GoogleMapsFlutterPlatform.instance.getScreenCoordinate( latLng, mapId: mapId, @@ -344,6 +351,7 @@ class GoogleMapController { /// Returned [LatLng] corresponds to a screen location. The screen location is specified in screen /// pixels (not display pixels) relative to the top left of the map, not top left of the whole screen. Future getLatLng(ScreenCoordinate screenCoordinate) { + _checkWidgetMountedOrThrow(); return GoogleMapsFlutterPlatform.instance.getLatLng( screenCoordinate, mapId: mapId, @@ -359,6 +367,7 @@ class GoogleMapController { /// * [hideMarkerInfoWindow] to hide the Info Window. /// * [isMarkerInfoWindowShown] to check if the Info Window is showing. Future showMarkerInfoWindow(MarkerId markerId) { + _checkWidgetMountedOrThrow(); return GoogleMapsFlutterPlatform.instance.showMarkerInfoWindow( markerId, mapId: mapId, @@ -374,6 +383,7 @@ class GoogleMapController { /// * [showMarkerInfoWindow] to show the Info Window. /// * [isMarkerInfoWindowShown] to check if the Info Window is showing. Future hideMarkerInfoWindow(MarkerId markerId) { + _checkWidgetMountedOrThrow(); return GoogleMapsFlutterPlatform.instance.hideMarkerInfoWindow( markerId, mapId: mapId, @@ -389,6 +399,7 @@ class GoogleMapController { /// * [showMarkerInfoWindow] to show the Info Window. /// * [hideMarkerInfoWindow] to hide the Info Window. Future isMarkerInfoWindowShown(MarkerId markerId) { + _checkWidgetMountedOrThrow(); return GoogleMapsFlutterPlatform.instance.isMarkerInfoWindowShown( markerId, mapId: mapId, @@ -397,11 +408,13 @@ class GoogleMapController { /// Returns the current zoom level of the map Future getZoomLevel() { + _checkWidgetMountedOrThrow(); return GoogleMapsFlutterPlatform.instance.getZoomLevel(mapId: mapId); } /// Returns the image bytes of the map Future takeSnapshot() { + _checkWidgetMountedOrThrow(); return GoogleMapsFlutterPlatform.instance.takeSnapshot(mapId: mapId); } @@ -414,4 +427,21 @@ class GoogleMapController { _streamSubscriptions.clear(); GoogleMapsFlutterPlatform.instance.dispose(mapId: mapId); } + + /// It is relatively easy to mistakenly call a method on the controller + /// after the [GoogleMap] widget has already been disposed. + /// Historically, this led to Platform-side errors such as + /// `MissingPluginException` or `Unable to establish connection on channel` + /// errors. + /// + /// To facilitate debugging, this guard function + /// raises a use-after-disposed [StateError]. + void _checkWidgetMountedOrThrow() { + if (!_googleMapState.mounted) { + throw StateError( + 'GoogleMapController for map ID $mapId was used after ' + 'the associated GoogleMap widget had already been disposed.', + ); + } + } } diff --git a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml index dfa32974c48..a271bd524b4 100644 --- a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter description: A Flutter plugin for integrating Google Maps in iOS and Android applications. repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.13.1 +version: 2.14.0 environment: sdk: ^3.7.0 diff --git a/packages/google_maps_flutter/google_maps_flutter/test/google_map_controller_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/google_map_controller_test.dart new file mode 100644 index 00000000000..079b536a903 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/test/google_map_controller_test.dart @@ -0,0 +1,62 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +import 'fake_google_maps_flutter_platform.dart'; + +void main() { + late FakeGoogleMapsFlutterPlatform platform; + + setUp(() { + platform = FakeGoogleMapsFlutterPlatform(); + GoogleMapsFlutterPlatform.instance = platform; + }); + + testWidgets('onMapCreated is called with controller', ( + WidgetTester tester, + ) async { + GoogleMapController? controller; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + initialCameraPosition: const CameraPosition(target: LatLng(0.0, 0.0)), + onMapCreated: (GoogleMapController value) => controller = value, + ), + ), + ); + + expect(controller, isNotNull); + await expectLater(controller?.getZoomLevel(), isNotNull); + }); + + testWidgets('controller throws when used after dispose', ( + WidgetTester tester, + ) async { + GoogleMapController? controller; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + initialCameraPosition: const CameraPosition(target: LatLng(0.0, 0.0)), + onMapCreated: (GoogleMapController value) => controller = value, + ), + ), + ); + + // Now dispose of the map... + await tester.pumpWidget(Container()); + + await expectLater( + () => controller?.getZoomLevel(), + throwsA(isA()), + ); + }); +}