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 5fe6a9812b02..13ea071ea8c4 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.1 + +* Batches clustered marker add/remove operations to avoid redundant re-rendering. + ## 0.6.0 * **BREAKING CHANGES**: Adds type constraints to generic type parameters: 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 1a3132e13d40..5cc202d21fc6 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,8 @@ void main() { const initialCameraPosition = CameraPosition(target: mapCenter); group('MarkersController', () { - const testMapId = 33930; - testWidgets('Marker clustering', (WidgetTester tester) async { + const testMapId = 33930; const clusterManagerId = ClusterManagerId('cluster 1'); final clusterManagers = { @@ -67,8 +68,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 +104,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, + ), + }; + + const 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 9af2c5856654..714c03811641 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 @@ -5,6 +5,7 @@ import 'dart:js_interop'; import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:google_maps/google_maps.dart' as gmaps; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; @@ -150,4 +151,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.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker.dart index 70b99f417216..ca443c4b8441 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker.dart @@ -96,6 +96,9 @@ abstract class MarkerController { /// /// This cannot be called after [remove]. void showInfoWindow(); + + /// Sets the map of the wrapped marker object. + void setMap(gmaps.Map map); } /// A `MarkerController` that wraps a [gmaps.Marker] object. @@ -203,6 +206,9 @@ class LegacyMarkerController _infoWindow.content = newInfoWindowContent; } } + + @override + void setMap(gmaps.Map map) => _marker?.map = map; } /// A `MarkerController` that wraps a [gmaps.AdvancedMarkerElement] object. @@ -324,4 +330,7 @@ class AdvancedMarkerController _infoWindow.content = newInfoWindowContent; } } + + @override + void setMap(gmaps.Map map) => _marker?.map = map; } 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 a09ca329e828..8fb0e57e8ae4 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 @@ -9,9 +9,19 @@ import 'package:google_maps/google_maps.dart' as gmaps; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; import '../google_maps_flutter_web.dart'; -import 'marker_clustering_js_interop.dart'; +import 'marker_clustering_js_interop.dart' hide getClustererEvents; +import 'marker_clustering_js_interop.dart' as interop show getClustererEvents; 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] @@ -84,8 +94,19 @@ class ClusterManagersController extends GeometryController { } } - /// Removes given marker from the [MarkerClusterer] with - /// given [ClusterManagerId]. + /// 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, T? marker) { if (marker != null) { final MarkerClusterer? markerClusterer = @@ -97,6 +118,19 @@ class ClusterManagersController extends GeometryController { } } + /// Removes given markers 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) { @@ -113,6 +147,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 fc639778706b..1fa5a697f8bf 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 @@ -116,7 +129,8 @@ 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?.cast().toList().toJS, noDraw); + @JS('addMarkers') external void _addMarkers(JSArray? markers, bool? noDraw); @@ -128,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?.cast().toList().toJS, noDraw); @JS('removeMarkers') external bool _removeMarkers(JSArray? markers, bool? noDraw); @@ -163,3 +177,29 @@ MarkerClusterer createMarkerClusterer( ); return MarkerClusterer(options); } + +/// Converts events emitted by the clustering manager during rendering into a stream +Stream getClustererEvents(MarkerClusterer clusterer) { + 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 7dd31d1c5448..4ded27038e5f 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 @@ -44,10 +44,44 @@ abstract class MarkersController /// /// 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> markerControllers = await Future.wait( + entry.value.map(_createMarker), + ); + + final ClusterManagerId? clusterManagerId = entry.key; + if (clusterManagerId != null) { + _clusterManagersController.addItems( + clusterManagerId, + markerControllers + .map((controller) => controller.marker) + .whereType() + .toList(), + ); + } else { + for (final controller in markerControllers) { + controller.setMap(googleMap); + } + } + } } Future _addMarker(Marker marker) async { + final MarkerController controler = await _createMarker(marker); + final ClusterManagerId? clusterManagerId = marker.clusterManagerId; + + if (clusterManagerId != null && controler.marker != null) { + _clusterManagersController.addItem(clusterManagerId, controler.marker!); + } else { + controler.setMap(googleMap); + } + } + + Future> _createMarker(Marker marker) async { final gmaps.InfoWindowOptions? infoWindowOptions = _infoWindowOptionsFromMarker(marker); gmaps.InfoWindow? gmInfoWindow; @@ -79,6 +113,8 @@ abstract class MarkersController gmInfoWindow, ); _markerIdToController[marker.markerId] = controller; + + return controller; } /// Creates a [MarkerController] for the given [marker]. @@ -128,7 +164,50 @@ abstract class MarkersController /// Removes a set of [MarkerId]s from the cache. void removeMarkers(Set markerIdsToRemove) { - markerIdsToRemove.forEach(_removeMarker); + final List?>> markersControllers = + markerIdsToRemove + .map( + (MarkerId markerId) => + MapEntry?>( + markerId, + _markerIdToController[markerId], + ), + ) + .toList(); + + 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) { @@ -235,12 +314,6 @@ class LegacyMarkersController final gmMarker = gmaps.Marker(markerOptions); gmMarker.set('markerId', marker.markerId.value.toJS); - if (marker.clusterManagerId != null) { - _clusterManagersController.addItem(marker.clusterManagerId!, gmMarker); - } else { - gmMarker.map = googleMap; - } - return LegacyMarkerController( marker: gmMarker, clusterManagerId: marker.clusterManagerId, @@ -288,12 +361,6 @@ class AdvancedMarkersController final gmMarker = gmaps.AdvancedMarkerElement(markerOptions); gmMarker.setAttribute('id', marker.markerId.value); - if (marker.clusterManagerId != null) { - _clusterManagersController.addItem(marker.clusterManagerId!, gmMarker); - } else { - gmMarker.map = googleMap; - } - return AdvancedMarkerController( marker: gmMarker, clusterManagerId: marker.clusterManagerId, 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 e62af433d3b7..79c431e2a569 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.6.0 +version: 0.6.1 environment: sdk: ^3.9.0