diff --git a/packages/google_maps_flutter/google_maps_flutter/example/integration_test/google_maps_test.dart b/packages/google_maps_flutter/google_maps_flutter/example/integration_test/google_maps_test.dart index 38a02ea0d8f1..5f9ce5bc3898 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/integration_test/google_maps_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/integration_test/google_maps_test.dart @@ -1161,6 +1161,107 @@ void main() { expect(tileOverlayInfo1, isNull); }, ); + + testWidgets('marker clustering', (WidgetTester tester) async { + final Key key = GlobalKey(); + const int clusterManagersAmount = 2; + const int markersPerClusterManager = 5; + final Map markers = {}; + final Set clusterManagers = {}; + + for (int i = 0; i < clusterManagersAmount; i++) { + final ClusterManagerId clusterManagerId = + ClusterManagerId('cluster_manager_$i'); + final ClusterManager clusterManager = + ClusterManager(clusterManagerId: clusterManagerId); + clusterManagers.add(clusterManager); + } + + for (final ClusterManager cm in clusterManagers) { + for (int i = 0; i < markersPerClusterManager; i++) { + final MarkerId markerId = + MarkerId('${cm.clusterManagerId.value}_marker_$i'); + final Marker marker = Marker( + markerId: markerId, + clusterManagerId: cm.clusterManagerId, + position: LatLng( + _kInitialMapCenter.latitude + i, _kInitialMapCenter.longitude)); + markers[markerId] = marker; + } + } + + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + key: key, + initialCameraPosition: _kInitialCameraPosition, + clusterManagers: clusterManagers, + markers: Set.of(markers.values), + onMapCreated: (GoogleMapController googleMapController) { + controllerCompleter.complete(googleMapController); + }, + ), + )); + + final GoogleMapController controller = await controllerCompleter.future; + + final GoogleMapsInspectorPlatform inspector = + GoogleMapsInspectorPlatform.instance!; + + for (final ClusterManager cm in clusterManagers) { + final List clusters = await inspector.getClusters( + mapId: controller.mapId, clusterManagerId: cm.clusterManagerId); + final int markersAmountForClusterManager = clusters + .map((Cluster cluster) => cluster.count) + .reduce((int value, int element) => value + element); + expect(markersAmountForClusterManager, markersPerClusterManager); + } + + // Remove markers from clusterManagers and test that clusterManagers are empty. + for (final MapEntry entry in markers.entries) { + markers[entry.key] = _copyMarkerWithClusterManagerId(entry.value, null); + } + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + key: key, + initialCameraPosition: _kInitialCameraPosition, + clusterManagers: clusterManagers, + markers: Set.of(markers.values)), + )); + + for (final ClusterManager cm in clusterManagers) { + final List clusters = await inspector.getClusters( + mapId: controller.mapId, clusterManagerId: cm.clusterManagerId); + expect(clusters.length, 0); + } + }); +} + +Marker _copyMarkerWithClusterManagerId( + Marker marker, ClusterManagerId? clusterManagerId) { + return Marker( + markerId: marker.markerId, + alpha: marker.alpha, + anchor: marker.anchor, + consumeTapEvents: marker.consumeTapEvents, + draggable: marker.draggable, + flat: marker.flat, + icon: marker.icon, + infoWindow: marker.infoWindow, + position: marker.position, + rotation: marker.rotation, + visible: marker.visible, + zIndex: marker.zIndex, + onTap: marker.onTap, + onDragStart: marker.onDragStart, + onDrag: marker.onDrag, + onDragEnd: marker.onDragEnd, + clusterManagerId: clusterManagerId, + ); } class _DebugTileProvider implements TileProvider { diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/clustering.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/clustering.dart new file mode 100644 index 000000000000..7882c2d3191f --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/clustering.dart @@ -0,0 +1,237 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: public_member_api_docs + +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +import 'page.dart'; + +class ClusteringPage extends GoogleMapExampleAppPage { + const ClusteringPage({Key? key}) + : super(const Icon(Icons.place), 'Manage clustering', key: key); + + @override + Widget build(BuildContext context) { + return const ClusteringBody(); + } +} + +class ClusteringBody extends StatefulWidget { + const ClusteringBody({Key? key}) : super(key: key); + + @override + State createState() => ClusteringBodyState(); +} + +typedef MarkerUpdateAction = Marker Function(Marker marker); + +class ClusteringBodyState extends State { + ClusteringBodyState(); + static const LatLng center = LatLng(-33.86711, 151.1547171); + static const double _scaleFactor = 0.05; + + GoogleMapController? controller; + Map clusterManagers = + {}; + Map markers = {}; + MarkerId? selectedMarker; + int _clusterManagerIdCounter = 1; + int _markerIdCounter = 1; + Cluster? lastCluster; + + // ignore: use_setters_to_change_properties + void _onMapCreated(GoogleMapController controller) { + this.controller = controller; + } + + @override + void dispose() { + super.dispose(); + } + + void _onMarkerTapped(MarkerId markerId) { + final Marker? tappedMarker = markers[markerId]; + if (tappedMarker != null) { + setState(() { + final MarkerId? previousMarkerId = selectedMarker; + if (previousMarkerId != null && markers.containsKey(previousMarkerId)) { + final Marker resetOld = markers[previousMarkerId]! + .copyWith(iconParam: BitmapDescriptor.defaultMarker); + markers[previousMarkerId] = resetOld; + } + selectedMarker = markerId; + final Marker newMarker = tappedMarker.copyWith( + iconParam: BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueGreen, + ), + ); + markers[markerId] = newMarker; + }); + } + } + + void _addClusterManager() { + final int clusterManagerCount = clusterManagers.length; + + if (clusterManagerCount == 3) { + return; + } + + final String clusterManagerIdVal = + 'cluster_manager_id_$_clusterManagerIdCounter'; + _clusterManagerIdCounter++; + final ClusterManagerId clusterManagerId = + ClusterManagerId(clusterManagerIdVal); + + final ClusterManager clusterManager = ClusterManager( + clusterManagerId: clusterManagerId, + onClusterTap: (Cluster cluster) => setState(() { + lastCluster = cluster; + }), + ); + + setState(() { + clusterManagers[clusterManagerId] = clusterManager; + }); + _addMarkersToCluster(clusterManager); + } + + void _removeClusterManager(ClusterManager clusterManager) { + setState(() { + // Remove markers managed by cluster manager to be removed. + markers.removeWhere((MarkerId key, Marker marker) => + marker.clusterManagerId == clusterManager.clusterManagerId); + // Remove cluster manager. + clusterManagers.remove(clusterManager.clusterManagerId); + }); + } + + void _addMarkersToCluster(ClusterManager clusterManager) { + for (int i = 0; i < 15; i++) { + final String markerIdVal = + '${clusterManager.clusterManagerId.value}_marker_id_$_markerIdCounter'; + _markerIdCounter++; + final MarkerId markerId = MarkerId(markerIdVal); + + final int clusterManagerIndex = + clusterManagers.values.toList().indexOf(clusterManager); + final Marker marker = Marker( + clusterManagerId: clusterManager.clusterManagerId, + markerId: markerId, + position: LatLng( + center.latitude + _getRandomOffset(), + center.longitude + + _getRandomOffset() + + clusterManagerIndex * _scaleFactor * 2, + ), + infoWindow: InfoWindow(title: markerIdVal, snippet: '*'), + onTap: () => _onMarkerTapped(markerId), + ); + markers[markerId] = marker; + } + setState(() {}); + } + + double _getRandomOffset() { + return (Random().nextDouble() - 0.5) * _scaleFactor; + } + + void _remove(MarkerId markerId) { + setState(() { + if (markers.containsKey(markerId)) { + markers.remove(markerId); + } + }); + } + + void _changeMarkersRotation() { + for (final MarkerId markerId in markers.keys) { + final Marker marker = markers[markerId]!; + final double current = marker.rotation; + markers[markerId] = marker.copyWith( + rotationParam: current == 315.0 ? 0.0 : current + 45.0, + ); + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final MarkerId? selectedId = selectedMarker; + return Stack(children: [ + Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: GoogleMap( + onMapCreated: _onMapCreated, + initialCameraPosition: const CameraPosition( + target: LatLng(-33.852, 151.211), + zoom: 11.0, + ), + markers: Set.of(markers.values), + clusterManagers: Set.of(clusterManagers.values), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: () => _addClusterManager(), + child: const Text('Add cluster manager'), + ), + TextButton( + onPressed: clusterManagers.isEmpty + ? null + : () => _removeClusterManager(clusterManagers.values.last), + child: const Text('Remove cluster manager'), + ), + ], + ), + Wrap( + alignment: WrapAlignment.spaceEvenly, + children: [ + for (final MapEntry clusterEntry + in clusterManagers.entries) + TextButton( + onPressed: () => _addMarkersToCluster(clusterEntry.value), + child: Text('Add markers to ${clusterEntry.key.value}'), + ), + ], + ), + Wrap( + alignment: WrapAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: selectedId == null + ? null + : () { + _remove(selectedId); + setState(() { + selectedMarker = null; + }); + }, + child: const Text('Remove selected marker'), + ), + TextButton( + onPressed: + markers.isEmpty ? null : () => _changeMarkersRotation(), + child: const Text('Change all markers rotation'), + ), + ], + ), + if (lastCluster != null) + Padding( + padding: const EdgeInsets.all(10), + child: Text( + 'Cluster with ${lastCluster!.count} markers clicked at ${lastCluster!.position}')), + ], + ), + ]); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart index 60d4fdd95dcf..0625db036e0e 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart @@ -7,6 +7,7 @@ import 'package:google_maps_flutter_android/google_maps_flutter_android.dart'; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; import 'animate_camera.dart'; +import 'clustering.dart'; import 'lite_mode.dart'; import 'map_click.dart'; import 'map_coordinates.dart'; @@ -39,6 +40,7 @@ final List _allPages = [ const SnapshotPage(), const LiteModePage(), const TileOverlayPage(), + const ClusteringPage(), ]; /// MapsDemo is the Main Application. diff --git a/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml index 5813d42e617e..a65019c387a6 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml @@ -35,3 +35,13 @@ flutter: uses-material-design: true assets: - assets/ + + +# FOR TESTING ONLY. DO NOT MERGE. +dependency_overrides: + google_maps_flutter_android: + path: ../../../google_maps_flutter/google_maps_flutter_android + google_maps_flutter_ios: + path: ../../../google_maps_flutter/google_maps_flutter_ios + google_maps_flutter_platform_interface: + path: ../../../google_maps_flutter/google_maps_flutter_platform_interface diff --git a/packages/google_maps_flutter/google_maps_flutter/lib/google_maps_flutter.dart b/packages/google_maps_flutter/google_maps_flutter/lib/google_maps_flutter.dart index a4be120b2117..b90356a372b2 100644 --- a/packages/google_maps_flutter/google_maps_flutter/lib/google_maps_flutter.dart +++ b/packages/google_maps_flutter/google_maps_flutter/lib/google_maps_flutter.dart @@ -28,6 +28,9 @@ export 'package:google_maps_flutter_platform_interface/google_maps_flutter_platf Cap, Circle, CircleId, + Cluster, + ClusterManager, + ClusterManagerId, InfoWindow, JointType, LatLng, 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 cd3d0781e471..6bc07e5c3277 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 @@ -80,6 +80,9 @@ class GoogleMapController { .listen((MapTapEvent e) => _googleMapState.onTap(e.position)); GoogleMapsFlutterPlatform.instance.onLongPress(mapId: mapId).listen( (MapLongPressEvent e) => _googleMapState.onLongPress(e.position)); + GoogleMapsFlutterPlatform.instance + .onClusterTap(mapId: mapId) + .listen((ClusterTapEvent e) => _googleMapState.onClusterTap(e.value)); } /// Updates configuration options of the map user interface. @@ -105,6 +108,19 @@ class GoogleMapController { .updateMarkers(markerUpdates, mapId: mapId); } + /// Updates cluster manager configuration. + /// + /// Change listeners are notified once the update has been made on the + /// platform side. + /// + /// The returned [Future] completes after listeners have been notified. + Future _updateClusterManagers( + ClusterManagerUpdates clusterManagerUpdates) { + assert(clusterManagerUpdates != null); + return GoogleMapsFlutterPlatform.instance + .updateClusterManagers(clusterManagerUpdates, mapId: mapId); + } + /// Updates polygon configuration. /// /// Change listeners are notified once the update has been made on the diff --git a/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart b/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart index 1f7871068cab..d7e44c370ae7 100644 --- a/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart +++ b/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart @@ -117,6 +117,7 @@ class GoogleMap extends StatefulWidget { this.polygons = const {}, this.polylines = const {}, this.circles = const {}, + this.clusterManagers = const {}, this.onCameraMoveStarted, this.tileOverlays = const {}, this.onCameraMove, @@ -198,6 +199,9 @@ class GoogleMap extends StatefulWidget { /// Tile overlays to be placed on the map. final Set tileOverlays; + /// Cluster Managers to be placed for the map. + final Set clusterManagers; + /// Called when the camera starts moving. /// /// This can be initiated by the following: @@ -298,6 +302,8 @@ class _GoogleMapState extends State { Map _polygons = {}; Map _polylines = {}; Map _circles = {}; + Map _clusterManagers = + {}; late MapConfiguration _mapConfiguration; @override @@ -313,11 +319,11 @@ class _GoogleMapState extends State { gestureRecognizers: widget.gestureRecognizers, ), mapObjects: MapObjects( - markers: widget.markers, - polygons: widget.polygons, - polylines: widget.polylines, - circles: widget.circles, - ), + markers: widget.markers, + polygons: widget.polygons, + polylines: widget.polylines, + circles: widget.circles, + clusterManagers: widget.clusterManagers), mapConfiguration: _mapConfiguration, ); } @@ -326,6 +332,7 @@ class _GoogleMapState extends State { void initState() { super.initState(); _mapConfiguration = _configurationFromMapWidget(widget); + _clusterManagers = keyByClusterManagerId(widget.clusterManagers); _markers = keyByMarkerId(widget.markers); _polygons = keyByPolygonId(widget.polygons); _polylines = keyByPolylineId(widget.polylines); @@ -347,6 +354,7 @@ class _GoogleMapState extends State { void didUpdateWidget(GoogleMap oldWidget) { super.didUpdateWidget(oldWidget); _updateOptions(); + _updateClusterManagers(); _updateMarkers(); _updatePolygons(); _updatePolylines(); @@ -374,6 +382,14 @@ class _GoogleMapState extends State { _markers = keyByMarkerId(widget.markers); } + Future _updateClusterManagers() async { + final GoogleMapController controller = await _controller.future; + // ignore: unawaited_futures + controller._updateClusterManagers(ClusterManagerUpdates.from( + _clusterManagers.values.toSet(), widget.clusterManagers)); + _clusterManagers = keyByClusterManagerId(widget.clusterManagers); + } + Future _updatePolygons() async { final GoogleMapController controller = await _controller.future; // ignore: unawaited_futures @@ -529,6 +545,20 @@ class _GoogleMapState extends State { onLongPress(position); } } + + void onClusterTap(Cluster cluster) { + assert(cluster != null); + final ClusterManager? clusterManager = + _clusterManagers[cluster.clusterManagerId]; + if (clusterManager == null) { + throw UnknownMapObjectIdError( + 'clusterManager', cluster.clusterManagerId, 'onClusterTap'); + } + final ArgumentCallback? onClusterTap = clusterManager.onClusterTap; + if (onClusterTap != null) { + onClusterTap(cluster); + } + } } /// Builds a [MapConfiguration] from the given [map]. diff --git a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml index 0771314b9e44..94bc1f111c23 100644 --- a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml @@ -28,3 +28,13 @@ dev_dependencies: sdk: flutter plugin_platform_interface: ^2.0.0 stream_transform: ^2.0.0 + + +# FOR TESTING ONLY. DO NOT MERGE. +dependency_overrides: + google_maps_flutter_android: + path: ../../google_maps_flutter/google_maps_flutter_android + google_maps_flutter_ios: + path: ../../google_maps_flutter/google_maps_flutter_ios + google_maps_flutter_platform_interface: + path: ../../google_maps_flutter/google_maps_flutter_platform_interface diff --git a/packages/google_maps_flutter/google_maps_flutter/test/map_creation_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/map_creation_test.dart index 49b64b1b4b2a..1244e62b963f 100644 --- a/packages/google_maps_flutter/google_maps_flutter/test/map_creation_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter/test/map_creation_test.dart @@ -274,6 +274,11 @@ class TestGoogleMapsFlutterPlatform extends GoogleMapsFlutterPlatform { return mapEventStreamController.stream.whereType(); } + @override + Stream onClusterTap({required int mapId}) { + return mapEventStreamController.stream.whereType(); + } + @override void dispose({required int mapId}) { disposed = true; diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/build.gradle b/packages/google_maps_flutter/google_maps_flutter_android/android/build.gradle index 5b383fe3bc86..490573f42862 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/build.gradle +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/build.gradle @@ -38,6 +38,7 @@ android { dependencies { implementation "androidx.annotation:annotation:1.1.0" implementation 'com.google.android.gms:play-services-maps:18.1.0' + implementation 'com.google.maps.android:android-maps-utils:2.4.0' androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test:rules:1.4.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/ClusterListener.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/ClusterListener.java new file mode 100644 index 000000000000..2725f425d054 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/ClusterListener.java @@ -0,0 +1,13 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlemaps; + +import com.google.maps.android.clustering.ClusterManager; + +interface ClusterListener extends ClusterManager.OnClusterClickListener {} + +interface ClusterItemListener + extends ClusterManagersController.onClusterItemMarker, + ClusterManager.OnClusterItemClickListener {} diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/ClusterManagersController.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/ClusterManagersController.java new file mode 100644 index 000000000000..2ba28d0cf3b1 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/ClusterManagersController.java @@ -0,0 +1,222 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlemaps; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import com.google.maps.android.clustering.Cluster; +import com.google.maps.android.clustering.ClusterItem; +import com.google.maps.android.clustering.ClusterManager; +import com.google.maps.android.clustering.view.DefaultClusterRenderer; +import com.google.maps.android.collections.MarkerManager; +import io.flutter.plugin.common.MethodChannel; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Controls cluster managers and exposes interfaces for adding and removing clusteritems for + * specific cluster managers. + */ +class ClusterManagersController implements GoogleMap.OnCameraIdleListener, ClusterListener { + private final Context context; + private final HashMap> clusterManagerIdToManager; + private final MethodChannel methodChannel; + private MarkerManager markerManager; + private GoogleMap googleMap; + private ClusterItemListener clusterItemListener; + + ClusterManagersController(MethodChannel methodChannel, Context context) { + this.clusterManagerIdToManager = new HashMap<>(); + this.context = context; + this.methodChannel = methodChannel; + } + + void init(GoogleMap googleMap, MarkerManager markerManager) { + this.markerManager = markerManager; + this.googleMap = googleMap; + } + + void setClusterItemListener(@Nullable ClusterItemListener listener) { + clusterItemListener = listener; + initListenersForClusterManagers(this, listener); + } + + private void initListenersForClusterManagers( + @Nullable ClusterListener clusterListener, + @Nullable ClusterItemListener clusterItemListener) { + for (Map.Entry> entry : + clusterManagerIdToManager.entrySet()) { + initListenersForClusterManager(entry.getValue(), clusterListener, clusterItemListener); + } + } + + private void initListenersForClusterManager( + ClusterManager clusterManager, + @Nullable ClusterListener clusterListener, + @Nullable ClusterItemListener clusterItemListener) { + clusterManager.setOnClusterClickListener(clusterListener); + clusterManager.setOnClusterItemClickListener(clusterItemListener); + } + + /** Adds new ClusterManagers. */ + void addClusterManagers(List clusterManagersToAdd) { + if (clusterManagersToAdd != null) { + for (Object clusterToAdd : clusterManagersToAdd) { + addClusterManager(clusterToAdd); + } + } + } + + /** Adds new ClusterManager. */ + void addClusterManager(Object clusterManagerData) { + String clusterManagerId = getClusterManagerId(clusterManagerData); + if (clusterManagerId == null) { + throw new IllegalArgumentException("clusterManagerId was null"); + } + ClusterManager clusterManager = + new ClusterManager<>(context, googleMap, markerManager); + ClusterRenderer clusterRenderer = new ClusterRenderer(context, googleMap, clusterManager, this); + clusterManager.setRenderer(clusterRenderer); + initListenersForClusterManager(clusterManager, this, clusterItemListener); + clusterManagerIdToManager.put(clusterManagerId, clusterManager); + } + + /** Removes ClusterManagers by IDs. */ + public void removeClusterManagers(List clusterManagerIdsToRemove) { + if (clusterManagerIdsToRemove == null) { + return; + } + for (Object rawClusterManagerId : clusterManagerIdsToRemove) { + if (rawClusterManagerId == null) { + continue; + } + String clusterManagerId = (String) rawClusterManagerId; + removeClusterManager(clusterManagerId); + } + } + + private void removeClusterManager(Object clusterManagerId) { + final ClusterManager clusterManager = + clusterManagerIdToManager.remove(clusterManagerId); + if (clusterManager == null) { + return; + } + initListenersForClusterManager(clusterManager, null, null); + clusterManager.clearItems(); + clusterManager.cluster(); + } + + /** Adds item to the ClusterManager it belongs. */ + public void addItem(MarkerBuilder item) { + ClusterManager clusterManager = + clusterManagerIdToManager.get(item.clusterManagerId()); + if (clusterManager != null) { + clusterManager.addItem(item); + clusterManager.cluster(); + } + } + + /** Removes item from the ClusterManager it belongs. */ + public void removeItem(MarkerBuilder item) { + ClusterManager clusterManager = + clusterManagerIdToManager.get(item.clusterManagerId()); + if (clusterManager != null) { + clusterManager.removeItem(item); + clusterManager.cluster(); + } + } + + /** Called when ClusterRenderer has rendered new visible marker to the map. */ + void onClusterItemMarker(MarkerBuilder item, Marker marker) { + if (clusterItemListener != null) { + clusterItemListener.onClusterItemMarker(item, marker); + } + } + + /** Reads clusterManagerId from object data. */ + @SuppressWarnings("unchecked") + private static String getClusterManagerId(Object clusterManagerData) { + Map clusterMap = (Map) clusterManagerData; + return (String) clusterMap.get("clusterManagerId"); + } + + /** + * Requests all current clusters from the algorithm of the requested ClusterManager and converts + * them to result response. + */ + public void getClustersWithClusterManagerId( + String clusterManagerId, MethodChannel.Result result) { + ClusterManager clusterManager = clusterManagerIdToManager.get(clusterManagerId); + if (clusterManager == null) { + result.error( + "Invalid clusterManagerId", "getClusters called with invalid clusterManagerId", null); + return; + } + + final Set> clusters = + clusterManager.getAlgorithm().getClusters(googleMap.getCameraPosition().zoom); + result.success(Convert.clustersToJson(clusterManagerId, clusters)); + } + + @Override + public void onCameraIdle() { + for (Map.Entry> entry : + clusterManagerIdToManager.entrySet()) { + entry.getValue().onCameraIdle(); + } + } + + @Override + public boolean onClusterClick(Cluster cluster) { + if (cluster.getSize() > 0) { + MarkerBuilder[] builders = cluster.getItems().toArray(new MarkerBuilder[0]); + String clusterManagerId = builders[0].clusterManagerId(); + methodChannel.invokeMethod("cluster#onTap", Convert.clusterToJson(clusterManagerId, cluster)); + } + return false; + } + + /** + * ClusterRenderer builds marker options for new markers to be rendered to the map. After cluster + * item (marker) is renderer, it is sent to the listeners for control. + */ + private static class ClusterRenderer extends DefaultClusterRenderer { + private final ClusterManagersController clusterManagersController; + + public ClusterRenderer( + Context context, + GoogleMap map, + ClusterManager clusterManager, + ClusterManagersController clusterManagersController) { + super(context, map, clusterManager); + this.clusterManagersController = clusterManagersController; + } + + @Override + protected void onBeforeClusterItemRendered( + @NonNull MarkerBuilder item, @NonNull MarkerOptions markerOptions) { + // Builds new markerOptions for new marker created by the ClusterRenderer under + // ClusterManager. + item.build(markerOptions); + } + + @Override + protected void onClusterItemRendered(@NonNull MarkerBuilder item, @NonNull Marker marker) { + super.onClusterItemRendered(item, marker); + clusterManagersController.onClusterItemMarker(item, marker); + } + } + + /** Interface for handling situations where clusterManager adds new visible marker to the map. */ + public interface onClusterItemMarker { + void onClusterItemMarker(T item, Marker marker); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/Convert.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/Convert.java index 72c6959fe55e..375212b4d657 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/Convert.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/Convert.java @@ -24,18 +24,22 @@ import com.google.android.gms.maps.model.RoundCap; import com.google.android.gms.maps.model.SquareCap; import com.google.android.gms.maps.model.Tile; +import com.google.maps.android.clustering.Cluster; import io.flutter.view.FlutterMain; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** Conversions between JSON-like values and GoogleMaps data types. */ class Convert { - // TODO(hamdikahloun): FlutterMain has been deprecated and should be replaced with FlutterLoader - // when it's available in Stable channel: https://github.com/flutter/flutter/issues/70923. + // TODO(hamdikahloun): FlutterMain has been deprecated and should be replaced + // with FlutterLoader + // when it's available in Stable channel: + // https://github.com/flutter/flutter/issues/70923. @SuppressWarnings("deprecation") private static BitmapDescriptor toBitmapDescriptor(Object o) { final List data = toList(o); @@ -159,7 +163,7 @@ static Object cameraPositionToJson(CameraPosition position) { return data; } - static Object latlngBoundsToJson(LatLngBounds latLngBounds) { + static Object latLngBoundsToJson(LatLngBounds latLngBounds) { final Map arguments = new HashMap<>(2); arguments.put("southwest", latLngToJson(latLngBounds.southwest)); arguments.put("northeast", latLngToJson(latLngBounds.northeast)); @@ -220,6 +224,41 @@ static Object latLngToJson(LatLng latLng) { return Arrays.asList(latLng.latitude, latLng.longitude); } + static Object clustersToJson( + String clusterManagerId, Set> clusters) { + List data = new ArrayList<>(clusters.size()); + for (Cluster cluster : clusters) { + data.add(clusterToJson(clusterManagerId, cluster)); + } + return data; + } + + static Object clusterToJson(String clusterManagerId, Cluster cluster) { + int clusterSize = cluster.getSize(); + LatLngBounds.Builder latLngBoundsBuilder = LatLngBounds.builder(); + + String[] markerIds = new String[clusterSize]; + MarkerBuilder[] markerBuilders = cluster.getItems().toArray(new MarkerBuilder[clusterSize]); + + // Loops though cluster items and reads markers position for the LatLngBounds builder + // and also builds list of marker ids on the cluster. + for (int i = 0; i < clusterSize; i++) { + MarkerBuilder markerBuilder = markerBuilders[i]; + latLngBoundsBuilder.include(markerBuilder.getPosition()); + markerIds[i] = markerBuilder.markerId(); + } + + Object position = latLngToJson(cluster.getPosition()); + Object bounds = latLngBoundsToJson(latLngBoundsBuilder.build()); + + final Map data = new HashMap<>(4); + data.put("clusterManagerId", clusterManagerId); + data.put("position", position); + data.put("bounds", bounds); + data.put("markerIds", Arrays.asList(markerIds)); + return data; + } + static LatLng toLatLng(Object o) { final List data = toList(o); return new LatLng(toDouble(data.get(0)), toDouble(data.get(1))); @@ -379,7 +418,7 @@ static void interpretGoogleMapOptions(Object o, GoogleMapOptionsSink sink) { } /** Returns the dartMarkerId of the interpreted marker. */ - static String interpretMarkerOptions(Object o, MarkerOptionsSink sink) { + static void interpretMarkerOptions(Object o, MarkerOptionsSink sink) { final Map data = toMap(o); final Object alpha = data.get("alpha"); if (alpha != null) { @@ -427,11 +466,10 @@ static String interpretMarkerOptions(Object o, MarkerOptionsSink sink) { if (zIndex != null) { sink.setZIndex(toFloat(zIndex)); } + final String markerId = (String) data.get("markerId"); if (markerId == null) { throw new IllegalArgumentException("markerId was null"); - } else { - return markerId; } } diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapBuilder.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapBuilder.java index ad5179a69a45..f044d15c1f2f 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapBuilder.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapBuilder.java @@ -22,6 +22,7 @@ class GoogleMapBuilder implements GoogleMapOptionsSink { private boolean trafficEnabled = false; private boolean buildingsEnabled = true; private Object initialMarkers; + private Object initialClusterManagers; private Object initialPolygons; private Object initialPolylines; private Object initialCircles; @@ -42,6 +43,7 @@ GoogleMapController build( controller.setTrafficEnabled(trafficEnabled); controller.setBuildingsEnabled(buildingsEnabled); controller.setTrackCameraPosition(trackCameraPosition); + controller.setInitialClusterManagers(initialClusterManagers); controller.setInitialMarkers(initialMarkers); controller.setInitialPolygons(initialPolygons); controller.setInitialPolylines(initialPolylines); @@ -155,6 +157,11 @@ public void setInitialMarkers(Object initialMarkers) { this.initialMarkers = initialMarkers; } + @Override + public void setInitialClusterManagers(Object initialClusterManagers) { + this.initialClusterManagers = initialClusterManagers; + } + @Override public void setInitialPolygons(Object initialPolygons) { this.initialPolygons = initialPolygons; diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java index 66d3e283b8df..8af472a0a148 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java @@ -34,6 +34,7 @@ import com.google.android.gms.maps.model.Marker; import com.google.android.gms.maps.model.Polygon; import com.google.android.gms.maps.model.Polyline; +import com.google.maps.android.collections.MarkerManager; import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.MethodCall; @@ -54,6 +55,7 @@ final class GoogleMapController MethodChannel.MethodCallHandler, OnMapReadyCallback, GoogleMapListener, + ClusterItemListener, PlatformView { private static final String TAG = "GoogleMapController"; @@ -75,11 +77,15 @@ final class GoogleMapController private final Context context; private final LifecycleProvider lifecycleProvider; private final MarkersController markersController; + private final ClusterManagersController clusterManagersController; private final PolygonsController polygonsController; private final PolylinesController polylinesController; private final CirclesController circlesController; private final TileOverlaysController tileOverlaysController; + private MarkerManager markerManager; + private MarkerManager.Collection markerCollection; private List initialMarkers; + private List initialClusterManagers; private List initialPolygons; private List initialPolylines; private List initialCircles; @@ -100,7 +106,8 @@ final class GoogleMapController new MethodChannel(binaryMessenger, "plugins.flutter.dev/google_maps_android_" + id); methodChannel.setMethodCallHandler(this); this.lifecycleProvider = lifecycleProvider; - this.markersController = new MarkersController(methodChannel); + this.clusterManagersController = new ClusterManagersController(methodChannel, context); + this.markersController = new MarkersController(methodChannel, clusterManagersController); this.polygonsController = new PolygonsController(methodChannel, density); this.polylinesController = new PolylinesController(methodChannel, density); this.circlesController = new CirclesController(methodChannel, density); @@ -113,7 +120,7 @@ public View getView() { } @VisibleForTesting - /*package*/ void setView(MapView view) { + /* package */ void setView(MapView view) { mapView = view; } @@ -197,13 +204,19 @@ public void onMapReady(GoogleMap googleMap) { mapReadyResult.success(null); mapReadyResult = null; } - setGoogleMapListener(this); + markerManager = new MarkerManager(googleMap); + markerCollection = markerManager.newCollection(); updateMyLocationSettings(); - markersController.setGoogleMap(googleMap); + markersController.setCollection(markerCollection); + clusterManagersController.init(googleMap, markerManager); polygonsController.setGoogleMap(googleMap); polylinesController.setGoogleMap(googleMap); circlesController.setGoogleMap(googleMap); tileOverlaysController.setGoogleMap(googleMap); + setGoogleMapListener(this); + setMarkerCollectionListener(this); + setClusterListener(this); + updateInitialClusterManagers(); updateInitialMarkers(); updateInitialPolygons(); updateInitialPolylines(); @@ -231,7 +244,7 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) { { if (googleMap != null) { LatLngBounds latLngBounds = googleMap.getProjection().getVisibleRegion().latLngBounds; - result.success(Convert.latlngBoundsToJson(latLngBounds)); + result.success(Convert.latLngBoundsToJson(latLngBounds)); } else { result.error( "GoogleMap uninitialized", @@ -332,6 +345,23 @@ public void onSnapshotReady(Bitmap bitmap) { markersController.isInfoWindowShown((String) markerId, result); break; } + case "clusterManagers#update": + { + invalidateMapIfNeeded(); + List clusterManagersToAdd = call.argument("clusterManagersToAdd"); + clusterManagersController.addClusterManagers(clusterManagersToAdd); + List clusterManagerIdsToRemove = call.argument("clusterManagerIdsToRemove"); + clusterManagersController.removeClusterManagers(clusterManagerIdsToRemove); + result.success(null); + break; + } + case "clusterManager#getClusters": + { + Object clusterManagerId = call.argument("clusterManagerId"); + clusterManagersController.getClustersWithClusterManagerId( + (String) clusterManagerId, result); + break; + } case "polygons#update": { invalidateMapIfNeeded(); @@ -529,12 +559,13 @@ public void onCameraMove() { @Override public void onCameraIdle() { + clusterManagersController.onCameraIdle(); methodChannel.invokeMethod("camera#onIdle", Collections.singletonMap("map", id)); } @Override public boolean onMarkerClick(Marker marker) { - return markersController.onMarkerTap(marker.getId()); + return markersController.onMapsMarkerTap(marker.getId()); } @Override @@ -575,6 +606,7 @@ public void dispose() { disposed = true; methodChannel.setMethodCallHandler(null); setGoogleMapListener(null); + setMarkerCollectionListener(null); destroyMapViewIfNecessary(); Lifecycle lifecycle = lifecycleProvider.getLifecycle(); if (lifecycle != null) { @@ -590,8 +622,6 @@ private void setGoogleMapListener(@Nullable GoogleMapListener listener) { googleMap.setOnCameraMoveStartedListener(listener); googleMap.setOnCameraMoveListener(listener); googleMap.setOnCameraIdleListener(listener); - googleMap.setOnMarkerClickListener(listener); - googleMap.setOnMarkerDragListener(listener); googleMap.setOnPolygonClickListener(listener); googleMap.setOnPolylineClickListener(listener); googleMap.setOnCircleClickListener(listener); @@ -599,6 +629,25 @@ private void setGoogleMapListener(@Nullable GoogleMapListener listener) { googleMap.setOnMapLongClickListener(listener); } + private void setMarkerCollectionListener(@Nullable GoogleMapListener listener) { + if (googleMap == null) { + Log.v(TAG, "Controller was disposed before GoogleMap was ready."); + return; + } + + markerCollection.setOnMarkerClickListener(listener); + markerCollection.setOnMarkerDragListener(listener); + } + + private void setClusterListener(@Nullable ClusterItemListener listener) { + if (googleMap == null) { + Log.v(TAG, "Controller was disposed before GoogleMap was ready."); + return; + } + + clusterManagersController.setClusterItemListener(listener); + } + // @Override // The minimum supported version of Flutter doesn't have this method on the PlatformView interface, but the maximum // does. This will override it when available even with the annotation commented out. @@ -801,6 +850,19 @@ private void updateInitialMarkers() { markersController.addMarkers(initialMarkers); } + @Override + public void setInitialClusterManagers(Object initialClusterManagers) { + ArrayList clusterManagers = (ArrayList) initialClusterManagers; + this.initialClusterManagers = clusterManagers != null ? new ArrayList<>(clusterManagers) : null; + if (googleMap != null) { + updateInitialClusterManagers(); + } + } + + private void updateInitialClusterManagers() { + clusterManagersController.addClusterManagers(initialClusterManagers); + } + @Override public void setInitialPolygons(Object initialPolygons) { ArrayList polygons = (ArrayList) initialPolygons; @@ -859,7 +921,7 @@ private void updateMyLocationSettings() { // the feature won't require the permission. // Gradle is doing a static check for missing permission and in some configurations will // fail the build if the permission is missing. The following disables the Gradle lint. - //noinspection ResourceType + // noinspection ResourceType googleMap.setMyLocationEnabled(myLocationEnabled); googleMap.getUiSettings().setMyLocationButtonEnabled(myLocationButtonEnabled); } else { @@ -907,4 +969,14 @@ public void setTrafficEnabled(boolean trafficEnabled) { public void setBuildingsEnabled(boolean buildingsEnabled) { this.buildingsEnabled = buildingsEnabled; } + + @Override + public void onClusterItemMarker(MarkerBuilder markerBuilder, Marker marker) { + markersController.onClusterItemMarker(markerBuilder, marker); + } + + @Override + public boolean onClusterItemClick(MarkerBuilder item) { + return markersController.onMarkerTap(item.markerId()); + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapFactory.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapFactory.java index ffa2412f9c42..97d18fedf678 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapFactory.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapFactory.java @@ -39,6 +39,9 @@ public PlatformView create(Context context, int id, Object args) { CameraPosition position = Convert.toCameraPosition(params.get("initialCameraPosition")); builder.setInitialCameraPosition(position); } + if (params.containsKey("clusterManagersToAdd")) { + builder.setInitialClusterManagers(params.get("clusterManagersToAdd")); + } if (params.containsKey("markersToAdd")) { builder.setInitialMarkers(params.get("markersToAdd")); } diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapOptionsSink.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapOptionsSink.java index 17f0d970a4ef..80dc092aeeb0 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapOptionsSink.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapOptionsSink.java @@ -48,6 +48,8 @@ interface GoogleMapOptionsSink { void setInitialMarkers(Object initialMarkers); + void setInitialClusterManagers(Object initialClusterManagers); + void setInitialPolygons(Object initialPolygons); void setInitialPolylines(Object initialPolylines); diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/MarkerBuilder.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/MarkerBuilder.java index ecc5f01bc87c..ea49b6ae3eb3 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/MarkerBuilder.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/MarkerBuilder.java @@ -7,31 +7,75 @@ import com.google.android.gms.maps.model.BitmapDescriptor; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.MarkerOptions; - -class MarkerBuilder implements MarkerOptionsSink { - private final MarkerOptions markerOptions; +import com.google.maps.android.clustering.ClusterItem; + +class MarkerBuilder implements MarkerOptionsSink, ClusterItem { + private float alpha = 1.0f; + private float anchorU; + private float anchorV; + private boolean draggable; + private boolean flat; private boolean consumeTapEvents; - - MarkerBuilder() { - this.markerOptions = new MarkerOptions(); + private BitmapDescriptor bitmapDescriptor; + private float infoWindowAnchorU = 0.5f; + private float infoWindowAnchorV; + private String infoWindowTitle; + private String infoWindowSnippet; + private LatLng position = new LatLng(0.0f, 0.0f); + private float rotation; + private boolean visible; + private float zIndex; + private String clusterManagerId; + private String markerId; + + MarkerBuilder(String markerId, String clusterManagerId) { + this.markerId = markerId; + this.clusterManagerId = clusterManagerId; } MarkerOptions build() { - return markerOptions; + MarkerOptions markerOptions = new MarkerOptions(); + return build(markerOptions); + } + + /** Update existing markerOptions with builder values */ + MarkerOptions build(MarkerOptions markerOptions) { + return markerOptions + .position(position) + .alpha(alpha) + .anchor(anchorU, anchorV) + .draggable(draggable) + .flat(flat) + .icon(bitmapDescriptor) + .infoWindowAnchor(infoWindowAnchorU, infoWindowAnchorV) + .title(infoWindowTitle) + .snippet(infoWindowSnippet) + .rotation(rotation) + .visible(visible) + .zIndex(zIndex); } boolean consumeTapEvents() { return consumeTapEvents; } + String clusterManagerId() { + return clusterManagerId; + } + + String markerId() { + return markerId; + } + @Override public void setAlpha(float alpha) { - markerOptions.alpha(alpha); + this.alpha = alpha; } @Override public void setAnchor(float u, float v) { - markerOptions.anchor(u, v); + anchorU = u; + anchorV = v; } @Override @@ -41,47 +85,63 @@ public void setConsumeTapEvents(boolean consumeTapEvents) { @Override public void setDraggable(boolean draggable) { - markerOptions.draggable(draggable); + this.draggable = draggable; } @Override public void setFlat(boolean flat) { - markerOptions.flat(flat); + this.flat = flat; } @Override public void setIcon(BitmapDescriptor bitmapDescriptor) { - markerOptions.icon(bitmapDescriptor); + this.bitmapDescriptor = bitmapDescriptor; } @Override public void setInfoWindowAnchor(float u, float v) { - markerOptions.infoWindowAnchor(u, v); + infoWindowAnchorU = u; + infoWindowAnchorV = v; } @Override public void setInfoWindowText(String title, String snippet) { - markerOptions.title(title); - markerOptions.snippet(snippet); + infoWindowTitle = title; + infoWindowSnippet = snippet; } @Override public void setPosition(LatLng position) { - markerOptions.position(position); + this.position = position; } @Override public void setRotation(float rotation) { - markerOptions.rotation(rotation); + this.rotation = rotation; } @Override public void setVisible(boolean visible) { - markerOptions.visible(visible); + this.visible = visible; } @Override public void setZIndex(float zIndex) { - markerOptions.zIndex(zIndex); + this.zIndex = zIndex; + } + + @Override + public LatLng getPosition() { + return position; + } + + @Override + public String getTitle() { + return infoWindowTitle; + } + + @Override + public String getSnippet() { + return infoWindowSnippet; } } diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/MarkerController.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/MarkerController.java index 5c568a1c9a1e..3844fda588ac 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/MarkerController.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/MarkerController.java @@ -7,82 +7,136 @@ import com.google.android.gms.maps.model.BitmapDescriptor; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.Marker; +import com.google.maps.android.collections.MarkerManager; +import java.lang.ref.WeakReference; /** Controller of a single Marker on the map. */ class MarkerController implements MarkerOptionsSink { - private final Marker marker; + private final WeakReference weakMarker; private final String googleMapsMarkerId; private boolean consumeTapEvents; MarkerController(Marker marker, boolean consumeTapEvents) { - this.marker = marker; + this.weakMarker = new WeakReference<>(marker); this.consumeTapEvents = consumeTapEvents; this.googleMapsMarkerId = marker.getId(); } - void remove() { - marker.remove(); + void removeFromCollection(MarkerManager.Collection markerCollection) { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } + markerCollection.remove(marker); } @Override public void setAlpha(float alpha) { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } marker.setAlpha(alpha); } @Override public void setAnchor(float u, float v) { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } marker.setAnchor(u, v); } @Override public void setConsumeTapEvents(boolean consumeTapEvents) { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } this.consumeTapEvents = consumeTapEvents; } @Override public void setDraggable(boolean draggable) { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } marker.setDraggable(draggable); } @Override public void setFlat(boolean flat) { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } marker.setFlat(flat); } @Override public void setIcon(BitmapDescriptor bitmapDescriptor) { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } marker.setIcon(bitmapDescriptor); } @Override public void setInfoWindowAnchor(float u, float v) { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } marker.setInfoWindowAnchor(u, v); } @Override public void setInfoWindowText(String title, String snippet) { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } marker.setTitle(title); marker.setSnippet(snippet); } @Override public void setPosition(LatLng position) { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } marker.setPosition(position); } @Override public void setRotation(float rotation) { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } marker.setRotation(rotation); } @Override public void setVisible(boolean visible) { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } marker.setVisible(visible); } @Override public void setZIndex(float zIndex) { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } marker.setZIndex(zIndex); } @@ -95,14 +149,26 @@ boolean consumeTapEvents() { } public void showInfoWindow() { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } marker.showInfoWindow(); } public void hideInfoWindow() { + Marker marker = weakMarker.get(); + if (marker == null) { + return; + } marker.hideInfoWindow(); } public boolean isInfoWindowShown() { + Marker marker = weakMarker.get(); + if (marker == null) { + return false; + } return marker.isInfoWindowShown(); } } diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/MarkersController.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/MarkersController.java index 47ffe9b857d6..86b651d6bd50 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/MarkersController.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/MarkersController.java @@ -4,30 +4,34 @@ package io.flutter.plugins.googlemaps; -import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.Marker; import com.google.android.gms.maps.model.MarkerOptions; +import com.google.maps.android.collections.MarkerManager; import io.flutter.plugin.common.MethodChannel; import java.util.HashMap; import java.util.List; import java.util.Map; class MarkersController { - - private final Map markerIdToController; - private final Map googleMapsMarkerIdToDartMarkerId; + private final HashMap markerIdToMarkerBuilder; + private final HashMap markerIdToController; + private final HashMap googleMapsMarkerIdToDartMarkerId; private final MethodChannel methodChannel; - private GoogleMap googleMap; + private MarkerManager.Collection markerCollection; + private final ClusterManagersController clusterManagersController; - MarkersController(MethodChannel methodChannel) { + MarkersController( + MethodChannel methodChannel, ClusterManagersController clusterManagersController) { + this.markerIdToMarkerBuilder = new HashMap<>(); this.markerIdToController = new HashMap<>(); this.googleMapsMarkerIdToDartMarkerId = new HashMap<>(); this.methodChannel = methodChannel; + this.clusterManagersController = clusterManagersController; } - void setGoogleMap(GoogleMap googleMap) { - this.googleMap = googleMap; + void setCollection(MarkerManager.Collection markerCollection) { + this.markerCollection = markerCollection; } void addMarkers(List markersToAdd) { @@ -55,11 +59,27 @@ void removeMarkers(List markerIdsToRemove) { continue; } String markerId = (String) rawMarkerId; - final MarkerController markerController = markerIdToController.remove(markerId); - if (markerController != null) { - markerController.remove(); - googleMapsMarkerIdToDartMarkerId.remove(markerController.getGoogleMapsMarkerId()); - } + removeMarker(markerId); + } + } + + private void removeMarker(String markerId) { + final MarkerBuilder markerBuilder = markerIdToMarkerBuilder.remove(markerId); + if (markerBuilder == null) { + return; + } + final MarkerController markerController = markerIdToController.remove(markerId); + final String clusterManagerId = markerBuilder.clusterManagerId(); + if (clusterManagerId != null) { + // Remove marker from clusterManager + clusterManagersController.removeItem(markerBuilder); + } else if (markerController != null && this.markerCollection != null) { + // Remove marker from map and markerCollection + markerController.removeFromCollection(markerCollection); + } + + if (markerController != null) { + googleMapsMarkerIdToDartMarkerId.remove(markerController.getGoogleMapsMarkerId()); } } @@ -79,7 +99,10 @@ void hideMarkerInfoWindow(String markerId, MethodChannel.Result result) { markerController.hideInfoWindow(); result.success(null); } else { - result.error("Invalid markerId", "hideInfoWindow called with invalid markerId", null); + result.error( + "Invalid markerId", + "hideInfoWindow called with invalid markerId or for hidden cluster marker", + null); } } @@ -88,15 +111,22 @@ void isInfoWindowShown(String markerId, MethodChannel.Result result) { if (markerController != null) { result.success(markerController.isInfoWindowShown()); } else { - result.error("Invalid markerId", "isInfoWindowShown called with invalid markerId", null); + result.error( + "Invalid markerId", + "isInfoWindowShown called with invalid markerId or for hidden cluster marker", + null); } } - boolean onMarkerTap(String googleMarkerId) { + boolean onMapsMarkerTap(String googleMarkerId) { String markerId = googleMapsMarkerIdToDartMarkerId.get(googleMarkerId); if (markerId == null) { return false; } + return onMarkerTap(markerId); + } + + boolean onMarkerTap(String markerId) { methodChannel.invokeMethod("marker#onTap", Convert.markerIdToJson(markerId)); MarkerController markerController = markerIdToController.get(markerId); if (markerController != null) { @@ -146,18 +176,56 @@ void onInfoWindowTap(String googleMarkerId) { methodChannel.invokeMethod("infoWindow#onTap", Convert.markerIdToJson(markerId)); } + // Called each time clusterManager adds new visible marker to the map. + // Creates markerController for marker for realtime marker updates. + public void onClusterItemMarker(MarkerBuilder markerBuilder, Marker marker) { + String markerId = markerBuilder.markerId(); + if (markerIdToMarkerBuilder.get(markerId) == markerBuilder) { + createControllerForMarker(markerBuilder.markerId(), marker, markerBuilder.consumeTapEvents()); + } + } + private void addMarker(Object marker) { if (marker == null) { return; } - MarkerBuilder markerBuilder = new MarkerBuilder(); - String markerId = Convert.interpretMarkerOptions(marker, markerBuilder); + String markerId = getMarkerId(marker); + if (markerId == null) { + throw new IllegalArgumentException("markerId was null"); + } + String clusterManagerId = getClusterManagerId(marker); + MarkerBuilder markerBuilder = new MarkerBuilder(markerId, clusterManagerId); + Convert.interpretMarkerOptions(marker, markerBuilder); + addMarker(markerBuilder); + } + + private void addMarker(MarkerBuilder markerBuilder) { + if (markerBuilder == null) { + return; + } + String markerId = markerBuilder.markerId(); + + // Store marker builder for future marker rebuilds when used under clusters. + markerIdToMarkerBuilder.put(markerId, markerBuilder); + + if (markerBuilder.clusterManagerId() == null) { + addMarkerToCollection(markerId, markerBuilder); + } else { + addMarkerBuilderForCluster(markerBuilder); + } + } + + private void addMarkerToCollection(String markerId, MarkerBuilder markerBuilder) { MarkerOptions options = markerBuilder.build(); - addMarker(markerId, options, markerBuilder.consumeTapEvents()); + final Marker marker = markerCollection.addMarker(options); + createControllerForMarker(markerId, marker, markerBuilder.consumeTapEvents()); } - private void addMarker(String markerId, MarkerOptions markerOptions, boolean consumeTapEvents) { - final Marker marker = googleMap.addMarker(markerOptions); + private void addMarkerBuilderForCluster(MarkerBuilder markerBuilder) { + clusterManagersController.addItem(markerBuilder); + } + + private void createControllerForMarker(String markerId, Marker marker, boolean consumeTapEvents) { MarkerController controller = new MarkerController(marker, consumeTapEvents); markerIdToController.put(markerId, controller); googleMapsMarkerIdToDartMarkerId.put(marker.getId(), markerId); @@ -168,6 +236,28 @@ private void changeMarker(Object marker) { return; } String markerId = getMarkerId(marker); + + MarkerBuilder markerBuilder = markerIdToMarkerBuilder.get(markerId); + if (markerBuilder == null) { + return; + } + + String clusterManagerId = getClusterManagerId(marker); + String oldClusterManagerId = markerBuilder.clusterManagerId(); + + // Cluster id on updated marker has changed. + // Marker need to be removed and added again. + if (!((clusterManagerId == oldClusterManagerId) + || (clusterManagerId != null && clusterManagerId.equals(oldClusterManagerId)))) { + removeMarker(markerId); + addMarker(marker); + return; + } + + // Update marker builder + Convert.interpretMarkerOptions(marker, markerBuilder); + + // Update existing marker on map MarkerController markerController = markerIdToController.get(markerId); if (markerController != null) { Convert.interpretMarkerOptions(marker, markerController); @@ -179,4 +269,10 @@ private static String getMarkerId(Object marker) { Map markerMap = (Map) marker; return (String) markerMap.get("markerId"); } + + @SuppressWarnings("unchecked") + private static String getClusterManagerId(Object marker) { + Map markerMap = (Map) marker; + return (String) markerMap.get("clusterManagerId"); + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java index 3ca78e7674d7..7d025e7b81ac 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java @@ -9,10 +9,14 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; +import android.content.Context; +import android.os.Build; +import androidx.test.core.app.ApplicationProvider; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.Marker; import com.google.android.gms.maps.model.MarkerOptions; +import com.google.maps.android.collections.MarkerManager; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodCodec; @@ -21,18 +25,37 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.junit.Before; import org.junit.Test; +import org.junit.runner.RunWith; import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +@RunWith(RobolectricTestRunner.class) +@Config(sdk = Build.VERSION_CODES.P) public class MarkersControllerTest { + private Context context; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + } @Test public void controller_OnMarkerDragStart() { final MethodChannel methodChannel = spy(new MethodChannel(mock(BinaryMessenger.class), "no-name", mock(MethodCodec.class))); - final MarkersController controller = new MarkersController(methodChannel); + final ClusterManagersController clusterManagersController = + new ClusterManagersController(methodChannel, context); + final MarkersController controller = + new MarkersController(methodChannel, clusterManagersController); final GoogleMap googleMap = mock(GoogleMap.class); - controller.setGoogleMap(googleMap); + + final MarkerManager markerManager = new MarkerManager(googleMap); + final MarkerManager.Collection markerCollection = markerManager.newCollection(); + controller.setCollection(markerCollection); + clusterManagersController.init(googleMap, markerManager); final Marker marker = mock(Marker.class); @@ -63,9 +86,16 @@ public void controller_OnMarkerDragStart() { public void controller_OnMarkerDragEnd() { final MethodChannel methodChannel = spy(new MethodChannel(mock(BinaryMessenger.class), "no-name", mock(MethodCodec.class))); - final MarkersController controller = new MarkersController(methodChannel); + final ClusterManagersController clusterManagersController = + new ClusterManagersController(methodChannel, context); + final MarkersController controller = + new MarkersController(methodChannel, clusterManagersController); final GoogleMap googleMap = mock(GoogleMap.class); - controller.setGoogleMap(googleMap); + + final MarkerManager markerManager = new MarkerManager(googleMap); + final MarkerManager.Collection markerCollection = markerManager.newCollection(); + controller.setCollection(markerCollection); + clusterManagersController.init(googleMap, markerManager); final Marker marker = mock(Marker.class); @@ -96,9 +126,16 @@ public void controller_OnMarkerDragEnd() { public void controller_OnMarkerDrag() { final MethodChannel methodChannel = spy(new MethodChannel(mock(BinaryMessenger.class), "no-name", mock(MethodCodec.class))); - final MarkersController controller = new MarkersController(methodChannel); + final ClusterManagersController clusterManagersController = + new ClusterManagersController(methodChannel, context); + final MarkersController controller = + new MarkersController(methodChannel, clusterManagersController); final GoogleMap googleMap = mock(GoogleMap.class); - controller.setGoogleMap(googleMap); + + final MarkerManager markerManager = new MarkerManager(googleMap); + final MarkerManager.Collection markerCollection = markerManager.newCollection(); + controller.setCollection(markerCollection); + clusterManagersController.init(googleMap, markerManager); final Marker marker = mock(Marker.class); diff --git a/packages/google_maps_flutter/google_maps_flutter_android/example/android/app/build.gradle b/packages/google_maps_flutter/google_maps_flutter_android/example/android/app/build.gradle index f6d29f63fadc..d33dc8acf11a 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/example/android/app/build.gradle +++ b/packages/google_maps_flutter/google_maps_flutter_android/example/android/app/build.gradle @@ -65,6 +65,7 @@ android { androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' api 'androidx.test:core:1.2.0' testImplementation 'com.google.android.gms:play-services-maps:17.0.0' + testImplementation 'com.google.maps.android:android-maps-utils:2.4.0' } } diff --git a/packages/google_maps_flutter/google_maps_flutter_android/example/integration_test/google_maps_tests.dart b/packages/google_maps_flutter/google_maps_flutter_android/example/integration_test/google_maps_tests.dart index bd72b7ba52d2..5e79b41ce807 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/example/integration_test/google_maps_tests.dart +++ b/packages/google_maps_flutter/google_maps_flutter_android/example/integration_test/google_maps_tests.dart @@ -1178,6 +1178,85 @@ void googleMapsTests() { expect(tileOverlayInfo1, isNull); }, ); + + testWidgets('marker clustering', (WidgetTester tester) async { + final Key key = GlobalKey(); + const int clusterManagersAmount = 2; + const int markersPerClusterManager = 5; + final Map markers = {}; + final Set clusterManagers = {}; + + for (int i = 0; i < clusterManagersAmount; i++) { + final ClusterManagerId clusterManagerId = + ClusterManagerId('cluster_manager_$i'); + final ClusterManager clusterManager = + ClusterManager(clusterManagerId: clusterManagerId); + clusterManagers.add(clusterManager); + } + + for (final ClusterManager cm in clusterManagers) { + for (int i = 0; i < markersPerClusterManager; i++) { + final MarkerId markerId = + MarkerId('${cm.clusterManagerId.value}_marker_$i'); + final Marker marker = Marker( + markerId: markerId, + clusterManagerId: cm.clusterManagerId, + position: LatLng( + _kInitialMapCenter.latitude + i, _kInitialMapCenter.longitude)); + markers[markerId] = marker; + } + } + + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: ExampleGoogleMap( + key: key, + initialCameraPosition: _kInitialCameraPosition, + clusterManagers: clusterManagers, + markers: Set.of(markers.values), + onMapCreated: (ExampleGoogleMapController googleMapController) { + controllerCompleter.complete(googleMapController); + }, + ), + )); + + final ExampleGoogleMapController controller = + await controllerCompleter.future; + + final GoogleMapsInspectorPlatform inspector = + GoogleMapsInspectorPlatform.instance!; + + for (final ClusterManager cm in clusterManagers) { + final List clusters = await inspector.getClusters( + mapId: controller.mapId, clusterManagerId: cm.clusterManagerId); + final int markersAmountForClusterManager = clusters + .map((Cluster cluster) => cluster.count) + .reduce((int value, int element) => value + element); + expect(markersAmountForClusterManager, markersPerClusterManager); + } + + // Remove markers from clusterManagers and test that clusterManagers are empty. + for (final MapEntry entry in markers.entries) { + markers[entry.key] = _copyMarkerWithClusterManagerId(entry.value, null); + } + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: ExampleGoogleMap( + key: key, + initialCameraPosition: _kInitialCameraPosition, + clusterManagers: clusterManagers, + markers: Set.of(markers.values)), + )); + + for (final ClusterManager cm in clusterManagers) { + final List clusters = await inspector.getClusters( + mapId: controller.mapId, clusterManagerId: cm.clusterManagerId); + expect(clusters.length, 0); + } + }); } class _DebugTileProvider implements TileProvider { @@ -1223,3 +1302,26 @@ class _DebugTileProvider implements TileProvider { return Tile(width, height, byteData); } } + +Marker _copyMarkerWithClusterManagerId( + Marker marker, ClusterManagerId? clusterManagerId) { + return Marker( + markerId: marker.markerId, + alpha: marker.alpha, + anchor: marker.anchor, + consumeTapEvents: marker.consumeTapEvents, + draggable: marker.draggable, + flat: marker.flat, + icon: marker.icon, + infoWindow: marker.infoWindow, + position: marker.position, + rotation: marker.rotation, + visible: marker.visible, + zIndex: marker.zIndex, + onTap: marker.onTap, + onDragStart: marker.onDragStart, + onDrag: marker.onDrag, + onDragEnd: marker.onDragEnd, + clusterManagerId: clusterManagerId, + ); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_android/example/lib/clustering.dart b/packages/google_maps_flutter/google_maps_flutter_android/example/lib/clustering.dart new file mode 100644 index 000000000000..1e76cf2addbb --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/example/lib/clustering.dart @@ -0,0 +1,238 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: public_member_api_docs + +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +import 'example_google_map.dart'; +import 'page.dart'; + +class ClusteringPage extends GoogleMapExampleAppPage { + const ClusteringPage({Key? key}) + : super(const Icon(Icons.place), 'Manage clustering', key: key); + + @override + Widget build(BuildContext context) { + return const ClusteringBody(); + } +} + +class ClusteringBody extends StatefulWidget { + const ClusteringBody({Key? key}) : super(key: key); + + @override + State createState() => ClusteringBodyState(); +} + +typedef MarkerUpdateAction = Marker Function(Marker marker); + +class ClusteringBodyState extends State { + ClusteringBodyState(); + static const LatLng center = LatLng(-33.86711, 151.1547171); + static const double _scaleFactor = 0.05; + + ExampleGoogleMapController? controller; + Map clusterManagers = + {}; + Map markers = {}; + MarkerId? selectedMarker; + int _clusterManagerIdCounter = 1; + int _markerIdCounter = 1; + Cluster? lastCluster; + + // ignore: use_setters_to_change_properties + void _onMapCreated(ExampleGoogleMapController controller) { + this.controller = controller; + } + + @override + void dispose() { + super.dispose(); + } + + void _onMarkerTapped(MarkerId markerId) { + final Marker? tappedMarker = markers[markerId]; + if (tappedMarker != null) { + setState(() { + final MarkerId? previousMarkerId = selectedMarker; + if (previousMarkerId != null && markers.containsKey(previousMarkerId)) { + final Marker resetOld = markers[previousMarkerId]! + .copyWith(iconParam: BitmapDescriptor.defaultMarker); + markers[previousMarkerId] = resetOld; + } + selectedMarker = markerId; + final Marker newMarker = tappedMarker.copyWith( + iconParam: BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueGreen, + ), + ); + markers[markerId] = newMarker; + }); + } + } + + void _addClusterManager() { + final int clusterManagerCount = clusterManagers.length; + + if (clusterManagerCount == 3) { + return; + } + + final String clusterManagerIdVal = + 'cluster_manager_id_$_clusterManagerIdCounter'; + _clusterManagerIdCounter++; + final ClusterManagerId clusterManagerId = + ClusterManagerId(clusterManagerIdVal); + + final ClusterManager clusterManager = ClusterManager( + clusterManagerId: clusterManagerId, + onClusterTap: (Cluster cluster) => setState(() { + lastCluster = cluster; + }), + ); + + setState(() { + clusterManagers[clusterManagerId] = clusterManager; + }); + _addMarkersToCluster(clusterManager); + } + + void _removeClusterManager(ClusterManager clusterManager) { + setState(() { + // Remove markers managed by cluster manager to be removed. + markers.removeWhere((MarkerId key, Marker marker) => + marker.clusterManagerId == clusterManager.clusterManagerId); + // Remove cluster manager. + clusterManagers.remove(clusterManager.clusterManagerId); + }); + } + + void _addMarkersToCluster(ClusterManager clusterManager) { + for (int i = 0; i < 15; i++) { + final String markerIdVal = + '${clusterManager.clusterManagerId.value}_marker_id_$_markerIdCounter'; + _markerIdCounter++; + final MarkerId markerId = MarkerId(markerIdVal); + + final int clusterManagerIndex = + clusterManagers.values.toList().indexOf(clusterManager); + final Marker marker = Marker( + clusterManagerId: clusterManager.clusterManagerId, + markerId: markerId, + position: LatLng( + center.latitude + _getRandomOffset(), + center.longitude + + _getRandomOffset() + + clusterManagerIndex * _scaleFactor * 2, + ), + infoWindow: InfoWindow(title: markerIdVal, snippet: '*'), + onTap: () => _onMarkerTapped(markerId), + ); + markers[markerId] = marker; + } + setState(() {}); + } + + double _getRandomOffset() { + return (Random().nextDouble() - 0.5) * _scaleFactor; + } + + void _remove(MarkerId markerId) { + setState(() { + if (markers.containsKey(markerId)) { + markers.remove(markerId); + } + }); + } + + void _changeMarkersRotation() { + for (final MarkerId markerId in markers.keys) { + final Marker marker = markers[markerId]!; + final double current = marker.rotation; + markers[markerId] = marker.copyWith( + rotationParam: current == 315.0 ? 0.0 : current + 45.0, + ); + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final MarkerId? selectedId = selectedMarker; + return Stack(children: [ + Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: ExampleGoogleMap( + onMapCreated: _onMapCreated, + initialCameraPosition: const CameraPosition( + target: LatLng(-33.852, 151.211), + zoom: 11.0, + ), + markers: Set.of(markers.values), + clusterManagers: Set.of(clusterManagers.values), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: () => _addClusterManager(), + child: const Text('Add cluster manager'), + ), + TextButton( + onPressed: clusterManagers.isEmpty + ? null + : () => _removeClusterManager(clusterManagers.values.last), + child: const Text('Remove cluster manager'), + ), + ], + ), + Wrap( + alignment: WrapAlignment.spaceEvenly, + children: [ + for (final MapEntry clusterEntry + in clusterManagers.entries) + TextButton( + onPressed: () => _addMarkersToCluster(clusterEntry.value), + child: Text('Add markers to ${clusterEntry.key.value}'), + ), + ], + ), + Wrap( + alignment: WrapAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: selectedId == null + ? null + : () { + _remove(selectedId); + setState(() { + selectedMarker = null; + }); + }, + child: const Text('Remove selected marker'), + ), + TextButton( + onPressed: + markers.isEmpty ? null : () => _changeMarkersRotation(), + child: const Text('Change all markers rotation'), + ), + ], + ), + if (lastCluster != null) + Padding( + padding: const EdgeInsets.all(10), + child: Text( + 'Cluster with ${lastCluster!.count} markers clicked at ${lastCluster!.position}')), + ], + ), + ]); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_android/example/lib/example_google_map.dart b/packages/google_maps_flutter/google_maps_flutter_android/example/lib/example_google_map.dart index 1c1261cb5b82..883911bf3926 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/example/lib/example_google_map.dart +++ b/packages/google_maps_flutter/google_maps_flutter_android/example/lib/example_google_map.dart @@ -90,6 +90,9 @@ class ExampleGoogleMapController { .listen((MapTapEvent e) => _googleMapState.onTap(e.position)); GoogleMapsFlutterPlatform.instance.onLongPress(mapId: mapId).listen( (MapLongPressEvent e) => _googleMapState.onLongPress(e.position)); + GoogleMapsFlutterPlatform.instance + .onClusterTap(mapId: mapId) + .listen((ClusterTapEvent e) => _googleMapState.onClusterTap(e.value)); } /// Updates configuration options of the map user interface. @@ -104,6 +107,13 @@ class ExampleGoogleMapController { .updateMarkers(markerUpdates, mapId: mapId); } + /// Updates cluster manager configuration. + Future _updateClusterManagers( + ClusterManagerUpdates clusterManagerUpdates) { + return GoogleMapsFlutterPlatform.instance + .updateClusterManagers(clusterManagerUpdates, mapId: mapId); + } + /// Updates polygon configuration. Future _updatePolygons(PolygonUpdates polygonUpdates) { return GoogleMapsFlutterPlatform.instance @@ -241,6 +251,7 @@ class ExampleGoogleMap extends StatefulWidget { this.polygons = const {}, this.polylines = const {}, this.circles = const {}, + this.clusterManagers = const {}, this.onCameraMoveStarted, this.tileOverlays = const {}, this.onCameraMove, @@ -314,6 +325,9 @@ class ExampleGoogleMap extends StatefulWidget { /// Tile overlays to be placed on the map. final Set tileOverlays; + /// Cluster Managers to be placed for the map. + final Set clusterManagers; + /// Called when the camera starts moving. final VoidCallback? onCameraMoveStarted; @@ -364,6 +378,8 @@ class _ExampleGoogleMapState extends State { Map _polygons = {}; Map _polylines = {}; Map _circles = {}; + Map _clusterManagers = + {}; late MapConfiguration _mapConfiguration; @override @@ -383,6 +399,7 @@ class _ExampleGoogleMapState extends State { polygons: widget.polygons, polylines: widget.polylines, circles: widget.circles, + clusterManagers: widget.clusterManagers, ), mapConfiguration: _mapConfiguration, ); @@ -392,6 +409,7 @@ class _ExampleGoogleMapState extends State { void initState() { super.initState(); _mapConfiguration = _configurationFromMapWidget(widget); + _clusterManagers = keyByClusterManagerId(widget.clusterManagers); _markers = keyByMarkerId(widget.markers); _polygons = keyByPolygonId(widget.polygons); _polylines = keyByPolylineId(widget.polylines); @@ -409,6 +427,7 @@ class _ExampleGoogleMapState extends State { void didUpdateWidget(ExampleGoogleMap oldWidget) { super.didUpdateWidget(oldWidget); _updateOptions(); + _updateClusterManagers(); _updateMarkers(); _updatePolygons(); _updatePolylines(); @@ -434,6 +453,14 @@ class _ExampleGoogleMapState extends State { _markers = keyByMarkerId(widget.markers); } + Future _updateClusterManagers() async { + final ExampleGoogleMapController controller = await _controller.future; + // ignore: unawaited_futures + controller._updateClusterManagers(ClusterManagerUpdates.from( + _clusterManagers.values.toSet(), widget.clusterManagers)); + _clusterManagers = keyByClusterManagerId(widget.clusterManagers); + } + Future _updatePolygons() async { final ExampleGoogleMapController controller = await _controller.future; controller._updatePolygons( @@ -511,6 +538,12 @@ class _ExampleGoogleMapState extends State { void onLongPress(LatLng position) { widget.onLongPress?.call(position); } + + void onClusterTap(Cluster cluster) { + final ClusterManager? clusterManager = + _clusterManagers[cluster.clusterManagerId]; + clusterManager?.onClusterTap?.call(cluster); + } } /// Builds a [MapConfiguration] from the given [map]. diff --git a/packages/google_maps_flutter/google_maps_flutter_android/example/lib/main.dart b/packages/google_maps_flutter/google_maps_flutter_android/example/lib/main.dart index 4adec524f87b..711389f6abf3 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/example/lib/main.dart +++ b/packages/google_maps_flutter/google_maps_flutter_android/example/lib/main.dart @@ -7,6 +7,7 @@ import 'package:google_maps_flutter_android/google_maps_flutter_android.dart'; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; import 'animate_camera.dart'; +import 'clustering.dart'; import 'lite_mode.dart'; import 'map_click.dart'; import 'map_coordinates.dart'; @@ -39,6 +40,7 @@ final List _allPages = [ const SnapshotPage(), const LiteModePage(), const TileOverlayPage(), + const ClusteringPage(), ]; /// MapsDemo is the Main Application. diff --git a/packages/google_maps_flutter/google_maps_flutter_android/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_android/example/pubspec.yaml index aa29fa99a97b..813cfe362718 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_android/example/pubspec.yaml @@ -34,3 +34,11 @@ flutter: uses-material-design: true assets: - assets/ + + +# FOR TESTING ONLY. DO NOT MERGE. +dependency_overrides: + google_maps_flutter_android: + path: ../../../google_maps_flutter/google_maps_flutter_android + google_maps_flutter_platform_interface: + path: ../../../google_maps_flutter/google_maps_flutter_platform_interface diff --git a/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_map_inspector_android.dart b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_map_inspector_android.dart index 4e0cad78e869..bb90ffc79670 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_map_inspector_android.dart +++ b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_map_inspector_android.dart @@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; +import 'google_maps_flutter_android.dart'; + /// An Android of implementation of [GoogleMapsInspectorPlatform]. @visibleForTesting class GoogleMapsInspectorAndroid extends GoogleMapsInspectorPlatform { @@ -110,4 +112,23 @@ class GoogleMapsInspectorAndroid extends GoogleMapsInspectorPlatform { return (await _channelProvider(mapId)! .invokeMethod('map#isTrafficEnabled'))!; } + + @override + Future> getClusters({ + required int mapId, + required ClusterManagerId clusterManagerId, + }) async { + final List data = (await _channelProvider(mapId)! + .invokeMethod>('clusterManager#getClusters', + {'clusterManagerId': clusterManagerId.value}))!; + return data.map((dynamic clusterData) { + final Map clusterDataMap = + Map.from(clusterData as Map); + return GoogleMapsFlutterAndroid.parseCluster( + clusterDataMap['clusterManagerId']! as String, + clusterDataMap['position']! as Object, + clusterDataMap['bounds']! as Map, + clusterDataMap['markerIds']! as List); + }).toList(); + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_maps_flutter_android.dart b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_maps_flutter_android.dart index 0461b4cf71bc..205350b9444a 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_maps_flutter_android.dart +++ b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_maps_flutter_android.dart @@ -16,6 +16,7 @@ import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platf import 'package:stream_transform/stream_transform.dart'; import 'google_map_inspector_android.dart'; +import 'utils/cluster_manager.dart'; // TODO(stuartmorgan): Remove the dependency on platform interface toJson // methods. Channel serialization details should all be package-internal. @@ -184,6 +185,11 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { return _events(mapId).whereType(); } + @override + Stream onClusterTap({required int mapId}) { + return _events(mapId).whereType(); + } + Future _handleMethodCall(MethodCall call, int mapId) async { switch (call.method) { case 'camera#onMoveStarted': @@ -289,6 +295,18 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { arguments['zoom'] as int?, ); return tile.toJson(); + case 'cluster#onTap': + final Map arguments = _getArgumentDictionary(call); + final Cluster cluster = parseCluster( + arguments['clusterManagerId']! as String, + arguments['position']!, + arguments['bounds']! as Map, + arguments['markerIds']! as List); + _mapEventStreamController.add(ClusterTapEvent( + mapId, + cluster, + )); + break; default: throw MissingPluginException(); } @@ -383,6 +401,19 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { ); } + @override + Future updateClusterManagers( + ClusterManagerUpdates clusterManagerUpdates, { + required int mapId, + }) { + assert(clusterManagerUpdates != null); + + return _channel(mapId).invokeMethod( + 'clusterManagers#update', + serializeClusterManagerUpdates(clusterManagerUpdates), + ); + } + @override Future clearTileCache( TileOverlayId tileOverlayId, { @@ -579,6 +610,8 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { 'polylinesToAdd': serializePolylineSet(mapObjects.polylines), 'circlesToAdd': serializeCircleSet(mapObjects.circles), 'tileOverlaysToAdd': serializeTileOverlaySet(mapObjects.tileOverlays), + 'clusterManagersToAdd': + serializeClusterManagerSet(mapObjects.clusterManagers), }; const String viewType = 'plugins.flutter.dev/google_maps_android'; @@ -655,6 +688,7 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { Set polylines = const {}, Set circles = const {}, Set tileOverlays = const {}, + Set clusterManagers = const {}, Set>? gestureRecognizers, Map mapOptions = const {}, }) { @@ -669,6 +703,7 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { polygons: polygons, polylines: polylines, circles: circles, + clusterManagers: clusterManagers, tileOverlays: tileOverlays), mapOptions: mapOptions, ); @@ -684,6 +719,7 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { Set polylines = const {}, Set circles = const {}, Set tileOverlays = const {}, + Set clusterManagers = const {}, Set>? gestureRecognizers, Map mapOptions = const {}, }) { @@ -697,6 +733,7 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { polylines: polylines, circles: circles, tileOverlays: tileOverlays, + clusterManagers: clusterManagers, gestureRecognizers: gestureRecognizers, mapOptions: mapOptions, ); @@ -708,6 +745,32 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { GoogleMapsInspectorPlatform.instance = GoogleMapsInspectorAndroid((int mapId) => _channel(mapId)); } + + /// Parses cluster data from dynamic json objects and returns [Cluster] object. + /// Used by `cluster#onTap` method call handler and [getClusters] response parser. + static Cluster parseCluster( + String clusterManagerIdString, + Object positionObject, + Map boundsMap, + List markerIdsList) { + final ClusterManagerId clusterManagerId = + ClusterManagerId(clusterManagerIdString); + final LatLng position = LatLng.fromJson(positionObject)!; + + final Map> latLngData = boundsMap.map( + (dynamic key, dynamic object) => MapEntry>( + key as String, object as List)); + + final LatLngBounds bounds = LatLngBounds( + northeast: LatLng.fromJson(latLngData['northeast'])!, + southwest: LatLng.fromJson(latLngData['southwest'])!); + + final List markerIds = markerIdsList + .map((dynamic markerId) => MarkerId(markerId as String)) + .toList(); + + return Cluster(clusterManagerId, position, bounds, markerIds); + } } Map _jsonForMapConfiguration(MapConfiguration config) { diff --git a/packages/google_maps_flutter/google_maps_flutter_android/lib/src/utils/cluster_manager.dart b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/utils/cluster_manager.dart new file mode 100644 index 000000000000..854690206a29 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/utils/cluster_manager.dart @@ -0,0 +1,34 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +/// Converts a Set of Cluster Managers into object serializable in JSON. +Object serializeClusterManagerSet(Set clusterManagers) { + return clusterManagers + .map((ClusterManager cm) => serializeClusterManager(cm)) + .toList(); +} + +/// Converts a Cluster Manager into object serializable in JSON. +Object serializeClusterManager(ClusterManager clusterManager) { + final Map json = {}; + json['clusterManagerId'] = clusterManager.clusterManagerId.value; + return json; +} + +/// Converts a Cluster Manager updates into object serializable in JSON. +Object serializeClusterManagerUpdates( + ClusterManagerUpdates clusterManagerUpdates) { + final Map updateMap = {}; + + updateMap['clusterManagersToAdd'] = + serializeClusterManagerSet(clusterManagerUpdates.objectsToAdd); + updateMap['clusterManagerIdsToRemove'] = clusterManagerUpdates + .objectIdsToRemove + .map((MapsObjectId id) => id.value) + .toList(); + + return updateMap; +} diff --git a/packages/google_maps_flutter/google_maps_flutter_android/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_android/pubspec.yaml index d67e85f15e9a..212599ff88f4 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_android/pubspec.yaml @@ -29,3 +29,9 @@ dev_dependencies: flutter_test: sdk: flutter plugin_platform_interface: ^2.0.0 + + +# FOR TESTING ONLY. DO NOT MERGE. +dependency_overrides: + google_maps_flutter_platform_interface: + path: ../../google_maps_flutter/google_maps_flutter_platform_interface diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/integration_test/google_maps_test.dart b/packages/google_maps_flutter/google_maps_flutter_ios/example/integration_test/google_maps_test.dart index eb00ccb673f4..f5340532984d 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/integration_test/google_maps_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/integration_test/google_maps_test.dart @@ -1024,6 +1024,85 @@ void main() { expect(tileOverlayInfo1, isNull); }, ); + + testWidgets('marker clustering', (WidgetTester tester) async { + final Key key = GlobalKey(); + const int clusterManagersAmount = 2; + const int markersPerClusterManager = 5; + final Map markers = {}; + final Set clusterManagers = {}; + + for (int i = 0; i < clusterManagersAmount; i++) { + final ClusterManagerId clusterManagerId = + ClusterManagerId('cluster_manager_$i'); + final ClusterManager clusterManager = + ClusterManager(clusterManagerId: clusterManagerId); + clusterManagers.add(clusterManager); + } + + for (final ClusterManager cm in clusterManagers) { + for (int i = 0; i < markersPerClusterManager; i++) { + final MarkerId markerId = + MarkerId('${cm.clusterManagerId.value}_marker_$i'); + final Marker marker = Marker( + markerId: markerId, + clusterManagerId: cm.clusterManagerId, + position: LatLng( + _kInitialMapCenter.latitude + i, _kInitialMapCenter.longitude)); + markers[markerId] = marker; + } + } + + final Completer controllerCompleter = + Completer(); + + final GoogleMapsInspectorPlatform inspector = + GoogleMapsInspectorPlatform.instance!; + + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: ExampleGoogleMap( + key: key, + initialCameraPosition: _kInitialCameraPosition, + clusterManagers: clusterManagers, + markers: Set.of(markers.values), + onMapCreated: (ExampleGoogleMapController googleMapController) { + controllerCompleter.complete(googleMapController); + }, + ), + )); + + final ExampleGoogleMapController controller = + await controllerCompleter.future; + + for (final ClusterManager cm in clusterManagers) { + final List clusters = await inspector.getClusters( + mapId: controller.mapId, clusterManagerId: cm.clusterManagerId); + final int markersAmountForClusterManager = clusters + .map((Cluster cluster) => cluster.count) + .reduce((int value, int element) => value + element); + expect(markersAmountForClusterManager, markersPerClusterManager); + } + + // Remove markers from clusterManagers and test that clusterManagers are empty. + for (final MapEntry entry in markers.entries) { + markers[entry.key] = _copyMarkerWithClusterManagerId(entry.value, null); + } + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: ExampleGoogleMap( + key: key, + initialCameraPosition: _kInitialCameraPosition, + clusterManagers: clusterManagers, + markers: Set.of(markers.values)), + )); + + for (final ClusterManager cm in clusterManagers) { + final List clusters = await inspector.getClusters( + mapId: controller.mapId, clusterManagerId: cm.clusterManagerId); + expect(clusters.length, 0); + } + }); } class _DebugTileProvider implements TileProvider { @@ -1069,3 +1148,26 @@ class _DebugTileProvider implements TileProvider { return Tile(width, height, byteData); } } + +Marker _copyMarkerWithClusterManagerId( + Marker marker, ClusterManagerId? clusterManagerId) { + return Marker( + markerId: marker.markerId, + alpha: marker.alpha, + anchor: marker.anchor, + consumeTapEvents: marker.consumeTapEvents, + draggable: marker.draggable, + flat: marker.flat, + icon: marker.icon, + infoWindow: marker.infoWindow, + position: marker.position, + rotation: marker.rotation, + visible: marker.visible, + zIndex: marker.zIndex, + onTap: marker.onTap, + onDragStart: marker.onDragStart, + onDrag: marker.onDrag, + onDragEnd: marker.onDragEnd, + clusterManagerId: clusterManagerId, + ); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/lib/clustering.dart b/packages/google_maps_flutter/google_maps_flutter_ios/example/lib/clustering.dart new file mode 100644 index 000000000000..1e76cf2addbb --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/lib/clustering.dart @@ -0,0 +1,238 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: public_member_api_docs + +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +import 'example_google_map.dart'; +import 'page.dart'; + +class ClusteringPage extends GoogleMapExampleAppPage { + const ClusteringPage({Key? key}) + : super(const Icon(Icons.place), 'Manage clustering', key: key); + + @override + Widget build(BuildContext context) { + return const ClusteringBody(); + } +} + +class ClusteringBody extends StatefulWidget { + const ClusteringBody({Key? key}) : super(key: key); + + @override + State createState() => ClusteringBodyState(); +} + +typedef MarkerUpdateAction = Marker Function(Marker marker); + +class ClusteringBodyState extends State { + ClusteringBodyState(); + static const LatLng center = LatLng(-33.86711, 151.1547171); + static const double _scaleFactor = 0.05; + + ExampleGoogleMapController? controller; + Map clusterManagers = + {}; + Map markers = {}; + MarkerId? selectedMarker; + int _clusterManagerIdCounter = 1; + int _markerIdCounter = 1; + Cluster? lastCluster; + + // ignore: use_setters_to_change_properties + void _onMapCreated(ExampleGoogleMapController controller) { + this.controller = controller; + } + + @override + void dispose() { + super.dispose(); + } + + void _onMarkerTapped(MarkerId markerId) { + final Marker? tappedMarker = markers[markerId]; + if (tappedMarker != null) { + setState(() { + final MarkerId? previousMarkerId = selectedMarker; + if (previousMarkerId != null && markers.containsKey(previousMarkerId)) { + final Marker resetOld = markers[previousMarkerId]! + .copyWith(iconParam: BitmapDescriptor.defaultMarker); + markers[previousMarkerId] = resetOld; + } + selectedMarker = markerId; + final Marker newMarker = tappedMarker.copyWith( + iconParam: BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueGreen, + ), + ); + markers[markerId] = newMarker; + }); + } + } + + void _addClusterManager() { + final int clusterManagerCount = clusterManagers.length; + + if (clusterManagerCount == 3) { + return; + } + + final String clusterManagerIdVal = + 'cluster_manager_id_$_clusterManagerIdCounter'; + _clusterManagerIdCounter++; + final ClusterManagerId clusterManagerId = + ClusterManagerId(clusterManagerIdVal); + + final ClusterManager clusterManager = ClusterManager( + clusterManagerId: clusterManagerId, + onClusterTap: (Cluster cluster) => setState(() { + lastCluster = cluster; + }), + ); + + setState(() { + clusterManagers[clusterManagerId] = clusterManager; + }); + _addMarkersToCluster(clusterManager); + } + + void _removeClusterManager(ClusterManager clusterManager) { + setState(() { + // Remove markers managed by cluster manager to be removed. + markers.removeWhere((MarkerId key, Marker marker) => + marker.clusterManagerId == clusterManager.clusterManagerId); + // Remove cluster manager. + clusterManagers.remove(clusterManager.clusterManagerId); + }); + } + + void _addMarkersToCluster(ClusterManager clusterManager) { + for (int i = 0; i < 15; i++) { + final String markerIdVal = + '${clusterManager.clusterManagerId.value}_marker_id_$_markerIdCounter'; + _markerIdCounter++; + final MarkerId markerId = MarkerId(markerIdVal); + + final int clusterManagerIndex = + clusterManagers.values.toList().indexOf(clusterManager); + final Marker marker = Marker( + clusterManagerId: clusterManager.clusterManagerId, + markerId: markerId, + position: LatLng( + center.latitude + _getRandomOffset(), + center.longitude + + _getRandomOffset() + + clusterManagerIndex * _scaleFactor * 2, + ), + infoWindow: InfoWindow(title: markerIdVal, snippet: '*'), + onTap: () => _onMarkerTapped(markerId), + ); + markers[markerId] = marker; + } + setState(() {}); + } + + double _getRandomOffset() { + return (Random().nextDouble() - 0.5) * _scaleFactor; + } + + void _remove(MarkerId markerId) { + setState(() { + if (markers.containsKey(markerId)) { + markers.remove(markerId); + } + }); + } + + void _changeMarkersRotation() { + for (final MarkerId markerId in markers.keys) { + final Marker marker = markers[markerId]!; + final double current = marker.rotation; + markers[markerId] = marker.copyWith( + rotationParam: current == 315.0 ? 0.0 : current + 45.0, + ); + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final MarkerId? selectedId = selectedMarker; + return Stack(children: [ + Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: ExampleGoogleMap( + onMapCreated: _onMapCreated, + initialCameraPosition: const CameraPosition( + target: LatLng(-33.852, 151.211), + zoom: 11.0, + ), + markers: Set.of(markers.values), + clusterManagers: Set.of(clusterManagers.values), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: () => _addClusterManager(), + child: const Text('Add cluster manager'), + ), + TextButton( + onPressed: clusterManagers.isEmpty + ? null + : () => _removeClusterManager(clusterManagers.values.last), + child: const Text('Remove cluster manager'), + ), + ], + ), + Wrap( + alignment: WrapAlignment.spaceEvenly, + children: [ + for (final MapEntry clusterEntry + in clusterManagers.entries) + TextButton( + onPressed: () => _addMarkersToCluster(clusterEntry.value), + child: Text('Add markers to ${clusterEntry.key.value}'), + ), + ], + ), + Wrap( + alignment: WrapAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: selectedId == null + ? null + : () { + _remove(selectedId); + setState(() { + selectedMarker = null; + }); + }, + child: const Text('Remove selected marker'), + ), + TextButton( + onPressed: + markers.isEmpty ? null : () => _changeMarkersRotation(), + child: const Text('Change all markers rotation'), + ), + ], + ), + if (lastCluster != null) + Padding( + padding: const EdgeInsets.all(10), + child: Text( + 'Cluster with ${lastCluster!.count} markers clicked at ${lastCluster!.position}')), + ], + ), + ]); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/lib/example_google_map.dart b/packages/google_maps_flutter/google_maps_flutter_ios/example/lib/example_google_map.dart index 1c1261cb5b82..883911bf3926 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/lib/example_google_map.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/lib/example_google_map.dart @@ -90,6 +90,9 @@ class ExampleGoogleMapController { .listen((MapTapEvent e) => _googleMapState.onTap(e.position)); GoogleMapsFlutterPlatform.instance.onLongPress(mapId: mapId).listen( (MapLongPressEvent e) => _googleMapState.onLongPress(e.position)); + GoogleMapsFlutterPlatform.instance + .onClusterTap(mapId: mapId) + .listen((ClusterTapEvent e) => _googleMapState.onClusterTap(e.value)); } /// Updates configuration options of the map user interface. @@ -104,6 +107,13 @@ class ExampleGoogleMapController { .updateMarkers(markerUpdates, mapId: mapId); } + /// Updates cluster manager configuration. + Future _updateClusterManagers( + ClusterManagerUpdates clusterManagerUpdates) { + return GoogleMapsFlutterPlatform.instance + .updateClusterManagers(clusterManagerUpdates, mapId: mapId); + } + /// Updates polygon configuration. Future _updatePolygons(PolygonUpdates polygonUpdates) { return GoogleMapsFlutterPlatform.instance @@ -241,6 +251,7 @@ class ExampleGoogleMap extends StatefulWidget { this.polygons = const {}, this.polylines = const {}, this.circles = const {}, + this.clusterManagers = const {}, this.onCameraMoveStarted, this.tileOverlays = const {}, this.onCameraMove, @@ -314,6 +325,9 @@ class ExampleGoogleMap extends StatefulWidget { /// Tile overlays to be placed on the map. final Set tileOverlays; + /// Cluster Managers to be placed for the map. + final Set clusterManagers; + /// Called when the camera starts moving. final VoidCallback? onCameraMoveStarted; @@ -364,6 +378,8 @@ class _ExampleGoogleMapState extends State { Map _polygons = {}; Map _polylines = {}; Map _circles = {}; + Map _clusterManagers = + {}; late MapConfiguration _mapConfiguration; @override @@ -383,6 +399,7 @@ class _ExampleGoogleMapState extends State { polygons: widget.polygons, polylines: widget.polylines, circles: widget.circles, + clusterManagers: widget.clusterManagers, ), mapConfiguration: _mapConfiguration, ); @@ -392,6 +409,7 @@ class _ExampleGoogleMapState extends State { void initState() { super.initState(); _mapConfiguration = _configurationFromMapWidget(widget); + _clusterManagers = keyByClusterManagerId(widget.clusterManagers); _markers = keyByMarkerId(widget.markers); _polygons = keyByPolygonId(widget.polygons); _polylines = keyByPolylineId(widget.polylines); @@ -409,6 +427,7 @@ class _ExampleGoogleMapState extends State { void didUpdateWidget(ExampleGoogleMap oldWidget) { super.didUpdateWidget(oldWidget); _updateOptions(); + _updateClusterManagers(); _updateMarkers(); _updatePolygons(); _updatePolylines(); @@ -434,6 +453,14 @@ class _ExampleGoogleMapState extends State { _markers = keyByMarkerId(widget.markers); } + Future _updateClusterManagers() async { + final ExampleGoogleMapController controller = await _controller.future; + // ignore: unawaited_futures + controller._updateClusterManagers(ClusterManagerUpdates.from( + _clusterManagers.values.toSet(), widget.clusterManagers)); + _clusterManagers = keyByClusterManagerId(widget.clusterManagers); + } + Future _updatePolygons() async { final ExampleGoogleMapController controller = await _controller.future; controller._updatePolygons( @@ -511,6 +538,12 @@ class _ExampleGoogleMapState extends State { void onLongPress(LatLng position) { widget.onLongPress?.call(position); } + + void onClusterTap(Cluster cluster) { + final ClusterManager? clusterManager = + _clusterManagers[cluster.clusterManagerId]; + clusterManager?.onClusterTap?.call(cluster); + } } /// Builds a [MapConfiguration] from the given [map]. diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/lib/main.dart b/packages/google_maps_flutter/google_maps_flutter_ios/example/lib/main.dart index de75162b09dd..62fa39dd3341 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/lib/main.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'animate_camera.dart'; +import 'clustering.dart'; import 'lite_mode.dart'; import 'map_click.dart'; import 'map_coordinates.dart'; @@ -37,6 +38,7 @@ final List _allPages = [ const SnapshotPage(), const LiteModePage(), const TileOverlayPage(), + const ClusteringPage(), ]; /// MapsDemo is the Main Application. diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios/example/pubspec.yaml index ac27996fbc25..2f2df32e987d 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/pubspec.yaml @@ -32,3 +32,11 @@ flutter: uses-material-design: true assets: - assets/ + + +# FOR TESTING ONLY. DO NOT MERGE. +dependency_overrides: + google_maps_flutter_ios: + path: ../../../google_maps_flutter/google_maps_flutter_ios + google_maps_flutter_platform_interface: + path: ../../../google_maps_flutter/google_maps_flutter_platform_interface diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/ClusterManagersController.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/ClusterManagersController.h new file mode 100644 index 000000000000..c68a9f467441 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/ClusterManagersController.h @@ -0,0 +1,64 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import GoogleMapsUtils; +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +// Defines cluster managers controller. +@interface FLTClusterManagersController : NSObject + +/** + * Initializes FLTClusterManagersController. + * + * @param methodChannel A Flutter method channel used to send events. + * @param mapView A map view that will be used to display clustered markers. + */ +- (instancetype)initWithMethodChannel:(FlutterMethodChannel *)methodChannel + mapView:(GMSMapView *)mapView; + +/** + * Creates ClusterManagers and initializes them. + * + * @param clusterManagersToAdd List of clustermanager object data. + */ +- (void)addClusterManagers:(NSArray *)clusterManagersToAdd; + +/** + * Removes requested ClusterManagers from the controller. + * + * @param identifiers List of clusterManagerIds to remove. + */ +- (void)removeClusterManagers:(NSArray *)identifiers; + +/** + * Get ClusterManager for the given id. + * + * @param identifier identifier of the ClusterManager. + * @return GMUClusterManager if found otherwise NSNull. + */ +- (GMUClusterManager *)getClusterManagerWithIdentifier:(NSString *)identifier; + +/** + * Converts all clusters from the specific ClusterManager to result object response. + * + * @param identifier identifier of the ClusterManager. + * @param result FlutterResult object to be updated with cluster data. + */ +- (void)getClustersWithIdentifier:(NSString *)identifier result:(FlutterResult)result; + +/** + * Called when cluster marker is tapped on the map. + * + * @param cluster GMUStaticCluster object. + */ +- (void)handleTapCluster:(GMUStaticCluster *)cluster; + +/// Calls cluster method of all ClusterManagers. +- (void)clusterAll; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/ClusterManagersController.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/ClusterManagersController.m new file mode 100644 index 000000000000..7f326e1bba86 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/ClusterManagersController.m @@ -0,0 +1,129 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "ClusterManagersController.h" +#import "FLTGoogleMapJSONConversions.h" +#import "GMSMarker+Userdata.h" + +@interface FLTClusterManagersController () + +@property(strong, nonatomic) NSMutableDictionary *clusterManagerIdToManagers; +@property(strong, nonatomic) FlutterMethodChannel *methodChannel; +@property(weak, nonatomic) GMSMapView *mapView; + +@end + +@implementation FLTClusterManagersController +- (instancetype)initWithMethodChannel:(FlutterMethodChannel *)methodChannel + mapView:(GMSMapView *)mapView { + self = [super init]; + if (self) { + _methodChannel = methodChannel; + _mapView = mapView; + _clusterManagerIdToManagers = [[NSMutableDictionary alloc] init]; + } + return self; +} + +- (void)addClusterManagers:(NSArray *)clusterManagersToAdd { + for (NSDictionary *clusterDict in clusterManagersToAdd) { + NSString *identifier = clusterDict[@"clusterManagerId"]; + id algorithm = [[GMUNonHierarchicalDistanceBasedAlgorithm alloc] init]; + id iconGenerator = [[GMUDefaultClusterIconGenerator alloc] init]; + id renderer = + [[GMUDefaultClusterRenderer alloc] initWithMapView:self.mapView + clusterIconGenerator:iconGenerator]; + GMUClusterManager *clusterManager = [[GMUClusterManager alloc] initWithMap:self.mapView + algorithm:algorithm + renderer:renderer]; + self.clusterManagerIdToManagers[identifier] = clusterManager; + } +} + +- (void)removeClusterManagers:(NSArray *)identifiers { + for (NSString *identifier in identifiers) { + GMUClusterManager *clusterManager = [self.clusterManagerIdToManagers objectForKey:identifier]; + if (!clusterManager) { + continue; + } + [clusterManager clearItems]; + [self.clusterManagerIdToManagers removeObjectForKey:identifier]; + } +} + +- (GMUClusterManager *)getClusterManagerWithIdentifier:(NSString *)identifier { + return [self.clusterManagerIdToManagers objectForKey:identifier]; +} + +- (void)clusterAll { + for (GMUClusterManager *clusterManager in [self.clusterManagerIdToManagers allValues]) { + [clusterManager cluster]; + } +} + +- (void)getClustersWithIdentifier:(NSString *)identifier result:(FlutterResult)result { + GMUClusterManager *clusterManager = [self.clusterManagerIdToManagers objectForKey:identifier]; + + if (!clusterManager) { + result([FlutterError errorWithCode:@"Invalid clusterManagerId" + message:@"getClusters called with invalid clusterManagerId" + details:nil]); + return; + } + + NSMutableArray *response = [[NSMutableArray alloc] init]; + + // Ref: + // https://github.com/googlemaps/google-maps-ios-utils/blob/main/src/Clustering/GMUClusterManager.m#L94. + NSUInteger integralZoom = (NSUInteger)floorf(_mapView.camera.zoom + 0.5f); + NSArray> *clusters = [clusterManager.algorithm clustersAtZoom:integralZoom]; + for (id cluster in clusters) { + NSDictionary *clusterDict = [self getClusterDict:cluster]; + if (clusterDict == nil) { + continue; + } + [response addObject:clusterDict]; + } + result(response); +} + +- (void)handleTapCluster:(GMUStaticCluster *)cluster { + NSDictionary *clusterDict = [self getClusterDict:cluster]; + if (clusterDict != nil) { + [self.methodChannel invokeMethod:@"cluster#onTap" arguments:clusterDict]; + } +} + +- (NSString *)getClusterManagerIdFrom:(GMUStaticCluster *)cluster { + if ([cluster.items count] == 0) { + return nil; + } + + GMSMarker *firstMarker = (GMSMarker *)cluster.items[0]; + return [firstMarker getClusterManagerId]; +} + +- (NSDictionary *)getClusterDict:(GMUStaticCluster *)cluster { + NSString *clusterManagerId = [self getClusterManagerIdFrom:cluster]; + if (clusterManagerId == nil) { + return nil; + } + + NSMutableArray *markerIds = [[NSMutableArray alloc] init]; + GMSCoordinateBounds *bounds = [[GMSCoordinateBounds alloc] init]; + + for (GMSMarker *marker in cluster.items) { + [markerIds addObject:[marker getMarkerId]]; + bounds = [bounds includingCoordinate:marker.position]; + } + + return @{ + @"clusterManagerId" : clusterManagerId, + @"position" : [FLTGoogleMapJSONConversions arrayFromLocation:cluster.position], + @"bounds" : [FLTGoogleMapJSONConversions dictionaryFromCoordinateBounds:bounds], + @"markerIds" : markerIds + }; +} + +@end diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapsPlugin.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapsPlugin.h index 26f69eaf3882..28c927f16654 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapsPlugin.h +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapsPlugin.h @@ -4,6 +4,7 @@ #import #import +#import "ClusterManagersController.h" #import "GoogleMapCircleController.h" #import "GoogleMapController.h" #import "GoogleMapMarkerController.h" diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GMSMarker+Userdata.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GMSMarker+Userdata.h new file mode 100644 index 000000000000..28f28d66760f --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GMSMarker+Userdata.h @@ -0,0 +1,42 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface GMSMarker (Userdata) + +/** + * Sets MarkerId to GMSMarker UserData. + * + * @param markerId Id of marker. + */ +- (void)setMarkerId:(NSString *)markerId; + +/** + * Sets MarkerId and ClusterManagerId to GMSMarker UserData. + * + * @param markerId Id of marker. + * @param clusterManagerId Id of cluster manager. + */ +- (void)setMarkerID:(NSString *)markerId andClusterManagerId:(NSString *)clusterManagerId; + +/** + * Get MarkerId from GMSMarker UserData. + * + * @return NSString if found otherwise nil. + */ +- (NSString *)getMarkerId; + +/** + * Get ClusterManagerId from GMSMarker UserData. + * + * @return NSString if found otherwise nil. + */ +- (NSString *)getClusterManagerId; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GMSMarker+Userdata.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GMSMarker+Userdata.m new file mode 100644 index 000000000000..21aa07d850b9 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GMSMarker+Userdata.m @@ -0,0 +1,35 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "GMSMarker+Userdata.h" + +@implementation GMSMarker (Userdata) + +- (void)setMarkerId:(NSString *)markerId { + self.userData = @[ markerId ]; +} + +- (void)setMarkerID:(NSString *)markerId andClusterManagerId:(NSString *)clusterManagerId { + self.userData = @[ markerId, clusterManagerId ]; +} + +- (NSString *)getMarkerId { + if ([self.userData count] == 0) { + return nil; + } + return self.userData[0]; +} + +- (NSString *)getClusterManagerId { + if ([self.userData count] != 2) { + return nil; + } + + NSString *clusterManagerId = self.userData[1]; + if (clusterManagerId == (id)[NSNull null]) { + return nil; + } + return clusterManagerId; +} +@end diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.h index d1069ac16b39..501fd4b11b42 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.h +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.h @@ -4,6 +4,7 @@ #import #import +#import "ClusterManagersController.h" #import "GoogleMapCircleController.h" #import "GoogleMapMarkerController.h" #import "GoogleMapPolygonController.h" diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.m index bd50c2d7a6de..99535b799f04 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.m +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.m @@ -2,9 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +@import GoogleMapsUtils; #import "GoogleMapController.h" #import "FLTGoogleMapJSONConversions.h" #import "FLTGoogleMapTileOverlayController.h" +#import "GMSMarker+Userdata.h" #pragma mark - Conversion of JSON-like values sent via platform channels. Forward declarations. @@ -62,6 +64,7 @@ @interface FLTGoogleMapController () @property(nonatomic, strong) FlutterMethodChannel *channel; @property(nonatomic, assign) BOOL trackCameraPosition; @property(nonatomic, weak) NSObject *registrar; +@property(nonatomic, strong) FLTClusterManagersController *clusterManagersController; @property(nonatomic, strong) FLTMarkersController *markersController; @property(nonatomic, strong) FLTPolygonsController *polygonsController; @property(nonatomic, strong) FLTPolylinesController *polylinesController; @@ -106,9 +109,13 @@ - (instancetype)initWithMapView:(GMSMapView *_Nonnull)mapView _mapView.delegate = weakSelf; _mapView.paddingAdjustmentBehavior = kGMSMapViewPaddingAdjustmentBehaviorNever; _registrar = registrar; - _markersController = [[FLTMarkersController alloc] initWithMethodChannel:_channel - mapView:_mapView - registrar:registrar]; + _clusterManagersController = + [[FLTClusterManagersController alloc] initWithMethodChannel:_channel mapView:_mapView]; + _markersController = + [[FLTMarkersController alloc] initWithClusterManagersController:_clusterManagersController + channel:_channel + mapView:_mapView + registrar:registrar]; _polygonsController = [[FLTPolygonsController alloc] init:_channel mapView:_mapView registrar:registrar]; @@ -121,6 +128,11 @@ - (instancetype)initWithMapView:(GMSMapView *_Nonnull)mapView _tileOverlaysController = [[FLTTileOverlaysController alloc] init:_channel mapView:_mapView registrar:registrar]; + + id clusterManagersToAdd = args[@"clusterManagersToAdd"]; + if ([clusterManagersToAdd isKindOfClass:[NSArray class]]) { + [_clusterManagersController addClusterManagers:clusterManagersToAdd]; + } id markersToAdd = args[@"markersToAdd"]; if ([markersToAdd isKindOfClass:[NSArray class]]) { [_markersController addMarkers:markersToAdd]; @@ -259,6 +271,7 @@ - (void)onMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { if ([markerIdsToRemove isKindOfClass:[NSArray class]]) { [self.markersController removeMarkersWithIdentifiers:markerIdsToRemove]; } + [self.clusterManagersController clusterAll]; result(nil); } else if ([call.method isEqualToString:@"markers#showInfoWindow"]) { id markerId = call.arguments[@"markerId"]; @@ -287,6 +300,19 @@ - (void)onMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { message:@"isInfoWindowShown called with invalid markerId" details:nil]); } + } else if ([call.method isEqualToString:@"clusterManagers#update"]) { + id clusterManagersToAdd = call.arguments[@"clusterManagersToAdd"]; + if ([clusterManagersToAdd isKindOfClass:[NSArray class]]) { + [self.clusterManagersController addClusterManagers:clusterManagersToAdd]; + } + id clusterManagerIdsToRemove = call.arguments[@"clusterManagerIdsToRemove"]; + if ([clusterManagerIdsToRemove isKindOfClass:[NSArray class]]) { + [self.clusterManagersController removeClusterManagers:clusterManagerIdsToRemove]; + } + result(nil); + } else if ([call.method isEqualToString:@"clusterManager#getClusters"]) { + id clusterManagerId = call.arguments[@"clusterManagerId"]; + [self.clusterManagersController getClustersWithIdentifier:clusterManagerId result:result]; } else if ([call.method isEqualToString:@"polygons#update"]) { id polygonsToAdd = call.arguments[@"polygonsToAdd"]; if ([polygonsToAdd isKindOfClass:[NSArray class]]) { @@ -523,28 +549,32 @@ - (void)mapView:(GMSMapView *)mapView idleAtCameraPosition:(GMSCameraPosition *) } - (BOOL)mapView:(GMSMapView *)mapView didTapMarker:(GMSMarker *)marker { - NSString *markerId = marker.userData[0]; - return [self.markersController didTapMarkerWithIdentifier:markerId]; + if ([marker.userData conformsToProtocol:@protocol(GMUCluster)]) { + GMUStaticCluster *cluster = marker.userData; + [self.clusterManagersController handleTapCluster:cluster]; + // When NO is returned the map will focus on the cluster + return NO; + } + return [self.markersController didTapMarkerWithIdentifier:[marker getMarkerId]]; } - (void)mapView:(GMSMapView *)mapView didEndDraggingMarker:(GMSMarker *)marker { - NSString *markerId = marker.userData[0]; - [self.markersController didEndDraggingMarkerWithIdentifier:markerId location:marker.position]; + [self.markersController didEndDraggingMarkerWithIdentifier:[marker getMarkerId] + location:marker.position]; } - (void)mapView:(GMSMapView *)mapView didStartDraggingMarker:(GMSMarker *)marker { - NSString *markerId = marker.userData[0]; - [self.markersController didStartDraggingMarkerWithIdentifier:markerId location:marker.position]; + [self.markersController didStartDraggingMarkerWithIdentifier:[marker getMarkerId] + location:marker.position]; } - (void)mapView:(GMSMapView *)mapView didDragMarker:(GMSMarker *)marker { - NSString *markerId = marker.userData[0]; - [self.markersController didDragMarkerWithIdentifier:markerId location:marker.position]; + [self.markersController didDragMarkerWithIdentifier:[marker getMarkerId] + location:marker.position]; } - (void)mapView:(GMSMapView *)mapView didTapInfoWindowOfMarker:(GMSMarker *)marker { - NSString *markerId = marker.userData[0]; - [self.markersController didTapInfoWindowOfMarkerWithIdentifier:markerId]; + [self.markersController didTapInfoWindowOfMarkerWithIdentifier:[marker getMarkerId]]; } - (void)mapView:(GMSMapView *)mapView didTapOverlay:(GMSOverlay *)overlay { NSString *overlayId = overlay.userData[0]; diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.h index a33d48073dd2..d458a174ecbe 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.h +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.h @@ -4,6 +4,7 @@ #import #import +#import "ClusterManagersController.h" #import "GoogleMapController.h" NS_ASSUME_NONNULL_BEGIN @@ -11,9 +12,10 @@ NS_ASSUME_NONNULL_BEGIN // Defines marker controllable by Flutter. @interface FLTGoogleMapMarkerController : NSObject @property(assign, nonatomic, readonly) BOOL consumeTapEvents; -- (instancetype)initMarkerWithPosition:(CLLocationCoordinate2D)position - identifier:(NSString *)identifier - mapView:(GMSMapView *)mapView; +- (instancetype)initWithMarker:(GMSMarker *)marker + identifier:(NSString *)identifier + clusterManagerId:(NSString *)identifier + mapView:(GMSMapView *)mapView; - (void)showInfoWindow; - (void)hideInfoWindow; - (BOOL)isInfoWindowShown; @@ -21,9 +23,10 @@ NS_ASSUME_NONNULL_BEGIN @end @interface FLTMarkersController : NSObject -- (instancetype)initWithMethodChannel:(FlutterMethodChannel *)methodChannel - mapView:(GMSMapView *)mapView - registrar:(NSObject *)registrar; +- (instancetype)initWithClusterManagersController:(FLTClusterManagersController *)clusterManagers + channel:(FlutterMethodChannel *)channel + mapView:(GMSMapView *)mapView + registrar:(NSObject *)registrar; - (void)addMarkers:(NSArray *)markersToAdd; - (void)changeMarkers:(NSArray *)markersToChange; - (void)removeMarkersWithIdentifiers:(NSArray *)identifiers; diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.m index dd07e791a888..008ffe085539 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.m +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.m @@ -4,25 +4,31 @@ #import "GoogleMapMarkerController.h" #import "FLTGoogleMapJSONConversions.h" +#import "GMSMarker+Userdata.h" @interface FLTGoogleMapMarkerController () @property(strong, nonatomic) GMSMarker *marker; @property(weak, nonatomic) GMSMapView *mapView; @property(assign, nonatomic, readwrite) BOOL consumeTapEvents; +@property(strong, nonatomic) NSString *clusterManagerId; +@property(strong, nonatomic) NSString *markerId; @end @implementation FLTGoogleMapMarkerController -- (instancetype)initMarkerWithPosition:(CLLocationCoordinate2D)position - identifier:(NSString *)identifier - mapView:(GMSMapView *)mapView { +- (instancetype)initWithMarker:(GMSMarker *)marker + identifier:(NSString *)identifier + clusterManagerId:(NSString *)clusterManagerId + mapView:(GMSMapView *)mapView { self = [super init]; if (self) { - _marker = [GMSMarker markerWithPosition:position]; + _marker = marker; + _markerId = identifier; _mapView = mapView; - _marker.userData = @[ identifier ]; + _clusterManagerId = clusterManagerId; + [self updateMarkerUserData]; } return self; } @@ -83,15 +89,31 @@ - (void)setRotation:(CLLocationDegrees)rotation { } - (void)setVisible:(BOOL)visible { - self.marker.map = visible ? self.mapView : nil; + // If marker belongs the cluster manager, visibility need to be controlled with the opacity + // as the cluster manager controls when marker is on the map and when not. + // Alpha value for marker must always be interpreted before visibility value. + if (self.clusterManagerId && self.clusterManagerId != (id)[NSNull null]) { + self.marker.opacity = visible ? self.marker.opacity : 0.0f; + } else { + self.marker.map = visible ? self.mapView : nil; + } } - (void)setZIndex:(int)zIndex { self.marker.zIndex = zIndex; } +- (void)updateMarkerUserData { + if (self.clusterManagerId) { + [self.marker setMarkerID:self.markerId andClusterManagerId:self.clusterManagerId]; + } else { + [self.marker setMarkerId:self.markerId]; + } +} + - (void)interpretMarkerOptions:(NSDictionary *)data registrar:(NSObject *)registrar { + // Alpha must be always set before visibility. NSNumber *alpha = data[@"alpha"]; if (alpha && alpha != (id)[NSNull null]) { [self setAlpha:[alpha floatValue]]; @@ -224,6 +246,7 @@ - (UIImage *)scaleImage:(UIImage *)image by:(id)scaleParam { @interface FLTMarkersController () @property(strong, nonatomic) NSMutableDictionary *markerIdentifierToController; +@property(weak, nonatomic) FLTClusterManagersController *clusterManagersController; @property(strong, nonatomic) FlutterMethodChannel *methodChannel; @property(weak, nonatomic) NSObject *registrar; @property(weak, nonatomic) GMSMapView *mapView; @@ -232,6 +255,14 @@ @interface FLTMarkersController () @implementation FLTMarkersController +- (instancetype)initWithClusterManagersController:(FLTClusterManagersController *)clusterManagers + channel:(FlutterMethodChannel *)channel + mapView:(GMSMapView *)mapView + registrar:(NSObject *)registrar { + _clusterManagersController = clusterManagers; + return [self initWithMethodChannel:channel mapView:mapView registrar:registrar]; +} + - (instancetype)initWithMethodChannel:(FlutterMethodChannel *)methodChannel mapView:(GMSMapView *)mapView registrar:(NSObject *)registrar { @@ -247,37 +278,75 @@ - (instancetype)initWithMethodChannel:(FlutterMethodChannel *)methodChannel - (void)addMarkers:(NSArray *)markersToAdd { for (NSDictionary *marker in markersToAdd) { - CLLocationCoordinate2D position = [FLTMarkersController getPosition:marker]; - NSString *identifier = marker[@"markerId"]; - FLTGoogleMapMarkerController *controller = - [[FLTGoogleMapMarkerController alloc] initMarkerWithPosition:position - identifier:identifier - mapView:self.mapView]; - [controller interpretMarkerOptions:marker registrar:self.registrar]; - self.markerIdentifierToController[identifier] = controller; + [self addMarker:marker]; } } +- (void)addMarker:(NSDictionary *)markerToAdd { + NSString *identifier = markerToAdd[@"markerId"]; + NSString *clusterManagerId = markerToAdd[@"clusterManagerId"]; + CLLocationCoordinate2D position = [FLTMarkersController getPosition:markerToAdd]; + GMSMarker *marker = [GMSMarker markerWithPosition:position]; + FLTGoogleMapMarkerController *controller = + [[FLTGoogleMapMarkerController alloc] initWithMarker:marker + identifier:identifier + clusterManagerId:clusterManagerId + mapView:self.mapView]; + [controller interpretMarkerOptions:markerToAdd registrar:self.registrar]; + if (clusterManagerId && clusterManagerId != (id)[NSNull null]) { + GMUClusterManager *clusterManager = + [_clusterManagersController getClusterManagerWithIdentifier:clusterManagerId]; + if (marker && clusterManager != (id)[NSNull null]) { + [clusterManager addItem:(id)marker]; + } + } + self.markerIdentifierToController[identifier] = controller; +} + - (void)changeMarkers:(NSArray *)markersToChange { for (NSDictionary *marker in markersToChange) { - NSString *identifier = marker[@"markerId"]; - FLTGoogleMapMarkerController *controller = self.markerIdentifierToController[identifier]; - if (!controller) { - continue; - } - [controller interpretMarkerOptions:marker registrar:self.registrar]; + [self changeMarker:marker]; + } +} + +- (void)changeMarker:(NSDictionary *)markerToChange { + NSString *identifier = markerToChange[@"markerId"]; + NSString *clusterManagerId = markerToChange[@"clusterManagerId"]; + FLTGoogleMapMarkerController *controller = self.markerIdentifierToController[identifier]; + if (!controller) { + return; + } + NSString *oldClusterManagerId = [controller clusterManagerId]; + if (![oldClusterManagerId isEqualToString:clusterManagerId]) { + [self removeMarker:identifier]; + [self addMarker:markerToChange]; + } else { + [controller interpretMarkerOptions:markerToChange registrar:self.registrar]; } } - (void)removeMarkersWithIdentifiers:(NSArray *)identifiers { for (NSString *identifier in identifiers) { - FLTGoogleMapMarkerController *controller = self.markerIdentifierToController[identifier]; - if (!controller) { - continue; + [self removeMarker:identifier]; + } +} + +- (void)removeMarker:(NSString *)identifier { + FLTGoogleMapMarkerController *controller = self.markerIdentifierToController[identifier]; + if (!controller) { + return; + } + NSString *clusterManagerId = [controller clusterManagerId]; + if (clusterManagerId && clusterManagerId != (id)[NSNull null]) { + GMUClusterManager *clusterManager = + [_clusterManagersController getClusterManagerWithIdentifier:clusterManagerId]; + if (controller.marker && clusterManager != (id)[NSNull null]) { + [clusterManager removeItem:(id)controller.marker]; } + } else { [controller removeMarker]; - [self.markerIdentifierToController removeObjectForKey:identifier]; } + [self.markerIdentifierToController removeObjectForKey:identifier]; } - (BOOL)didTapMarkerWithIdentifier:(NSString *)identifier { diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/google_maps_flutter_ios-umbrella.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/google_maps_flutter_ios-umbrella.h index 791c3aaea6c3..e1c8457e8a9e 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/google_maps_flutter_ios-umbrella.h +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/google_maps_flutter_ios-umbrella.h @@ -6,6 +6,7 @@ #import #import #import +#import FOUNDATION_EXPORT double google_maps_flutterVersionNumber; FOUNDATION_EXPORT const unsigned char google_maps_flutterVersionString[]; diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios.podspec b/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios.podspec index 14be02f372e4..35dc8526e2e9 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios.podspec +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios.podspec @@ -3,7 +3,7 @@ # Pod::Spec.new do |s| s.name = 'google_maps_flutter_ios' - s.version = '0.0.1' + s.version = '0.0.2' s.summary = 'Google Maps for Flutter' s.description = <<-DESC A Flutter plugin that provides a Google Maps widget. @@ -19,8 +19,14 @@ Downloaded by pub (not CocoaPods). s.module_map = 'Classes/google_maps_flutter_ios.modulemap' s.dependency 'Flutter' s.dependency 'GoogleMaps' + s.dependency 'Google-Maps-iOS-Utils' s.static_framework = true s.platform = :ios, '9.0' + # Enabling Swift support for Google-Maps-iOS-Utils + s.swift_version = '5.0' + s.xcconfig = { + 'LD_RUNPATH_SEARCH_PATHS' => '$(inherited) /usr/lib/swift', + } # GoogleMaps does not support arm64 simulators. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'arm64' } end diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_map_inspector_ios.dart b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_map_inspector_ios.dart index 8fae1a35e316..a041973293df 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_map_inspector_ios.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_map_inspector_ios.dart @@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; +import 'google_maps_flutter_ios.dart'; + /// An Android of implementation of [GoogleMapsInspectorPlatform]. @visibleForTesting class GoogleMapsInspectorIOS extends GoogleMapsInspectorPlatform { @@ -110,4 +112,23 @@ class GoogleMapsInspectorIOS extends GoogleMapsInspectorPlatform { return (await _channelProvider(mapId)! .invokeMethod('map#isTrafficEnabled'))!; } + + @override + Future> getClusters({ + required int mapId, + required ClusterManagerId clusterManagerId, + }) async { + final List data = (await _channelProvider(mapId)! + .invokeMethod>('clusterManager#getClusters', + {'clusterManagerId': clusterManagerId.value}))!; + return data.map((dynamic clusterData) { + final Map clusterDataMap = + Map.from(clusterData as Map); + return GoogleMapsFlutterIOS.parseCluster( + clusterDataMap['clusterManagerId']! as String, + clusterDataMap['position']! as Object, + clusterDataMap['bounds']! as Map, + clusterDataMap['markerIds']! as List); + }).toList(); + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_maps_flutter_ios.dart b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_maps_flutter_ios.dart index a0b46f0a96d1..643315b2d7de 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_maps_flutter_ios.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_maps_flutter_ios.dart @@ -15,6 +15,7 @@ import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platf import 'package:stream_transform/stream_transform.dart'; import 'google_map_inspector_ios.dart'; +import 'utils/cluster_manager.dart'; // TODO(stuartmorgan): Remove the dependency on platform interface toJson // methods. Channel serialization details should all be package-internal. @@ -166,6 +167,11 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { return _events(mapId).whereType(); } + @override + Stream onClusterTap({required int mapId}) { + return _events(mapId).whereType(); + } + Future _handleMethodCall(MethodCall call, int mapId) async { switch (call.method) { case 'camera#onMoveStarted': @@ -271,6 +277,18 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { arguments['zoom'] as int?, ); return tile.toJson(); + case 'cluster#onTap': + final Map arguments = _getArgumentDictionary(call); + final Cluster cluster = parseCluster( + arguments['clusterManagerId']! as String, + arguments['position']!, + arguments['bounds']! as Map, + arguments['markerIds']! as List); + _mapEventStreamController.add(ClusterTapEvent( + mapId, + cluster, + )); + break; default: throw MissingPluginException(); } @@ -365,6 +383,18 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { ); } + @override + Future updateClusterManagers( + ClusterManagerUpdates clusterManagerUpdates, { + required int mapId, + }) { + assert(clusterManagerUpdates != null); + return _channel(mapId).invokeMethod( + 'clusterManagers#update', + serializeClusterManagerUpdates(clusterManagerUpdates), + ); + } + @override Future clearTileCache( TileOverlayId tileOverlayId, { @@ -506,6 +536,8 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { 'polylinesToAdd': serializePolylineSet(mapObjects.polylines), 'circlesToAdd': serializeCircleSet(mapObjects.circles), 'tileOverlaysToAdd': serializeTileOverlaySet(mapObjects.tileOverlays), + 'clusterManagersToAdd': + serializeClusterManagerSet(mapObjects.clusterManagers), }; return UiKitView( @@ -545,6 +577,7 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { Set polylines = const {}, Set circles = const {}, Set tileOverlays = const {}, + Set clusterManagers = const {}, Set>? gestureRecognizers, Map mapOptions = const {}, }) { @@ -559,6 +592,7 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { polygons: polygons, polylines: polylines, circles: circles, + clusterManagers: clusterManagers, tileOverlays: tileOverlays), mapOptions: mapOptions, ); @@ -574,6 +608,7 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { Set polylines = const {}, Set circles = const {}, Set tileOverlays = const {}, + Set clusterManagers = const {}, Set>? gestureRecognizers, Map mapOptions = const {}, }) { @@ -587,6 +622,7 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { polylines: polylines, circles: circles, tileOverlays: tileOverlays, + clusterManagers: clusterManagers, gestureRecognizers: gestureRecognizers, mapOptions: mapOptions, ); @@ -598,6 +634,32 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { GoogleMapsInspectorPlatform.instance = GoogleMapsInspectorIOS((int mapId) => _channel(mapId)); } + + /// Parses cluster data from dynamic json objects and returns [Cluster] object. + /// Used by `cluster#onTap` method call handler and inspectors [getClusters] response parser. + static Cluster parseCluster( + String clusterManagerIdString, + Object positionObject, + Map boundsMap, + List markerIdsList) { + final ClusterManagerId clusterManagerId = + ClusterManagerId(clusterManagerIdString); + final LatLng position = LatLng.fromJson(positionObject)!; + + final Map> latLngData = boundsMap.map( + (dynamic key, dynamic object) => MapEntry>( + key as String, object as List)); + + final LatLngBounds bounds = LatLngBounds( + northeast: LatLng.fromJson(latLngData['northeast'])!, + southwest: LatLng.fromJson(latLngData['southwest'])!); + + final List markerIds = markerIdsList + .map((dynamic markerId) => MarkerId(markerId as String)) + .toList(); + + return Cluster(clusterManagerId, position, bounds, markerIds); + } } Map _jsonForMapConfiguration(MapConfiguration config) { diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/utils/cluster_manager.dart b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/utils/cluster_manager.dart new file mode 100644 index 000000000000..854690206a29 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/utils/cluster_manager.dart @@ -0,0 +1,34 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +/// Converts a Set of Cluster Managers into object serializable in JSON. +Object serializeClusterManagerSet(Set clusterManagers) { + return clusterManagers + .map((ClusterManager cm) => serializeClusterManager(cm)) + .toList(); +} + +/// Converts a Cluster Manager into object serializable in JSON. +Object serializeClusterManager(ClusterManager clusterManager) { + final Map json = {}; + json['clusterManagerId'] = clusterManager.clusterManagerId.value; + return json; +} + +/// Converts a Cluster Manager updates into object serializable in JSON. +Object serializeClusterManagerUpdates( + ClusterManagerUpdates clusterManagerUpdates) { + final Map updateMap = {}; + + updateMap['clusterManagersToAdd'] = + serializeClusterManagerSet(clusterManagerUpdates.objectsToAdd); + updateMap['clusterManagerIdsToRemove'] = clusterManagerUpdates + .objectIdsToRemove + .map((MapsObjectId id) => id.value) + .toList(); + + return updateMap; +} diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml index c4f8d23cb382..f36c6fb7430b 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml @@ -27,3 +27,9 @@ dev_dependencies: flutter_test: sdk: flutter plugin_platform_interface: ^2.0.0 + + +# FOR TESTING ONLY. DO NOT MERGE. +dependency_overrides: + google_maps_flutter_platform_interface: + path: ../../google_maps_flutter/google_maps_flutter_platform_interface diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart index 5961406c155c..ec06899b52c0 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart @@ -170,3 +170,12 @@ class MapLongPressEvent extends _PositionedMapEvent { /// The `position` of this event is the LatLng where the Map was long pressed. MapLongPressEvent(int mapId, LatLng position) : super(mapId, position, null); } + +/// An event fired when a cluster icon managed by [ClusterManager] is tapped. +class ClusterTapEvent extends MapEvent { + /// Build a ClusterTapEvent Event triggered from the map represented by `mapId`. + /// + /// The `value` of this event is a [Cluster] object that represents the tapped + /// cluster icon managed by [ClusterManager]. + ClusterTapEvent(int mapId, Cluster cluster) : super(mapId, cluster); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart index 3fd860e126eb..a919454eaaaa 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart @@ -169,6 +169,11 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { return _events(mapId).whereType(); } + @override + Stream onClusterTap({required int mapId}) { + return _events(mapId).whereType(); + } + Future _handleMethodCall(MethodCall call, int mapId) async { switch (call.method) { case 'camera#onMoveStarted': @@ -274,6 +279,32 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { arguments['zoom'] as int?, ); return tile.toJson(); + case 'cluster#onTap': + final Map arguments = _getArgumentDictionary(call); + final ClusterManagerId clusterManagerId = + ClusterManagerId(arguments['clusterManagerId']! as String); + final LatLng position = LatLng.fromJson(arguments['position'])!; + + final Map> latLngData = + (arguments['bounds']! as Map).map( + (dynamic key, dynamic object) => + MapEntry>( + key as String, object as List)); + + final LatLngBounds bounds = LatLngBounds( + northeast: LatLng.fromJson(latLngData['northeast'])!, + southwest: LatLng.fromJson(latLngData['southwest'])!); + + final List markerIds = + (arguments['markerIds']! as List) + .map((dynamic markerId) => MarkerId(markerId as String)) + .toList(); + + _mapEventStreamController.add(ClusterTapEvent( + mapId, + Cluster(clusterManagerId, position, bounds, markerIds), + )); + break; default: throw MissingPluginException(); } @@ -368,6 +399,18 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { ); } + @override + Future updateClusterManagers( + ClusterManagerUpdates clusterManagerUpdates, { + required int mapId, + }) { + assert(clusterManagerUpdates != null); + return channel(mapId).invokeMethod( + 'clusterManagers#update', + clusterManagerUpdates.toJson(), + ); + } + @override Future clearTileCache( TileOverlayId tileOverlayId, { @@ -609,6 +652,7 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { Set polylines = const {}, Set circles = const {}, Set tileOverlays = const {}, + Set clusterManagers = const {}, Set>? gestureRecognizers, Map mapOptions = const {}, }) { @@ -623,6 +667,7 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { polygons: polygons, polylines: polylines, circles: circles, + clusterManagers: clusterManagers, tileOverlays: tileOverlays), mapOptions: mapOptions, ); @@ -638,6 +683,7 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { Set polylines = const {}, Set circles = const {}, Set tileOverlays = const {}, + Set clusterManagers = const {}, Set>? gestureRecognizers, Map mapOptions = const {}, }) { @@ -651,6 +697,7 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { polylines: polylines, circles: circles, tileOverlays: tileOverlays, + clusterManagers: clusterManagers, gestureRecognizers: gestureRecognizers, mapOptions: mapOptions, ); diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart index 147d64f715b7..9e2198bec42a 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart @@ -145,6 +145,20 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { throw UnimplementedError('updateTileOverlays() has not been implemented.'); } + /// Updates cluster manager configuration. + /// + /// Change listeners are notified once the update has been made on the + /// platform side. + /// + /// The returned [Future] completes after listeners have been notified. + Future updateClusterManagers( + ClusterManagerUpdates clusterManagerUpdates, { + required int mapId, + }) { + throw UnimplementedError( + 'updateClusterManagers() has not been implemented.'); + } + /// Clears the tile cache so that all tiles will be requested again from the /// [TileProvider]. /// @@ -360,6 +374,11 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { throw UnimplementedError('onLongPress() has not been implemented.'); } + /// A marker icon managed by [ClusterManager] has been tapped. + Stream onClusterTap({required int mapId}) { + throw UnimplementedError('onClusterTap() has not been implemented.'); + } + /// Dispose of whatever resources the `mapId` is holding on to. void dispose({required int mapId}) { throw UnimplementedError('dispose() has not been implemented.'); @@ -376,6 +395,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { Set polylines = const {}, Set circles = const {}, Set tileOverlays = const {}, + Set clusterManagers = const {}, Set>? gestureRecognizers = const >{}, // TODO(stuartmorgan): Replace with a structured type that's part of the @@ -406,6 +426,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { Set polylines = const {}, Set circles = const {}, Set tileOverlays = const {}, + Set clusterManagers = const {}, Map mapOptions = const {}, }) { return buildView( @@ -417,6 +438,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { polylines: polylines, circles: circles, tileOverlays: tileOverlays, + clusterManagers: clusterManagers, gestureRecognizers: gestureRecognizers, mapOptions: mapOptions, ); @@ -440,6 +462,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { polylines: mapObjects.polylines, circles: mapObjects.circles, tileOverlays: mapObjects.tileOverlays, + clusterManagers: mapObjects.clusterManagers, gestureRecognizers: widgetConfiguration.gestureRecognizers, mapOptions: jsonForMapConfiguration(mapConfiguration), ); diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_inspector_platform.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_inspector_platform.dart index 1e07b97c300d..461d38a61616 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_inspector_platform.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_inspector_platform.dart @@ -115,4 +115,10 @@ abstract class GoogleMapsInspectorPlatform extends PlatformInterface { {required int mapId}) { throw UnimplementedError('getTileOverlayInfo() has not been implemented.'); } + + /// Returns current clusters from [ClusterManager]. + Future> getClusters( + {required int mapId, required ClusterManagerId clusterManagerId}) { + throw UnimplementedError('getClusters() has not been implemented.'); + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/cluster.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/cluster.dart new file mode 100644 index 000000000000..491f8e1635ac --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/cluster.dart @@ -0,0 +1,51 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart' + show immutable, listEquals, objectRuntimeType; +import 'types.dart'; + +/// A cluster containing multiple markers. +@immutable +class Cluster { + /// Creates a cluster with its location [LatLng], bounds [LatLngBounds], + /// and list of [MarkerId]s inside the cluster. + const Cluster( + this.clusterManagerId, this.position, this.bounds, this.markerIds) + : assert(position != null), + assert(bounds != null), + assert(markerIds.length > 0); + + /// ID of the [ClusterManager] of the cluster. + final ClusterManagerId clusterManagerId; + + /// Cluster marker location. + final LatLng position; + + /// The bounds containing all cluster markers. + final LatLngBounds bounds; + + /// List of [MarkerId]s inside the cluster. + final List markerIds; + + /// Returns the amount of markers in cluster. + int get count => markerIds.length; + + @override + String toString() => + '${objectRuntimeType(this, 'Cluster')}($clusterManagerId, $position, $bounds, $markerIds)'; + + @override + bool operator ==(Object other) { + return other is Cluster && + other.clusterManagerId == clusterManagerId && + other.position == position && + other.bounds == bounds && + listEquals(other.markerIds, markerIds); + } + + @override + int get hashCode => + Object.hash(clusterManagerId, position, bounds, markerIds); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/cluster_manager.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/cluster_manager.dart new file mode 100644 index 000000000000..a8189d2f1bd3 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/cluster_manager.dart @@ -0,0 +1,85 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart' show immutable; +import 'types.dart'; + +/// Uniquely identifies a [ClusterManager] among [GoogleMap] clusters. +/// +/// This does not have to be globally unique, only unique among the list. +@immutable +class ClusterManagerId extends MapsObjectId { + /// Creates an immutable identifier for a [ClusterManager]. + const ClusterManagerId(String value) : super(value); +} + +/// [ClusterManager] manages marker clustering for set of [Marker]s that have +/// the same [ClusterManagerId] set. +@immutable +class ClusterManager implements MapsObject { + /// Creates an immutable object for managing clustering for set of markers. + const ClusterManager({ + required this.clusterManagerId, + this.onClusterTap, + }); + + /// Uniquely identifies a [ClusterManager]. + final ClusterManagerId clusterManagerId; + + @override + ClusterManagerId get mapsId => clusterManagerId; + + /// Callback to receive tap events for cluster markers placed on this map. + final ArgumentCallback? onClusterTap; + + /// Creates a new [ClusterManager] object whose values are the same as this instance, + /// unless overwritten by the specified parameters. + ClusterManager copyWith({ + ArgumentCallback? onClusterTapParam, + }) { + return ClusterManager( + clusterManagerId: clusterManagerId, + onClusterTap: onClusterTapParam ?? onClusterTap, + ); + } + + /// Creates a new [ClusterManager] object whose values are the same as this instance. + @override + ClusterManager clone() => copyWith(); + + /// Converts this object to something serializable in JSON. + @override + Object toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, Object? value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('clusterManagerId', clusterManagerId.value); + return json; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is ClusterManager && + clusterManagerId == other.clusterManagerId; + } + + @override + int get hashCode => clusterManagerId.hashCode; + + @override + String toString() { + return 'Cluster{clusterManagerId: $clusterManagerId, onClusterTap: $onClusterTap}'; + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/cluster_manager_updates.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/cluster_manager_updates.dart new file mode 100644 index 000000000000..e2700f300978 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/cluster_manager_updates.dart @@ -0,0 +1,26 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'types.dart'; + +/// [ClusterManager] update events to be applied to the [GoogleMap]. +/// +/// Used in [GoogleMapController] when the map is updated. +// (Do not re-export) +class ClusterManagerUpdates extends MapsObjectUpdates { + /// Computes [ClusterManagerUpdates] given previous and current [ClusterManager]s. + ClusterManagerUpdates.from( + Set previous, Set current) + : super.from(previous, current, objectName: 'clusterManager'); + + /// Set of Clusters to be added in this update. + Set get clusterManagersToAdd => objectsToAdd; + + /// Set of ClusterManagerIds to be removed in this update. + Set get clusterManagerIdsToRemove => + objectIdsToRemove.cast(); + + /// Set of Clusters to be changed in this update. + Set get clusterManagersToChange => objectsToChange; +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/map_objects.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/map_objects.dart index 56f80e8312dd..009a6a078268 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/map_objects.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/map_objects.dart @@ -21,6 +21,7 @@ class MapObjects { this.polylines = const {}, this.circles = const {}, this.tileOverlays = const {}, + this.clusterManagers = const {}, }); final Set markers; @@ -28,4 +29,5 @@ class MapObjects { final Set polylines; final Set circles; final Set tileOverlays; + final Set clusterManagers; } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker.dart index 914e77a64c9f..325b8b639511 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker.dart @@ -151,6 +151,7 @@ class Marker implements MapsObject { this.rotation = 0.0, this.visible = true, this.zIndex = 0.0, + this.clusterManagerId, this.onTap, this.onDrag, this.onDragStart, @@ -163,6 +164,9 @@ class Marker implements MapsObject { @override MarkerId get mapsId => markerId; + /// Marker clustering is managed by [ClusterManager] with [clusterManagerId]. + final ClusterManagerId? clusterManagerId; + /// The opacity of the marker, between 0.0 and 1.0 inclusive. /// /// 0.0 means fully transparent, 1.0 means fully opaque. @@ -241,6 +245,7 @@ class Marker implements MapsObject { ValueChanged? onDragStartParam, ValueChanged? onDragParam, ValueChanged? onDragEndParam, + ClusterManagerId? clusterManagerIdParam, }) { return Marker( markerId: markerId, @@ -259,6 +264,7 @@ class Marker implements MapsObject { onDragStart: onDragStartParam ?? onDragStart, onDrag: onDragParam ?? onDrag, onDragEnd: onDragEndParam ?? onDragEnd, + clusterManagerId: clusterManagerIdParam ?? clusterManagerId, ); } @@ -289,6 +295,7 @@ class Marker implements MapsObject { addIfPresent('rotation', rotation); addIfPresent('visible', visible); addIfPresent('zIndex', zIndex); + addIfPresent('clusterManagerId', clusterManagerId?.value); return json; } @@ -312,7 +319,8 @@ class Marker implements MapsObject { position == other.position && rotation == other.rotation && visible == other.visible && - zIndex == other.zIndex; + zIndex == other.zIndex && + clusterManagerId == other.clusterManagerId; } @override @@ -324,6 +332,6 @@ class Marker implements MapsObject { 'consumeTapEvents: $consumeTapEvents, draggable: $draggable, flat: $flat, ' 'icon: $icon, infoWindow: $infoWindow, position: $position, rotation: $rotation, ' 'visible: $visible, zIndex: $zIndex, onTap: $onTap, onDragStart: $onDragStart, ' - 'onDrag: $onDrag, onDragEnd: $onDragEnd}'; + 'onDrag: $onDrag, onDragEnd: $onDragEnd, clusterManagerId: $clusterManagerId}'; } } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/types.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/types.dart index 0beb7d747ec8..9649ae2f4bf8 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/types.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/types.dart @@ -9,6 +9,9 @@ export 'camera.dart'; export 'cap.dart'; export 'circle.dart'; export 'circle_updates.dart'; +export 'cluster.dart'; +export 'cluster_manager.dart'; +export 'cluster_manager_updates.dart'; export 'joint_type.dart'; export 'location.dart'; export 'map_configuration.dart'; @@ -30,6 +33,7 @@ export 'tile_provider.dart'; export 'ui.dart'; // Export the utils used by the Widget export 'utils/circle.dart'; +export 'utils/cluster_manager.dart'; export 'utils/marker.dart'; export 'utils/polygon.dart'; export 'utils/polyline.dart'; diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/cluster_manager.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/cluster_manager.dart new file mode 100644 index 000000000000..c44578c04d04 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/cluster_manager.dart @@ -0,0 +1,13 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../types.dart'; +import 'maps_object.dart'; + +/// Converts an [Iterable] of Cluster Managers in a Map of ClusterManagerId -> Cluster. +Map keyByClusterManagerId( + Iterable clusterManagers) { + return keyByMapsObjectId(clusterManagers) + .cast(); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart index d1dba2b75b55..969c9de605e8 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart @@ -106,6 +106,7 @@ class BuildViewGoogleMapsFlutterPlatform extends GoogleMapsFlutterPlatform { Set polylines = const {}, Set circles = const {}, Set tileOverlays = const {}, + Set clusterManagers = const {}, Set>? gestureRecognizers = const >{}, Map mapOptions = const {}, diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/marker_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/marker_test.dart index db7afcbb0398..76e5de2b1866 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/marker_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/marker_test.dart @@ -96,7 +96,8 @@ void main() { expect(clone, equals(marker)); }); test('copyWith', () { - const Marker marker = Marker(markerId: MarkerId('ABC123')); + const MarkerId markerId = MarkerId('ABC123'); + const Marker marker = Marker(markerId: markerId); final BitmapDescriptor testDescriptor = BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan); @@ -111,6 +112,8 @@ void main() { const double testRotationParam = 100; final bool testVisibleParam = !marker.visible; const double testZIndexParam = 100; + const ClusterManagerId testClusterManagerIdParam = + ClusterManagerId('DEF123'); final List log = []; final Marker copy = marker.copyWith( @@ -125,6 +128,7 @@ void main() { rotationParam: testRotationParam, visibleParam: testVisibleParam, zIndexParam: testZIndexParam, + clusterManagerIdParam: testClusterManagerIdParam, onTapParam: () { log.add('onTapParam'); }, @@ -139,6 +143,7 @@ void main() { }, ); + expect(copy.markerId, equals(markerId)); expect(copy.alpha, equals(testAlphaParam)); expect(copy.anchor, equals(testAnchorParam)); expect(copy.consumeTapEvents, equals(testConsumeTapEventsParam)); @@ -150,6 +155,7 @@ void main() { expect(copy.rotation, equals(testRotationParam)); expect(copy.visible, equals(testVisibleParam)); expect(copy.zIndex, equals(testZIndexParam)); + expect(copy.clusterManagerId, equals(testClusterManagerIdParam)); copy.onTap!(); expect(log, contains('onTapParam')); diff --git a/packages/google_maps_flutter/google_maps_flutter_web/README.md b/packages/google_maps_flutter/google_maps_flutter_web/README.md index 692814731bec..9da22290f74b 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/README.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/README.md @@ -26,6 +26,20 @@ Modify the `` tag of your `web/index.html` to load the Google Maps JavaScr Now you should be able to use the Google Maps plugin normally. +## Clustering support + +Modify the tag of your web/index.html to load the [js-markerclusterer](https://github.com/googlemaps/js-markerclusterer#install) library, like so: + + +```html + + + + + + +``` + ## Limitations of the web version The following map options are not available in web, because the map doesn't rotate there: diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart index a85bce31e20f..98d321d37c0a 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart @@ -101,6 +101,7 @@ class MockGoogleMapController extends _i1.Mock _i4.CirclesController? circles, _i4.PolygonsController? polygons, _i4.PolylinesController? polylines, + _i4.ClusterManagersController? clusterManagers, }) => super.noSuchMethod( Invocation.method( @@ -112,6 +113,7 @@ class MockGoogleMapController extends _i1.Mock #circles: circles, #polygons: polygons, #polylines: polylines, + #clusterManagers: clusterManagers, }, ), returnValueForMissingStub: null, @@ -261,6 +263,15 @@ class MockGoogleMapController extends _i1.Mock returnValueForMissingStub: null, ); @override + void updateClusterManagers(_i3.ClusterManagerUpdates? updates) => + super.noSuchMethod( + Invocation.method( + #updateClusterManagers, + [updates], + ), + returnValueForMissingStub: null, + ); + @override void showInfoWindow(_i3.MarkerId? markerId) => super.noSuchMethod( Invocation.method( #showInfoWindow, diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/markers_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/markers_test.dart index e4c4dd7c0cfe..d53ecd654be8 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/markers_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/markers_test.dart @@ -18,18 +18,48 @@ import 'package:integration_test/integration_test.dart'; import 'resources/icon_image_base64.dart'; void main() { + const LatLng mapCenter = LatLng(65.011890, 25.468021); IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + // Repeatedly checks an asynchronous value against a test condition, waiting + // one frame between each check, returing the value if it passes the predicate + // before [maxTries] is reached. + // + // Returns null if the predicate is never satisfied. + // + // This is useful for cases where the Maps SDK has some internally + // asynchronous operation that we don't have visibility into (e.g., native UI + // animations). + Future waitForValueMatchingPredicate(WidgetTester tester, + Future Function() getValue, bool Function(T) predicate, + {int maxTries = 100}) async { + for (int i = 0; i < maxTries; i++) { + final T value = await getValue(); + if (predicate(value)) { + return value; + } + await tester.pump(); + } + return null; + } + group('MarkersController', () { late StreamController> events; - late MarkersController controller; + late MarkersController markersController; + late ClusterManagersController clusterManagersController; late gmaps.GMap map; setUp(() { events = StreamController>(); - controller = MarkersController(stream: events); - map = gmaps.GMap(html.DivElement()); - controller.bindToMap(123, map); + clusterManagersController = ClusterManagersController(stream: events); + markersController = MarkersController( + stream: events, clusterManagersController: clusterManagersController); + final gmaps.MapOptions options = gmaps.MapOptions(); + options.zoom = 4; + options.center = gmaps.LatLng(mapCenter.latitude, mapCenter.longitude); + map = gmaps.GMap(html.DivElement(), options); + clusterManagersController.bindToMap(123, map); + markersController.bindToMap(123, map); }); testWidgets('addMarkers', (WidgetTester tester) async { @@ -38,32 +68,32 @@ void main() { const Marker(markerId: MarkerId('2')), }; - controller.addMarkers(markers); + markersController.addMarkers(markers); - expect(controller.markers.length, 2); - expect(controller.markers, contains(const MarkerId('1'))); - expect(controller.markers, contains(const MarkerId('2'))); - expect(controller.markers, isNot(contains(const MarkerId('66')))); + expect(markersController.markers.length, 2); + expect(markersController.markers, contains(const MarkerId('1'))); + expect(markersController.markers, contains(const MarkerId('2'))); + expect(markersController.markers, isNot(contains(const MarkerId('66')))); }); testWidgets('changeMarkers', (WidgetTester tester) async { final Set markers = { const Marker(markerId: MarkerId('1')), }; - controller.addMarkers(markers); + markersController.addMarkers(markers); - expect( - controller.markers[const MarkerId('1')]?.marker?.draggable, isFalse); + expect(markersController.markers[const MarkerId('1')]?.marker?.draggable, + isFalse); // Update the marker with radius 10 final Set updatedMarkers = { const Marker(markerId: MarkerId('1'), draggable: true), }; - controller.changeMarkers(updatedMarkers); + markersController.changeMarkers(updatedMarkers); - expect(controller.markers.length, 1); - expect( - controller.markers[const MarkerId('1')]?.marker?.draggable, isTrue); + expect(markersController.markers.length, 1); + expect(markersController.markers[const MarkerId('1')]?.marker?.draggable, + isTrue); }); testWidgets('removeMarkers', (WidgetTester tester) async { @@ -73,9 +103,9 @@ void main() { const Marker(markerId: MarkerId('3')), }; - controller.addMarkers(markers); + markersController.addMarkers(markers); - expect(controller.markers.length, 3); + expect(markersController.markers.length, 3); // Remove some markers... final Set markerIdsToRemove = { @@ -83,12 +113,12 @@ void main() { const MarkerId('3'), }; - controller.removeMarkers(markerIdsToRemove); + markersController.removeMarkers(markerIdsToRemove); - expect(controller.markers.length, 1); - expect(controller.markers, isNot(contains(const MarkerId('1')))); - expect(controller.markers, contains(const MarkerId('2'))); - expect(controller.markers, isNot(contains(const MarkerId('3')))); + expect(markersController.markers.length, 1); + expect(markersController.markers, isNot(contains(const MarkerId('1')))); + expect(markersController.markers, contains(const MarkerId('2'))); + expect(markersController.markers, isNot(contains(const MarkerId('3')))); }); testWidgets('InfoWindow show/hide', (WidgetTester tester) async { @@ -99,17 +129,20 @@ void main() { ), }; - controller.addMarkers(markers); + markersController.addMarkers(markers); - expect(controller.markers[const MarkerId('1')]?.infoWindowShown, isFalse); + expect(markersController.markers[const MarkerId('1')]?.infoWindowShown, + isFalse); - controller.showMarkerInfoWindow(const MarkerId('1')); + markersController.showMarkerInfoWindow(const MarkerId('1')); - expect(controller.markers[const MarkerId('1')]?.infoWindowShown, isTrue); + expect(markersController.markers[const MarkerId('1')]?.infoWindowShown, + isTrue); - controller.hideMarkerInfoWindow(const MarkerId('1')); + markersController.hideMarkerInfoWindow(const MarkerId('1')); - expect(controller.markers[const MarkerId('1')]?.infoWindowShown, isFalse); + expect(markersController.markers[const MarkerId('1')]?.infoWindowShown, + isFalse); }); // https://github.com/flutter/flutter/issues/67380 @@ -125,20 +158,26 @@ void main() { infoWindow: InfoWindow(title: 'Title', snippet: 'Snippet'), ), }; - controller.addMarkers(markers); + markersController.addMarkers(markers); - expect(controller.markers[const MarkerId('1')]?.infoWindowShown, isFalse); - expect(controller.markers[const MarkerId('2')]?.infoWindowShown, isFalse); + expect(markersController.markers[const MarkerId('1')]?.infoWindowShown, + isFalse); + expect(markersController.markers[const MarkerId('2')]?.infoWindowShown, + isFalse); - controller.showMarkerInfoWindow(const MarkerId('1')); + markersController.showMarkerInfoWindow(const MarkerId('1')); - expect(controller.markers[const MarkerId('1')]?.infoWindowShown, isTrue); - expect(controller.markers[const MarkerId('2')]?.infoWindowShown, isFalse); + expect(markersController.markers[const MarkerId('1')]?.infoWindowShown, + isTrue); + expect(markersController.markers[const MarkerId('2')]?.infoWindowShown, + isFalse); - controller.showMarkerInfoWindow(const MarkerId('2')); + markersController.showMarkerInfoWindow(const MarkerId('2')); - expect(controller.markers[const MarkerId('1')]?.infoWindowShown, isFalse); - expect(controller.markers[const MarkerId('2')]?.infoWindowShown, isTrue); + expect(markersController.markers[const MarkerId('1')]?.infoWindowShown, + isFalse); + expect(markersController.markers[const MarkerId('2')]?.infoWindowShown, + isTrue); }); // https://github.com/flutter/flutter/issues/66622 @@ -152,11 +191,11 @@ void main() { ), }; - controller.addMarkers(markers); + markersController.addMarkers(markers); - expect(controller.markers.length, 1); - final gmaps.Icon? icon = - controller.markers[const MarkerId('1')]?.marker?.icon as gmaps.Icon?; + expect(markersController.markers.length, 1); + final gmaps.Icon? icon = markersController + .markers[const MarkerId('1')]?.marker?.icon as gmaps.Icon?; expect(icon, isNotNull); final String blobUrl = icon!.url!; @@ -179,11 +218,11 @@ void main() { ), }; - controller.addMarkers(markers); + markersController.addMarkers(markers); - expect(controller.markers.length, 1); - final gmaps.Icon? icon = - controller.markers[const MarkerId('1')]?.marker?.icon as gmaps.Icon?; + expect(markersController.markers.length, 1); + final gmaps.Icon? icon = markersController + .markers[const MarkerId('1')]?.marker?.icon as gmaps.Icon?; expect(icon, isNotNull); final gmaps.Size size = icon!.size!; @@ -208,11 +247,13 @@ void main() { ), }; - controller.addMarkers(markers); + markersController.addMarkers(markers); - expect(controller.markers.length, 1); - final html.HtmlElement? content = controller.markers[const MarkerId('1')] - ?.infoWindow?.content as html.HtmlElement?; + expect(markersController.markers.length, 1); + final html.HtmlElement? content = markersController + .markers[const MarkerId('1')] + ?.infoWindow + ?.content as html.HtmlElement?; expect(content?.innerHtml, contains('title for test')); expect( content?.innerHtml, @@ -233,11 +274,13 @@ void main() { ), }; - controller.addMarkers(markers); + markersController.addMarkers(markers); - expect(controller.markers.length, 1); - final html.HtmlElement? content = controller.markers[const MarkerId('1')] - ?.infoWindow?.content as html.HtmlElement?; + expect(markersController.markers.length, 1); + final html.HtmlElement? content = markersController + .markers[const MarkerId('1')] + ?.infoWindow + ?.content as html.HtmlElement?; content?.click(); @@ -246,5 +289,67 @@ void main() { expect(event, isA()); expect((event as InfoWindowTapEvent).value, equals(const MarkerId('1'))); }); + + testWidgets('clustering', (WidgetTester tester) async { + const ClusterManagerId clusterManagerId = ClusterManagerId('cluster 1'); + + final Set clusterManagers = { + const ClusterManager(clusterManagerId: clusterManagerId), + }; + + // Create the marker with clusterManagerId. + final Set markers = { + const Marker( + markerId: MarkerId('1'), + position: mapCenter, + clusterManagerId: clusterManagerId), + }; + + clusterManagersController.addClusterManagers(clusterManagers); + markersController.addMarkers(markers); + + final List clusters = + await waitForValueMatchingPredicate>( + tester, + () async => + clusterManagersController.getClusters(clusterManagerId), + (List clusters) => clusters.isNotEmpty) ?? + []; + + expect(clusters.length, 1); + + // Update the marker with null clusterManagerId. + final Set updatedMarkers = { + _copyMarkerWithClusterManagerId(markers.first, null) + }; + markersController.changeMarkers(updatedMarkers); + + expect(markersController.markers.length, 1); + + expect(clusterManagersController.getClusters(clusterManagerId).length, 0); + }); }); } + +Marker _copyMarkerWithClusterManagerId( + Marker marker, ClusterManagerId? clusterManagerId) { + return Marker( + markerId: marker.markerId, + alpha: marker.alpha, + anchor: marker.anchor, + consumeTapEvents: marker.consumeTapEvents, + draggable: marker.draggable, + flat: marker.flat, + icon: marker.icon, + infoWindow: marker.infoWindow, + position: marker.position, + rotation: marker.rotation, + visible: marker.visible, + zIndex: marker.zIndex, + onTap: marker.onTap, + onDragStart: marker.onDragStart, + onDrag: marker.onDrag, + onDragEnd: marker.onDragEnd, + clusterManagerId: clusterManagerId, + ); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml index 43f67946464a..749be1c5822d 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml @@ -26,3 +26,12 @@ dev_dependencies: integration_test: sdk: flutter mockito: ^5.3.2 + +# FOR TESTING ONLY. DO NOT MERGE. +dependency_overrides: + google_maps_flutter_android: + path: ../../../google_maps_flutter/google_maps_flutter_android + google_maps_flutter_platform_interface: + path: ../../../google_maps_flutter/google_maps_flutter_platform_interface + google_maps_flutter_web: + path: ../../../google_maps_flutter/google_maps_flutter_web diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html b/packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html index 3121d189b913..7ae910de7bd4 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html @@ -7,6 +7,7 @@ Browser Tests + diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart index 0650184a14d0..e9a5b3d72010 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart @@ -16,6 +16,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:google_maps/google_maps.dart' as gmaps; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; +import 'package:js/js.dart'; import 'package:sanitize_html/sanitize_html.dart'; import 'package:stream_transform/stream_transform.dart'; @@ -29,6 +30,7 @@ part 'src/convert.dart'; part 'src/google_maps_controller.dart'; part 'src/google_maps_flutter_web.dart'; part 'src/marker.dart'; +part 'src/marker_clustering.dart'; part 'src/markers.dart'; part 'src/polygon.dart'; part 'src/polygons.dart'; diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart index a659fb218803..09cb8b0842ff 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart @@ -25,11 +25,16 @@ class GoogleMapController { _polygons = mapObjects.polygons, _polylines = mapObjects.polylines, _circles = mapObjects.circles, + _clusterManagers = mapObjects.clusterManagers, _lastMapConfiguration = mapConfiguration { _circlesController = CirclesController(stream: _streamController); _polygonsController = PolygonsController(stream: _streamController); _polylinesController = PolylinesController(stream: _streamController); - _markersController = MarkersController(stream: _streamController); + _clusterManagersController = + ClusterManagersController(stream: _streamController); + _markersController = MarkersController( + stream: _streamController, + clusterManagersController: _clusterManagersController!); // Register the view factory that will hold the `_div` that holds the map in the DOM. // The `_div` needs to be created outside of the ViewFactory (and cached!) so we can @@ -53,6 +58,7 @@ class GoogleMapController { final Set _polygons; final Set _polylines; final Set _circles; + final Set _clusterManagers; // The configuraiton passed by the user, before converting to gmaps. // Caching this allows us to re-create the map faithfully when needed. MapConfiguration _lastMapConfiguration = const MapConfiguration(); @@ -100,6 +106,7 @@ class GoogleMapController { PolygonsController? _polygonsController; PolylinesController? _polylinesController; MarkersController? _markersController; + ClusterManagersController? _clusterManagersController; // Keeps track if _attachGeometryControllers has been called or not. bool _controllersBoundToMap = false; @@ -114,12 +121,14 @@ class GoogleMapController { CirclesController? circles, PolygonsController? polygons, PolylinesController? polylines, + ClusterManagersController? clusterManagers, }) { _overrideCreateMap = createMap; _markersController = markers ?? _markersController; _circlesController = circles ?? _circlesController; _polygonsController = polygons ?? _polygonsController; _polylinesController = polylines ?? _polylinesController; + _clusterManagersController = clusterManagers ?? _clusterManagersController; } DebugCreateMapFunction? _overrideCreateMap; @@ -168,6 +177,8 @@ class GoogleMapController { _attachMapEvents(map); _attachGeometryControllers(map); + _initClustering(_clusterManagers); + // Now attach the geometry, traffic and any other layers... _renderInitialGeometry( markers: _markers, @@ -228,15 +239,22 @@ class GoogleMapController { 'Cannot attach a map to a null PolylinesController instance.'); assert(_markersController != null, 'Cannot attach a map to a null MarkersController instance.'); + assert(_clusterManagersController != null, + 'Cannot attach a map to a null ClusterManagersController instance.'); _circlesController!.bindToMap(_mapId, map); _polygonsController!.bindToMap(_mapId, map); _polylinesController!.bindToMap(_mapId, map); _markersController!.bindToMap(_mapId, map); + _clusterManagersController!.bindToMap(_mapId, map); _controllersBoundToMap = true; } + void _initClustering(Set clusterManagers) { + _clusterManagersController!.addClusterManagers(clusterManagers); + } + // Renders the initial sets of geometry. void _renderInitialGeometry({ Set markers = const {}, @@ -394,6 +412,16 @@ class GoogleMapController { _markersController?.removeMarkers(updates.markerIdsToRemove); } + /// Applies [ClusterManagerUpdates] to the currently managed cluster managers. + void updateClusterManagers(ClusterManagerUpdates updates) { + assert(_clusterManagersController != null, + 'Cannot update markers after dispose().'); + _clusterManagersController + ?.addClusterManagers(updates.clusterManagersToAdd); + _clusterManagersController + ?.removeClusterManagers(updates.clusterManagerIdsToRemove); + } + /// Shows the [InfoWindow] of the marker identified by its [MarkerId]. void showInfoWindow(MarkerId markerId) { assert(_markersController != null, @@ -426,6 +454,7 @@ class GoogleMapController { _polygonsController = null; _polylinesController = null; _markersController = null; + _clusterManagersController = null; _streamController.close(); } } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart index c2085a2bddfc..55b66e68a817 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart @@ -98,6 +98,15 @@ class GoogleMapsPlugin extends GoogleMapsFlutterPlatform { return; // Noop for now! } + @override + Future updateClusterManagers( + ClusterManagerUpdates clusterManagerUpdates, { + required int mapId, + }) async { + assert(clusterManagerUpdates != null); + _map(mapId).updateClusterManagers(clusterManagerUpdates); + } + @override Future clearTileCache( TileOverlayId tileOverlayId, { @@ -279,6 +288,11 @@ class GoogleMapsPlugin extends GoogleMapsFlutterPlatform { return _events(mapId).whereType(); } + @override + Stream onClusterTap({required int mapId}) { + return _events(mapId).whereType(); + } + /// Disposes of the current map. It can't be used afterwards! @override void dispose({required int mapId}) { 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 9d607e9bbc6a..d80206616ccd 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 @@ -15,9 +15,11 @@ class MarkerController { LatLngCallback? onDrag, LatLngCallback? onDragEnd, ui.VoidCallback? onTap, + ClusterManagerId? clusterManagerId, }) : _marker = marker, _infoWindow = infoWindow, - _consumeTapEvents = consumeTapEvents { + _consumeTapEvents = consumeTapEvents, + _clusterManagerId = clusterManagerId { if (onTap != null) { marker.onClick.listen((gmaps.MapMouseEvent event) { onTap.call(); @@ -53,6 +55,8 @@ class MarkerController { final bool _consumeTapEvents; + final ClusterManagerId? _clusterManagerId; + final gmaps.InfoWindow? _infoWindow; bool _infoWindowShown = false; @@ -63,6 +67,9 @@ class MarkerController { /// Returns `true` if the [gmaps.InfoWindow] associated to this marker is being shown. bool get infoWindowShown => _infoWindowShown; + /// Returns [ClusterManagerId] if marker belongs to cluster. + ClusterManagerId? get clusterManagerId => _clusterManagerId; + /// Returns the [gmaps.Marker] associated to this controller. gmaps.Marker? get marker => _marker; 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 new file mode 100644 index 000000000000..29173353faaf --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering.dart @@ -0,0 +1,189 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: public_member_api_docs, non_constant_identifier_names +part of google_maps_flutter_web; + +typedef ClusterClickHandler = void Function( + gmaps.MapMouseEvent, MarkerClustererCluster, gmaps.GMap); + +class ClusterManagersController extends GeometryController { + ClusterManagersController( + {required StreamController> stream}) + : _streamController = stream, + _clusterManagerIdToMarkerClusterer = + {}; + + // The stream over which cluster managers broadcast their events + final StreamController> _streamController; + + // A cache of [MarkerClusterer]s indexed by their [ClusterManagerId]. + final Map + _clusterManagerIdToMarkerClusterer; + + /// Adds a set of [ClusterManager] objects to the cache. + void addClusterManagers(Set clusterManagersToAdd) { + clusterManagersToAdd.forEach(_addClusterManager); + } + + void _addClusterManager(ClusterManager clusterManager) { + if (clusterManager == null) { + return; + } + + final MarkerClusterer markerClusterer = createMarkerClusterer( + googleMap, + (gmaps.MapMouseEvent event, MarkerClustererCluster cluster, + gmaps.GMap map) => + _clusterClicked( + clusterManager.clusterManagerId, event, cluster, map)); + + _clusterManagerIdToMarkerClusterer[clusterManager.clusterManagerId] = + markerClusterer; + markerClusterer.onAdd(); + } + + /// Removes a set of [ClusterManagerId]s from the cache. + void removeClusterManagers(Set clusterManagerIdsToRemove) { + clusterManagerIdsToRemove.forEach(_removeClusterManager); + } + + void _removeClusterManager(ClusterManagerId clusterManagerId) { + final MarkerClusterer? markerClusterer = + _clusterManagerIdToMarkerClusterer[clusterManagerId]; + if (markerClusterer != null) { + markerClusterer.onRemove(); + markerClusterer.clearMarkers(true); + } + _clusterManagerIdToMarkerClusterer.remove(clusterManagerId); + } + + /// Adds given [gmaps.Marker] to the [MarkerClusterer] with given [ClusterManagerId]. + void addItem(ClusterManagerId clusterManagerId, gmaps.Marker marker) { + final MarkerClusterer? markerClusterer = + _clusterManagerIdToMarkerClusterer[clusterManagerId]; + if (markerClusterer != null) { + markerClusterer.addMarker(marker, false); + } + } + + /// Removes given [gmaps.Marker] from the [MarkerClusterer] with given [ClusterManagerId]. + void removeItem(ClusterManagerId clusterManagerId, gmaps.Marker? marker) { + if (marker != null) { + final MarkerClusterer? markerClusterer = + _clusterManagerIdToMarkerClusterer[clusterManagerId]; + if (markerClusterer != null) { + markerClusterer.removeMarker(marker, false); + } + } + } + + /// Returns list of clusters in [MarkerClusterer] with given [ClusterManagerId]. + List getClusters(ClusterManagerId clusterManagerId) { + final MarkerClusterer? markerClusterer = + _clusterManagerIdToMarkerClusterer[clusterManagerId]; + if (markerClusterer != null) { + return markerClusterer.clusters + .map((MarkerClustererCluster cluster) => + _convertCluster(clusterManagerId, cluster)) + .toList(); + } + return []; + } + + void _clusterClicked( + ClusterManagerId clusterManagerId, + gmaps.MapMouseEvent event, + MarkerClustererCluster markerClustererCluster, + gmaps.GMap map) { + if (markerClustererCluster.count > 0 && + markerClustererCluster.bounds != null && + markerClustererCluster.markers != null) { + final Cluster cluster = + _convertCluster(clusterManagerId, markerClustererCluster); + _streamController.add(ClusterTapEvent(mapId, cluster)); + } + } + + /// Converts [MarkerClustererCluster] to [Cluster]. + Cluster _convertCluster(ClusterManagerId clusterManagerId, + MarkerClustererCluster markerClustererCluster) { + final LatLng position = _gmLatLngToLatLng(markerClustererCluster.position); + final LatLngBounds bounds = + _gmLatLngBoundsTolatLngBounds(markerClustererCluster.bounds!); + + final List markerIds = markerClustererCluster.markers! + .map((gmaps.Marker marker) => + MarkerId(marker.get('markerId')! as String)) + .toList(); + return Cluster(clusterManagerId, position, bounds, markerIds); + } +} + +@JS() +external ClusterClickHandler defaultOnClusterClickHandler; + +@JS() +@anonymous +class MarkerClustererOptions { + external factory MarkerClustererOptions(); + + external gmaps.GMap? get map; + + external set map(gmaps.GMap? map); + + external List? get markers; + + external set markers(List? markers); + + external ClusterClickHandler? get onClusterClick; + + external set onClusterClick(ClusterClickHandler? handler); +} + +@JS('markerClusterer.Cluster') +class MarkerClustererCluster { + external gmaps.Marker get marker; + external List? markers; + + external gmaps.LatLngBounds? get bounds; + external gmaps.LatLng get position; + + /// Get the count of **visible** markers. + external int get count; + + external void delete(); + external void push(gmaps.Marker marker); +} + +@JS('markerClusterer.MarkerClusterer') +class MarkerClusterer { + external MarkerClusterer(MarkerClustererOptions options); + + external void addMarker(gmaps.Marker marker, bool? noDraw); + external void addMarkers(List? markers, bool? noDraw); + external bool removeMarker(gmaps.Marker marker, bool? noDraw); + external bool removeMarkers(List? markers, bool? noDraw); + external void clearMarkers(bool? noDraw); + external void onAdd(); + external void onRemove(); + external List get clusters; + + /// Recalculates and draws all the marker clusters. + external void render(); +} + +MarkerClusterer createMarkerClusterer( + gmaps.GMap map, ClusterClickHandler onClusterClickHandler) { + return MarkerClusterer(createClusterOptions(map, onClusterClickHandler)); +} + +MarkerClustererOptions createClusterOptions( + gmaps.GMap map, ClusterClickHandler onClusterClickHandler) { + final MarkerClustererOptions options = MarkerClustererOptions() + ..map = map + ..onClusterClick = allowInterop(onClusterClickHandler); + + return options; +} 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 1a712b109677..804c8c060d24 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 @@ -9,7 +9,9 @@ class MarkersController extends GeometryController { /// Initialize the cache. The [StreamController] comes from the [GoogleMapController], and is shared with other controllers. MarkersController({ required StreamController> stream, + required ClusterManagersController clusterManagersController, }) : _streamController = stream, + _clusterManagersController = clusterManagersController, _markerIdToController = {}; // A cache of [MarkerController]s indexed by their [MarkerId]. @@ -18,6 +20,8 @@ class MarkersController extends GeometryController { // The stream over which markers broadcast their events final StreamController> _streamController; + final ClusterManagersController _clusterManagersController; + /// Returns the cache of [MarkerController]s. Test only. @visibleForTesting Map get markers => _markerIdToController; @@ -56,9 +60,19 @@ class MarkersController extends GeometryController { final gmaps.MarkerOptions markerOptions = _markerOptionsFromMarker(marker, currentMarker); - final gmaps.Marker gmMarker = gmaps.Marker(markerOptions)..map = googleMap; + + final gmaps.Marker gmMarker = gmaps.Marker(markerOptions); + + gmMarker.set('markerId', marker.markerId.value); + + if (marker.clusterManagerId != null) { + _clusterManagersController.addItem(marker.clusterManagerId!, gmMarker); + } else { + gmMarker.map = googleMap; + } final MarkerController controller = MarkerController( marker: gmMarker, + clusterManagerId: marker.clusterManagerId, infoWindow: gmInfoWindow, consumeTapEvents: marker.consumeTapEvents, onTap: () { @@ -87,16 +101,26 @@ class MarkersController extends GeometryController { final MarkerController? markerController = _markerIdToController[marker.markerId]; if (markerController != null) { - final gmaps.MarkerOptions markerOptions = _markerOptionsFromMarker( - marker, - markerController.marker, - ); - final gmaps.InfoWindowOptions? infoWindow = - _infoWindowOptionsFromMarker(marker); - markerController.update( - markerOptions, - newInfoWindowContent: infoWindow?.content as HtmlElement?, - ); + final ClusterManagerId? oldClusterManagerId = + markerController.clusterManagerId; + final ClusterManagerId? newClusterManagerId = marker.clusterManagerId; + + if (oldClusterManagerId != newClusterManagerId) { + // If clusterManagerId changes. Remove existing marker and create new one. + _removeMarker(marker.markerId); + _addMarker(marker); + } else { + final gmaps.MarkerOptions markerOptions = _markerOptionsFromMarker( + marker, + markerController.marker, + ); + final gmaps.InfoWindowOptions? infoWindow = + _infoWindowOptionsFromMarker(marker); + markerController.update( + markerOptions, + newInfoWindowContent: infoWindow?.content as HtmlElement?, + ); + } } } @@ -107,6 +131,10 @@ class MarkersController extends GeometryController { void _removeMarker(MarkerId markerId) { final MarkerController? markerController = _markerIdToController[markerId]; + if (markerController?.clusterManagerId != null) { + _clusterManagersController.removeItem( + markerController!.clusterManagerId!, markerController.marker); + } markerController?.remove(); _markerIdToController.remove(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 072d584b133f..6165b41504b2 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: sdk: flutter google_maps: ^6.1.0 google_maps_flutter_platform_interface: ^2.2.2 + js: ^0.6.4 sanitize_html: ^2.0.0 stream_transform: ^2.0.0 @@ -33,3 +34,9 @@ dev_dependencies: # The example deliberately includes limited-use secrets. false_secrets: - /example/web/index.html + + +# FOR TESTING ONLY. DO NOT MERGE. +dependency_overrides: + google_maps_flutter_platform_interface: + path: ../../google_maps_flutter/google_maps_flutter_platform_interface