Skip to content
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> clearTileCache(TileOverlayId tileOverlayId) async {
_checkWidgetMountedOrThrow();
return GoogleMapsFlutterPlatform.instance.clearTileCache(
tileOverlayId,
mapId: mapId,
Expand All @@ -278,6 +279,7 @@ class GoogleMapController {
/// The returned [Future] completes after the change has been started on the
/// platform side.
Future<void> animateCamera(CameraUpdate cameraUpdate, {Duration? duration}) {
_checkWidgetMountedOrThrow();
return GoogleMapsFlutterPlatform.instance.animateCameraWithConfiguration(
cameraUpdate,
CameraUpdateAnimationConfiguration(duration: duration),
Expand All @@ -290,6 +292,7 @@ class GoogleMapController {
/// The returned [Future] completes after the change has been made on the
/// platform side.
Future<void> moveCamera(CameraUpdate cameraUpdate) {
_checkWidgetMountedOrThrow();
return GoogleMapsFlutterPlatform.instance.moveCamera(
cameraUpdate,
mapId: mapId,
Expand All @@ -311,6 +314,7 @@ class GoogleMapController {
/// style reference for more information regarding the supported styles.
@Deprecated('Use GoogleMap.style instead.')
Future<void> setMapStyle(String? mapStyle) {
_checkWidgetMountedOrThrow();
return GoogleMapsFlutterPlatform.instance.setMapStyle(
mapStyle,
mapId: mapId,
Expand All @@ -319,11 +323,13 @@ class GoogleMapController {

/// Returns the last style error, if any.
Future<String?> getStyleError() {
_checkWidgetMountedOrThrow();
return GoogleMapsFlutterPlatform.instance.getStyleError(mapId: mapId);
}

/// Return [LatLngBounds] defining the region that is visible in a map.
Future<LatLngBounds> getVisibleRegion() {
_checkWidgetMountedOrThrow();
return GoogleMapsFlutterPlatform.instance.getVisibleRegion(mapId: mapId);
}

Expand All @@ -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<ScreenCoordinate> getScreenCoordinate(LatLng latLng) {
_checkWidgetMountedOrThrow();
return GoogleMapsFlutterPlatform.instance.getScreenCoordinate(
latLng,
mapId: mapId,
Expand All @@ -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<LatLng> getLatLng(ScreenCoordinate screenCoordinate) {
_checkWidgetMountedOrThrow();
return GoogleMapsFlutterPlatform.instance.getLatLng(
screenCoordinate,
mapId: mapId,
Expand All @@ -359,6 +367,7 @@ class GoogleMapController {
/// * [hideMarkerInfoWindow] to hide the Info Window.
/// * [isMarkerInfoWindowShown] to check if the Info Window is showing.
Future<void> showMarkerInfoWindow(MarkerId markerId) {
_checkWidgetMountedOrThrow();
return GoogleMapsFlutterPlatform.instance.showMarkerInfoWindow(
markerId,
mapId: mapId,
Expand All @@ -374,6 +383,7 @@ class GoogleMapController {
/// * [showMarkerInfoWindow] to show the Info Window.
/// * [isMarkerInfoWindowShown] to check if the Info Window is showing.
Future<void> hideMarkerInfoWindow(MarkerId markerId) {
_checkWidgetMountedOrThrow();
return GoogleMapsFlutterPlatform.instance.hideMarkerInfoWindow(
markerId,
mapId: mapId,
Expand All @@ -389,6 +399,7 @@ class GoogleMapController {
/// * [showMarkerInfoWindow] to show the Info Window.
/// * [hideMarkerInfoWindow] to hide the Info Window.
Future<bool> isMarkerInfoWindowShown(MarkerId markerId) {
_checkWidgetMountedOrThrow();
return GoogleMapsFlutterPlatform.instance.isMarkerInfoWindowShown(
markerId,
mapId: mapId,
Expand All @@ -397,11 +408,13 @@ class GoogleMapController {

/// Returns the current zoom level of the map
Future<double> getZoomLevel() {
_checkWidgetMountedOrThrow();
return GoogleMapsFlutterPlatform.instance.getZoomLevel(mapId: mapId);
}

/// Returns the image bytes of the map
Future<Uint8List?> takeSnapshot() {
_checkWidgetMountedOrThrow();
return GoogleMapsFlutterPlatform.instance.takeSnapshot(mapId: mapId);
}

Expand All @@ -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.',
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<StateError>()),
);
});
}