diff --git a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md index 149dba55020..3c66ad3e30e 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md @@ -1,6 +1,6 @@ -## NEXT +## 0.5.15 -* Updates minimum supported SDK version to Flutter 3.35/Dart 3.9. +* Batches clustered marker add/remove operations to avoid redundant re-rendering. ## 0.5.14+3 diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_clustering_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_clustering_test.dart index 003934e6168..69b2c891f00 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_clustering_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_clustering_test.dart @@ -9,6 +9,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; +import 'package:google_maps_flutter_web/src/google_maps_inspector_web.dart'; +import 'package:google_maps_flutter_web/src/marker_clustering.dart'; import 'package:integration_test/integration_test.dart'; void main() { @@ -23,9 +25,14 @@ void main() { const initialCameraPosition = CameraPosition(target: mapCenter); group('MarkersController', () { - const testMapId = 33930; + late int testMapId; + + tearDown(() { + plugin.dispose(mapId: testMapId); + }); testWidgets('Marker clustering', (WidgetTester tester) async { + testMapId = 33930; const clusterManagerId = ClusterManagerId('cluster 1'); final clusterManagers = { @@ -67,8 +74,6 @@ void main() { final int mapId = await mapIdCompleter.future; expect(mapId, equals(testMapId)); - addTearDown(() => plugin.dispose(mapId: mapId)); - final List clusters = await waitForValueMatchingPredicate>( tester, @@ -105,6 +110,83 @@ void main() { expect(updatedClusters.length, 0); }); + + testWidgets('clusters render once per batched add', ( + WidgetTester tester, + ) async { + const clusterManagerId = ClusterManagerId('cluster 1'); + + final clusterManagers = { + const ClusterManager(clusterManagerId: clusterManagerId), + }; + + // Create the marker with clusterManagerId. + final initialMarkers = { + for (var i = 0; i < 3; i++) + Marker( + markerId: MarkerId(i.toString()), + position: mapCenter, + clusterManagerId: clusterManagerId, + ), + }; + + final markersCluster1 = { + for (var i = 3; i < 7; i++) + Marker( + markerId: MarkerId(i.toString()), + clusterManagerId: clusterManagerId, + position: mapCenter, + ), + }; + + testMapId = 33931; + final events = StreamController(); + await _pumpMap( + tester, + plugin.buildViewWithConfiguration( + testMapId, + (int id) async { + final StreamSubscription? subscription = + (inspector as GoogleMapsInspectorWeb) + .getClusteringEvents( + mapId: testMapId, + clusterManagerId: clusterManagerId, + ) + ?.listen(events.add); + + await plugin.updateMarkers( + MarkerUpdates.from(initialMarkers, markersCluster1), + mapId: testMapId, + ); + + await Future.delayed(const Duration(seconds: 1)); + await subscription?.cancel(); + await events.close(); + }, + widgetConfiguration: const MapWidgetConfiguration( + initialCameraPosition: initialCameraPosition, + textDirection: TextDirection.ltr, + ), + mapObjects: MapObjects( + clusterManagers: clusterManagers, + markers: initialMarkers, + ), + ), + ); + + await expectLater( + events.stream, + emitsInAnyOrder([ + //Once per initial markers + ClusteringEvent.begin, + ClusteringEvent.end, + //Once per new cluster + ClusteringEvent.begin, + ClusteringEvent.end, + emitsDone, + ]), + ); + }); }); } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_inspector_web.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_inspector_web.dart index b14a8e2812b..9bcb91b45ce 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_inspector_web.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_inspector_web.dart @@ -3,8 +3,8 @@ // found in the LICENSE file. import 'dart:js_interop'; -import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:google_maps/google_maps.dart' as gmaps; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; @@ -150,4 +150,15 @@ class GoogleMapsInspectorWeb extends GoogleMapsInspectorPlatform { )?.getClusters(clusterManagerId) ?? []; } + + /// Returns the stream of clustering events for a given [ClusterManager]. + @visibleForTesting + Stream? getClusteringEvents({ + required int mapId, + required ClusterManagerId clusterManagerId, + }) { + return _clusterManagersControllerProvider( + mapId, + )?.getClustererEvents(clusterManagerId); + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering.dart index 0f235dfc615..bfd7baa7a97 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering.dart @@ -10,8 +10,18 @@ import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platf import '../google_maps_flutter_web.dart'; import 'marker_clustering_js_interop.dart'; +import 'marker_clustering_js_interop.dart' as interop; import 'types.dart'; +/// Events emitted by the marker clustering lifecycle. +enum ClusteringEvent { + /// Clustering has started. + begin, + + /// Clustering finished and clusters are available. + end, +} + /// A controller class for managing marker clustering. /// /// This class maps [ClusterManager] objects to javascript [MarkerClusterer] @@ -82,6 +92,17 @@ class ClusterManagersController extends GeometryController { } } + /// Adds given list of [gmaps.Marker] to the [MarkerClusterer] with given + /// [ClusterManagerId]. + void addItems(ClusterManagerId clusterManagerId, List markers) { + final MarkerClusterer? markerClusterer = + _clusterManagerIdToMarkerClusterer[clusterManagerId]; + if (markerClusterer != null) { + markerClusterer.addMarkers(markers, true); + markerClusterer.render(); + } + } + /// Removes given [gmaps.Marker] from the [MarkerClusterer] with given /// [ClusterManagerId]. void removeItem(ClusterManagerId clusterManagerId, gmaps.Marker? marker) { @@ -95,6 +116,22 @@ class ClusterManagersController extends GeometryController { } } + /// Removes given [gmaps.Marker] from the [MarkerClusterer] with given + /// [ClusterManagerId]. + void removeItems( + ClusterManagerId clusterManagerId, + List? markers, + ) { + if (markers != null) { + final MarkerClusterer? markerClusterer = + _clusterManagerIdToMarkerClusterer[clusterManagerId]; + if (markerClusterer != null) { + markerClusterer.removeMarkers(markers, true); + markerClusterer.render(); + } + } + } + /// Returns list of clusters in [MarkerClusterer] with given /// [ClusterManagerId]. List getClusters(ClusterManagerId clusterManagerId) { @@ -111,6 +148,13 @@ class ClusterManagersController extends GeometryController { return []; } + /// Returns the stream of clustering lifecycle events for the given manager. + Stream? getClustererEvents( + ClusterManagerId clusterManagerId, + ) => interop.getClustererEvents( + _clusterManagerIdToMarkerClusterer[clusterManagerId]!, + ); + void _clusterClicked( ClusterManagerId clusterManagerId, gmaps.MapMouseEvent event, diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering_js_interop.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering_js_interop.dart index d5fca430e76..fa0d2ca4012 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering_js_interop.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering_js_interop.dart @@ -8,10 +8,13 @@ @JS() library; +import 'dart:async'; import 'dart:js_interop'; import 'package:google_maps/google_maps.dart' as gmaps; +import 'marker_clustering.dart'; + /// A typedef representing a callback function for handling cluster tap events. typedef ClusterClickHandler = void Function(gmaps.MapMouseEvent, MarkerClustererCluster, gmaps.Map); @@ -64,6 +67,16 @@ extension type MarkerClustererOptions._(JSObject _) implements JSObject { external JSExportedDartFunction? get _onClusterClick; } +@JS('google.maps.event.addListener') +external JSAny _gmapsAddListener( + JSAny instance, + String eventName, + JSFunction handler, +); + +@JS('google.maps.event.removeListener') +external void _gmapsRemoveListener(JSAny listenerHandle); + /// The cluster object handled by the [MarkerClusterer]. /// /// https://googlemaps.github.io/js-markerclusterer/classes/Cluster.html @@ -117,7 +130,7 @@ extension type MarkerClusterer._(JSObject _) implements JSObject { /// Adds a list of markers to be clustered by the [MarkerClusterer]. void addMarkers(List? markers, bool? noDraw) => - _addMarkers(markers?.cast().toJS, noDraw); + _addMarkers(markers?.toJS, noDraw); @JS('addMarkers') external void _addMarkers(JSArray? markers, bool? noDraw); @@ -129,7 +142,7 @@ extension type MarkerClusterer._(JSObject _) implements JSObject { /// Removes a list of markers from the [MarkerClusterer]. bool removeMarkers(List? markers, bool? noDraw) => - _removeMarkers(markers?.cast().toJS, noDraw); + _removeMarkers(markers?.toJS, noDraw); @JS('removeMarkers') external bool _removeMarkers(JSArray? markers, bool? noDraw); @@ -164,3 +177,29 @@ MarkerClusterer createMarkerClusterer( ); return MarkerClusterer(options); } + +///Converts events that clustering manager emits during rendering to stream +Stream getClustererEvents(MarkerClusterer clusterer) { + late final StreamController controller; + + final JSAny beginHandle = _gmapsAddListener( + clusterer, + 'clusteringbegin', + ((JSAny mc) => controller.add(ClusteringEvent.begin)).toJS, + ); + + final JSAny endHandle = _gmapsAddListener( + clusterer, + 'clusteringend', + ((JSAny mc) => controller.add(ClusteringEvent.end)).toJS, + ); + + controller = StreamController( + onCancel: () { + _gmapsRemoveListener(beginHandle); + _gmapsRemoveListener(endHandle); + }, + ); + + return controller.stream; +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart index a0dc2e361c4..a921ec3afc5 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart @@ -30,10 +30,34 @@ class MarkersController extends GeometryController { /// /// Wraps each [Marker] into its corresponding [MarkerController]. Future addMarkers(Set markersToAdd) async { - await Future.wait(markersToAdd.map(_addMarker)); + final Map> markersByClusters = markersToAdd + .groupListsBy((Marker marker) => marker.clusterManagerId); + + for (final MapEntry> entry + in markersByClusters.entries) { + final List markers = await Future.wait( + entry.value.map(_createMarker), + ); + if (entry.key != null) { + _clusterManagersController.addItems(entry.key!, markers); + } else { + for (final marker in markers) { + marker.map = googleMap; + } + } + } } Future _addMarker(Marker marker) async { + final gmaps.Marker gmapMarker = await _createMarker(marker); + if (marker.clusterManagerId != null) { + _clusterManagersController.addItem(marker.clusterManagerId!, gmapMarker); + } else { + gmapMarker.map = googleMap; + } + } + + Future _createMarker(Marker marker) async { final gmaps.InfoWindowOptions? infoWindowOptions = _infoWindowOptionsFromMarker(marker); gmaps.InfoWindow? gmInfoWindow; @@ -64,12 +88,6 @@ class MarkersController extends GeometryController { gmMarker.set('markerId', marker.markerId.value.toJS); - if (marker.clusterManagerId != null) { - _clusterManagersController.addItem(marker.clusterManagerId!, gmMarker); - } else { - gmMarker.map = googleMap; - } - final controller = MarkerController( marker: gmMarker, clusterManagerId: marker.clusterManagerId, @@ -90,6 +108,8 @@ class MarkersController extends GeometryController { }, ); _markerIdToController[marker.markerId] = controller; + + return gmMarker; } /// Updates a set of [Marker] objects with new options. @@ -124,7 +144,47 @@ class MarkersController extends GeometryController { /// Removes a set of [MarkerId]s from the cache. void removeMarkers(Set markerIdsToRemove) { - markerIdsToRemove.forEach(_removeMarker); + final Iterable> markersControllers = + markerIdsToRemove.map( + (MarkerId markerId) => MapEntry( + markerId, + _markerIdToController[markerId], + ), + ); + + final Map> controllersByCluster = + markersControllers + .groupListsBy( + (MapEntry markerControler) => + markerControler.value?._clusterManagerId, + ) + .map( + ( + ClusterManagerId? key, + List> value, + ) => MapEntry>( + key, + value + .map( + (MapEntry x) => + x.value?.marker, + ) + .whereType() + .toList(), + ), + ); + + for (final MapEntry> entry + in controllersByCluster.entries) { + if (entry.key != null) { + _clusterManagersController.removeItems(entry.key!, entry.value); + } + } + + for (final markerController in markersControllers) { + markerController.value?.remove(); + _markerIdToController.remove(markerController.key); + } } void _removeMarker(MarkerId markerId) { diff --git a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml index f87b3ea66e8..f2b141b9031 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_web description: Web platform implementation of google_maps_flutter repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 0.5.14+3 +version: 0.5.15 environment: sdk: ^3.9.0