From 6898e4c2db896450a2e6d07ebcd50004d1a845ed Mon Sep 17 00:00:00 2001 From: Joonas Kerttula Date: Tue, 30 Apr 2024 11:23:16 +0300 Subject: [PATCH] [google_maps_flutter_android] Add marker clustering support (#6185) This PR introduces support for marker clustering for Android platform An example usage is available in the example application at `./packages/google_maps_flutter/google_maps_flutter_android/example` on the page `Manage clustering` This is prequel PR for: https://github.com/flutter/packages/pull/4319 and sequel PR for: https://github.com/flutter/packages/pull/6158 Containing only changes to `google_maps_flutter_android` package. Follow up PR will hold the app-facing plugin implementation. Linked issue: https://github.com/flutter/flutter/issues/26863 --- .../google_maps_flutter_android/AUTHORS | 1 + .../google_maps_flutter_android/CHANGELOG.md | 4 + .../android/build.gradle | 1 + .../googlemaps/ClusterManagersController.java | 243 +++++++++++++++ .../flutter/plugins/googlemaps/Convert.java | 52 +++- .../plugins/googlemaps/GoogleMapBuilder.java | 7 + .../googlemaps/GoogleMapController.java | 147 ++++++++- .../plugins/googlemaps/GoogleMapFactory.java | 3 + .../googlemaps/GoogleMapOptionsSink.java | 2 + .../plugins/googlemaps/MarkerBuilder.java | 54 +++- .../plugins/googlemaps/MarkerController.java | 77 ++++- .../plugins/googlemaps/MarkersController.java | 130 ++++++-- .../ClusterManagersControllerTest.java | 183 ++++++++++++ .../plugins/googlemaps/ConvertTest.java | 61 ++++ .../googlemaps/GoogleMapControllerTest.java | 132 ++++++++- .../googlemaps/MarkersControllerTest.java | 160 ++++++++-- .../example/android/app/build.gradle | 1 + .../integration_test/google_maps_tests.dart | 102 +++++++ .../example/lib/clustering.dart | 279 ++++++++++++++++++ .../example/lib/example_google_map.dart | 32 ++ .../example/lib/main.dart | 2 + .../example/pubspec.yaml | 2 +- .../fake_google_maps_flutter_platform.dart | 18 ++ .../lib/src/google_map_inspector_android.dart | 21 ++ .../lib/src/google_maps_flutter_android.dart | 66 +++++ .../lib/src/utils/cluster_manager_utils.dart | 34 +++ .../google_maps_flutter_android/pubspec.yaml | 4 +- .../test/cluster_manager_utils_test.dart | 59 ++++ 28 files changed, 1810 insertions(+), 67 deletions(-) create mode 100644 packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/ClusterManagersController.java create mode 100644 packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/ClusterManagersControllerTest.java create mode 100644 packages/google_maps_flutter/google_maps_flutter_android/example/lib/clustering.dart create mode 100644 packages/google_maps_flutter/google_maps_flutter_android/lib/src/utils/cluster_manager_utils.dart create mode 100644 packages/google_maps_flutter/google_maps_flutter_android/test/cluster_manager_utils_test.dart diff --git a/packages/google_maps_flutter/google_maps_flutter_android/AUTHORS b/packages/google_maps_flutter/google_maps_flutter_android/AUTHORS index 9f1b53ee2667..4fc3ace39f0f 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/AUTHORS +++ b/packages/google_maps_flutter/google_maps_flutter_android/AUTHORS @@ -65,3 +65,4 @@ Anton Borries Alex Li Rahul Raj <64.rahulraj@gmail.com> Taha Tesser +Joonas Kerttula diff --git a/packages/google_maps_flutter/google_maps_flutter_android/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_android/CHANGELOG.md index a099bb5ef424..ab9a624e3fb4 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.8.0 + +* Adds support for marker clustering. + ## 2.7.0 * Adds support for `MapConfiguration.style`. 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 e9ea102e3974..00781bb77184 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 @@ -41,6 +41,7 @@ android { dependencies { implementation "androidx.annotation:annotation:1.7.0" implementation 'com.google.android.gms:play-services-maps:18.2.0' + implementation 'com.google.maps.android:android-maps-utils:3.6.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/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..1c46161df4cb --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/ClusterManagersController.java @@ -0,0 +1,243 @@ +// 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 cluster items for + * specific cluster managers. + */ +class ClusterManagersController + implements GoogleMap.OnCameraIdleListener, + ClusterManager.OnClusterClickListener { + @NonNull private final Context context; + @NonNull private final HashMap> clusterManagerIdToManager; + @NonNull private final MethodChannel methodChannel; + @Nullable private MarkerManager markerManager; + @Nullable private GoogleMap googleMap; + + @Nullable + private ClusterManager.OnClusterItemClickListener clusterItemClickListener; + + @Nullable + private ClusterManagersController.OnClusterItemRendered + clusterItemRenderedListener; + + 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 setClusterItemClickListener( + @Nullable ClusterManager.OnClusterItemClickListener listener) { + clusterItemClickListener = listener; + initListenersForClusterManagers(); + } + + void setClusterItemRenderedListener( + @Nullable ClusterManagersController.OnClusterItemRendered listener) { + clusterItemRenderedListener = listener; + } + + private void initListenersForClusterManagers() { + for (Map.Entry> entry : + clusterManagerIdToManager.entrySet()) { + initListenersForClusterManager(entry.getValue(), this, clusterItemClickListener); + } + } + + private void initListenersForClusterManager( + ClusterManager clusterManager, + @Nullable ClusterManager.OnClusterClickListener clusterClickListener, + @Nullable ClusterManager.OnClusterItemClickListener clusterItemClickListener) { + clusterManager.setOnClusterClickListener(clusterClickListener); + clusterManager.setOnClusterItemClickListener(clusterItemClickListener); + } + + /** Adds new ClusterManagers to the controller. */ + void addClusterManagers(@NonNull List clusterManagersToAdd) { + for (Object clusterToAdd : clusterManagersToAdd) { + addClusterManager(clusterToAdd); + } + } + + /** Adds new ClusterManager to the controller. */ + 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, clusterItemClickListener); + clusterManagerIdToManager.put(clusterManagerId, clusterManager); + } + + /** Removes ClusterManagers by given cluster manager IDs from the controller. */ + public void removeClusterManagers(@NonNull List clusterManagerIdsToRemove) { + for (Object rawClusterManagerId : clusterManagerIdsToRemove) { + if (rawClusterManagerId == null) { + continue; + } + String clusterManagerId = (String) rawClusterManagerId; + removeClusterManager(clusterManagerId); + } + } + + /** + * Removes the ClusterManagers by the given cluster manager ID from the controller. The reference + * to this cluster manager is removed from the clusterManagerIdToManager and it will be garbage + * collected later. + */ + private void removeClusterManager(Object clusterManagerId) { + // Remove the cluster manager from the hash map to allow it to be garbage collected. + 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 to. */ + 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 to. */ + 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 onClusterItemRendered(@NonNull MarkerBuilder item, @NonNull Marker marker) { + // If map is being disposed, clusterItemRenderedListener might have been cleared and + // set to null. + if (clusterItemRenderedListener != null) { + clusterItemRenderedListener.onClusterItemRendered(item, marker); + } + } + + /** Reads clusterManagerId from object data. */ + @SuppressWarnings("unchecked") + private static String getClusterManagerId(Object clusterManagerData) { + Map clusterMap = (Map) clusterManagerData; + // Ref: google_maps_flutter_platform_interface/lib/src/types/cluster_manager.dart ClusterManager.toJson() method. + 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:" + 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 to allow the default behavior of the cluster click event to occur. + return false; + } + + /** + * ClusterRenderer builds marker options for new markers to be rendered to the map. After cluster + * item (marker) is rendered, 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 T item, @NonNull MarkerOptions markerOptions) { + // Builds new markerOptions for new marker created by the ClusterRenderer under + // ClusterManager. + item.update(markerOptions); + } + + @Override + protected void onClusterItemRendered(@NonNull T item, @NonNull Marker marker) { + super.onClusterItemRendered(item, marker); + clusterManagersController.onClusterItemRendered(item, marker); + } + } + + /** Interface for handling situations where clusterManager adds new visible marker to the map. */ + public interface OnClusterItemRendered { + void onClusterItemRendered(@NonNull T item, @NonNull 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 b473f1ea17d8..28449b81f3ed 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 @@ -25,11 +25,13 @@ 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 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 { @@ -160,7 +162,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)); @@ -221,6 +223,44 @@ 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); + + // For dart side implementation see parseCluster method at + // packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_maps_flutter_android.dart + 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))); @@ -383,8 +423,8 @@ static void interpretGoogleMapOptions(Object o, GoogleMapOptionsSink sink) { } } - /** Returns the dartMarkerId of the interpreted marker. */ - static String interpretMarkerOptions(Object o, MarkerOptionsSink sink) { + /** Set the options in the given object to marker options sink. */ + static void interpretMarkerOptions(Object o, MarkerOptionsSink sink) { final Map data = toMap(o); final Object alpha = data.get("alpha"); if (alpha != null) { @@ -432,12 +472,6 @@ 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; - } } private static void interpretInfoWindowOptions( 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 dfbaf02b9d4a..02477418d425 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 @@ -23,6 +23,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; @@ -44,6 +45,7 @@ GoogleMapController build( controller.setTrafficEnabled(trafficEnabled); controller.setBuildingsEnabled(buildingsEnabled); controller.setTrackCameraPosition(trackCameraPosition); + controller.setInitialClusterManagers(initialClusterManagers); controller.setInitialMarkers(initialMarkers); controller.setInitialPolygons(initialPolygons); controller.setInitialPolylines(initialPolylines); @@ -162,6 +164,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 b4102471c4b2..9a077ece3809 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 @@ -37,6 +37,8 @@ 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.clustering.ClusterManager; +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; @@ -51,13 +53,15 @@ import java.util.Objects; /** Controller of a single GoogleMaps MapView instance. */ -final class GoogleMapController +class GoogleMapController implements DefaultLifecycleObserver, ActivityPluginBinding.OnSaveInstanceStateListener, GoogleMapOptionsSink, MethodChannel.MethodCallHandler, OnMapReadyCallback, GoogleMapListener, + ClusterManager.OnClusterItemClickListener, + ClusterManagersController.OnClusterItemRendered, PlatformView { private static final String TAG = "GoogleMapController"; @@ -79,11 +83,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; @@ -108,20 +116,50 @@ 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); this.tileOverlaysController = new TileOverlaysController(methodChannel); } + // Constructor for testing purposes only + @VisibleForTesting + GoogleMapController( + int id, + Context context, + MethodChannel methodChannel, + LifecycleProvider lifecycleProvider, + GoogleMapOptions options, + ClusterManagersController clusterManagersController, + MarkersController markersController, + PolygonsController polygonsController, + PolylinesController polylinesController, + CirclesController circlesController, + TileOverlaysController tileOverlaysController) { + this.id = id; + this.context = context; + this.methodChannel = methodChannel; + this.options = options; + this.mapView = new MapView(context, options); + this.density = context.getResources().getDisplayMetrics().density; + this.lifecycleProvider = lifecycleProvider; + this.clusterManagersController = clusterManagersController; + this.markersController = markersController; + this.polygonsController = polygonsController; + this.polylinesController = polylinesController; + this.circlesController = circlesController; + this.tileOverlaysController = tileOverlaysController; + } + @Override public View getView() { return mapView; } @VisibleForTesting - /*package*/ void setView(MapView view) { + /* package */ void setView(MapView view) { mapView = view; } @@ -149,18 +187,24 @@ public void onMapReady(GoogleMap googleMap) { this.googleMap.setTrafficEnabled(this.trafficEnabled); this.googleMap.setBuildingsEnabled(this.buildingsEnabled); installInvalidator(); - googleMap.setOnInfoWindowClickListener(this); if (mapReadyResult != null) { 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); + setMarkerCollectionListener(this); + setClusterItemClickListener(this); + setClusterItemRenderedListener(this); + updateInitialClusterManagers(); updateInitialMarkers(); updateInitialPolygons(); updateInitialPolylines(); @@ -264,7 +308,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", @@ -364,6 +408,28 @@ public void onSnapshotReady(Bitmap bitmap) { markersController.isInfoWindowShown((String) markerId, result); break; } + case "clusterManagers#update": + { + List clusterManagersToAdd = call.argument("clusterManagersToAdd"); + if (clusterManagersToAdd != null) { + clusterManagersController.addClusterManagers(clusterManagersToAdd); + } + List clusterManagerIdsToRemove = call.argument("clusterManagerIdsToRemove"); + if (clusterManagerIdsToRemove != null) { + clusterManagersController.removeClusterManagers(clusterManagerIdsToRemove); + } + result.success(null); + break; + } + case "clusterManager#getClusters": + { + // The "clusterManagerId" is set in getClusters method at: + // packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_map_inspector_android.dart + Object clusterManagerId = call.argument("clusterManagerId"); + clusterManagersController.getClustersWithClusterManagerId( + (String) clusterManagerId, result); + break; + } case "polygons#update": { List polygonsToAdd = call.argument("polygonsToAdd"); @@ -551,12 +617,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 @@ -597,6 +664,9 @@ public void dispose() { disposed = true; methodChannel.setMethodCallHandler(null); setGoogleMapListener(null); + setMarkerCollectionListener(null); + setClusterItemClickListener(null); + setClusterItemRenderedListener(null); destroyMapViewIfNecessary(); Lifecycle lifecycle = lifecycleProvider.getLifecycle(); if (lifecycle != null) { @@ -612,8 +682,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); @@ -621,6 +689,40 @@ private void setGoogleMapListener(@Nullable GoogleMapListener listener) { googleMap.setOnMapLongClickListener(listener); } + @VisibleForTesting + public 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); + markerCollection.setOnInfoWindowClickListener(listener); + } + + @VisibleForTesting + public void setClusterItemClickListener( + @Nullable ClusterManager.OnClusterItemClickListener listener) { + if (googleMap == null) { + Log.v(TAG, "Controller was disposed before GoogleMap was ready."); + return; + } + + clusterManagersController.setClusterItemClickListener(listener); + } + + @VisibleForTesting + public void setClusterItemRenderedListener( + @Nullable ClusterManagersController.OnClusterItemRendered listener) { + if (googleMap == null) { + Log.v(TAG, "Controller was disposed before GoogleMap was ready."); + return; + } + + clusterManagersController.setClusterItemRenderedListener(listener); + } + // DefaultLifecycleObserver @Override @@ -824,6 +926,21 @@ 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() { + if (initialClusterManagers != null) { + clusterManagersController.addClusterManagers(initialClusterManagers); + } + } + @Override public void setInitialPolygons(Object initialPolygons) { ArrayList polygons = (ArrayList) initialPolygons; @@ -882,7 +999,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 { @@ -931,6 +1048,16 @@ public void setBuildingsEnabled(boolean buildingsEnabled) { this.buildingsEnabled = buildingsEnabled; } + @Override + public void onClusterItemRendered(MarkerBuilder markerBuilder, Marker marker) { + markersController.onClusterItemRendered(markerBuilder, marker); + } + + @Override + public boolean onClusterItemClick(MarkerBuilder item) { + return markersController.onMarkerTap(item.markerId()); + } + public void setMapStyle(@Nullable String style) { if (googleMap == null) { initialMapStyle = style; 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 c4f6a98b6cfa..b8d6485d35eb 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 @@ -43,6 +43,9 @@ public PlatformView create(@NonNull Context context, int id, @Nullable Object ar 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 95c550c92fd8..9f744a653b3c 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 @@ -49,6 +49,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..fe99cb48adaa 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,23 +7,53 @@ import com.google.android.gms.maps.model.BitmapDescriptor; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.MarkerOptions; +import com.google.maps.android.clustering.ClusterItem; -class MarkerBuilder implements MarkerOptionsSink { +class MarkerBuilder implements MarkerOptionsSink, ClusterItem { private final MarkerOptions markerOptions; + private String clusterManagerId; + private String markerId; private boolean consumeTapEvents; - MarkerBuilder() { + MarkerBuilder(String markerId, String clusterManagerId) { this.markerOptions = new MarkerOptions(); + this.markerId = markerId; + this.clusterManagerId = clusterManagerId; } MarkerOptions build() { return markerOptions; } + /** Update existing markerOptions with builder values */ + void update(MarkerOptions markerOptionsToUpdate) { + markerOptionsToUpdate.alpha(markerOptions.getAlpha()); + markerOptionsToUpdate.anchor(markerOptions.getAnchorU(), markerOptions.getAnchorV()); + markerOptionsToUpdate.draggable(markerOptions.isDraggable()); + markerOptionsToUpdate.flat(markerOptions.isFlat()); + markerOptionsToUpdate.icon(markerOptions.getIcon()); + markerOptionsToUpdate.infoWindowAnchor( + markerOptions.getInfoWindowAnchorU(), markerOptions.getInfoWindowAnchorV()); + markerOptionsToUpdate.title(markerOptions.getTitle()); + markerOptionsToUpdate.snippet(markerOptions.getSnippet()); + markerOptionsToUpdate.position(markerOptions.getPosition()); + markerOptionsToUpdate.rotation(markerOptions.getRotation()); + markerOptionsToUpdate.visible(markerOptions.isVisible()); + markerOptionsToUpdate.zIndex(markerOptions.getZIndex()); + } + boolean consumeTapEvents() { return consumeTapEvents; } + String clusterManagerId() { + return clusterManagerId; + } + + String markerId() { + return markerId; + } + @Override public void setAlpha(float alpha) { markerOptions.alpha(alpha); @@ -84,4 +114,24 @@ public void setVisible(boolean visible) { public void setZIndex(float zIndex) { markerOptions.zIndex(zIndex); } + + @Override + public LatLng getPosition() { + return markerOptions.getPosition(); + } + + @Override + public String getTitle() { + return markerOptions.getTitle(); + } + + @Override + public String getSnippet() { + return markerOptions.getSnippet(); + } + + @Override + public Float getZIndex() { + return markerOptions.getZIndex(); + } } 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..353ec2dfadbc 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,139 @@ 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; + // Holds a weak reference to a Marker instance. The clustering library + // dynamically manages markers, adding and removing them from the map + // as needed based on user interaction or data changes. + 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 +152,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..fa88310e0eab 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,35 @@ 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; +import java.util.Objects; 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 +60,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()); } } @@ -92,11 +113,15 @@ void isInfoWindowShown(String markerId, MethodChannel.Result result) { } } - 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 +171,58 @@ 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 onClusterItemRendered(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 +233,27 @@ 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(); + + // If the cluster ID on the updated marker has changed, the marker needs to + // be removed and re-added to update its cluster manager state. + if (!(Objects.equals(clusterManagerId, 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 +265,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/ClusterManagersControllerTest.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/ClusterManagersControllerTest.java new file mode 100644 index 000000000000..565c11f2dfd3 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/ClusterManagersControllerTest.java @@ -0,0 +1,183 @@ +// 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +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.CameraPosition; +import com.google.android.gms.maps.model.LatLng; +import com.google.maps.android.clustering.algo.StaticCluster; +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; +import java.util.ArrayList; +import java.util.Arrays; +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.ArgumentCaptor; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = Build.VERSION_CODES.P) +public class ClusterManagersControllerTest { + private Context context; + private MethodChannel methodChannel; + private ClusterManagersController controller; + private GoogleMap googleMap; + private MarkerManager markerManager; + private MarkerManager.Collection markerCollection; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + methodChannel = + spy(new MethodChannel(mock(BinaryMessenger.class), "no-name", mock(MethodCodec.class))); + controller = spy(new ClusterManagersController(methodChannel, context)); + googleMap = mock(GoogleMap.class); + markerManager = new MarkerManager(googleMap); + markerCollection = markerManager.newCollection(); + controller.init(googleMap, markerManager); + } + + @Test + @SuppressWarnings("unchecked") + public void AddClusterManagersAndMarkers() throws InterruptedException { + final String clusterManagerId = "cm_1"; + final String markerId1 = "mid_1"; + final String markerId2 = "mid_2"; + + final LatLng latLng1 = new LatLng(1.1, 2.2); + final LatLng latLng2 = new LatLng(3.3, 4.4); + + final List location1 = new ArrayList<>(); + location1.add(latLng1.latitude); + location1.add(latLng1.longitude); + + final List location2 = new ArrayList<>(); + location2.add(latLng2.latitude); + location2.add(latLng2.longitude); + + when(googleMap.getCameraPosition()) + .thenReturn(CameraPosition.builder().target(new LatLng(0, 0)).build()); + Map initialClusterManager = new HashMap<>(); + initialClusterManager.put("clusterManagerId", clusterManagerId); + List clusterManagersToAdd = new ArrayList<>(); + clusterManagersToAdd.add(initialClusterManager); + controller.addClusterManagers(clusterManagersToAdd); + + MarkerBuilder markerBuilder1 = new MarkerBuilder(markerId1, clusterManagerId); + MarkerBuilder markerBuilder2 = new MarkerBuilder(markerId2, clusterManagerId); + + final Map markerData1 = + createMarkerData(markerId1, location1, clusterManagerId); + final Map markerData2 = + createMarkerData(markerId2, location2, clusterManagerId); + + Convert.interpretMarkerOptions(markerData1, markerBuilder1); + Convert.interpretMarkerOptions(markerData2, markerBuilder2); + + controller.addItem(markerBuilder1); + controller.addItem(markerBuilder2); + + final MethodChannel.Result clusterResult1 = mock(MethodChannel.Result.class); + + controller.getClustersWithClusterManagerId(clusterManagerId, clusterResult1); + + ArgumentCaptor resultCaptor1 = ArgumentCaptor.forClass(Object.class); + Mockito.verify(clusterResult1, times(1)).success(resultCaptor1.capture()); + Object capturedResult1 = resultCaptor1.getValue(); + + assertTrue( + "The captured result should be an instance of List", capturedResult1 instanceof List); + + List resultList1 = (List) capturedResult1; + assertEquals("Amount of clusters should be 1", 1, resultList1.size()); + + Map clusterData = (Map) resultList1.get(0); + assertEquals( + "Incorrect cluster manager ID", clusterManagerId, clusterData.get("clusterManagerId")); + assertNotNull("Cluster bounds should not be null", clusterData.get("bounds")); + assertNotNull("Cluster position should not be null", clusterData.get("position")); + List markerIds = (List) clusterData.get("markerIds"); + assertTrue("Marker IDs should contain markerId1", markerIds.contains(markerId1)); + assertTrue("Marker IDs should contain markerId2", markerIds.contains(markerId2)); + assertEquals("Cluster should contain exactly 2 markers", 2, markerIds.size()); + } + + @Test + @SuppressWarnings("unchecked") + public void OnClusterClickCallsMethodChannel() throws InterruptedException { + String clusterManagerId = "cm_1"; + LatLng clusterPosition = new LatLng(43.00, -87.90); + LatLng markerPosition1 = new LatLng(43.05, -87.95); + LatLng markerPosition2 = new LatLng(43.02, -87.92); + + StaticCluster cluster = new StaticCluster<>(clusterPosition); + + MarkerBuilder marker1 = new MarkerBuilder("m_1", clusterManagerId); + marker1.setPosition(markerPosition1); + cluster.add(marker1); + + MarkerBuilder marker2 = new MarkerBuilder("m_2", clusterManagerId); + marker2.setPosition(markerPosition2); + cluster.add(marker2); + + controller.onClusterClick(cluster); + Mockito.verify(methodChannel) + .invokeMethod("cluster#onTap", Convert.clusterToJson(clusterManagerId, cluster)); + } + + @Test + public void RemoveClusterManagers() { + final String clusterManagerId = "cm_1"; + + when(googleMap.getCameraPosition()) + .thenReturn(CameraPosition.builder().target(new LatLng(0, 0)).build()); + Map initialClusterManager = new HashMap<>(); + initialClusterManager.put("clusterManagerId", clusterManagerId); + List clusterManagersToAdd = new ArrayList<>(); + clusterManagersToAdd.add(initialClusterManager); + controller.addClusterManagers(clusterManagersToAdd); + + final MethodChannel.Result clusterResult1 = mock(MethodChannel.Result.class); + controller.getClustersWithClusterManagerId(clusterManagerId, clusterResult1); + // Verify that fetching the cluster data success and therefore ClusterManager is added. + Mockito.verify(clusterResult1, times(1)).success(any()); + + controller.removeClusterManagers(Arrays.asList(clusterManagerId)); + final MethodChannel.Result clusterResult2 = mock(MethodChannel.Result.class); + controller.getClustersWithClusterManagerId(clusterManagerId, clusterResult2); + + // Verify that fetching the cluster data fails and therefore ClusterManager is removed. + Mockito.verify(clusterResult2, times(1)).error(any(), any(), any()); + } + + private Map createMarkerData( + String markerId, List location, String clusterManagerId) { + Map markerData = new HashMap<>(); + markerData.put("markerId", markerId); + markerData.put("position", location); + markerData.put("clusterManagerId", clusterManagerId); + return markerData; + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/ConvertTest.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/ConvertTest.java index 0d635170c1f3..8f6c806838cc 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/ConvertTest.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/ConvertTest.java @@ -5,8 +5,13 @@ package io.flutter.plugins.googlemaps; import com.google.android.gms.maps.model.LatLng; +import com.google.maps.android.clustering.Cluster; +import com.google.maps.android.clustering.algo.StaticCluster; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import org.junit.Assert; import org.junit.Test; @@ -26,4 +31,60 @@ public void ConvertToPointsConvertsThePointsWithFullPrecision() { Assert.assertEquals(latitude, latLng.latitude, 1e-15); Assert.assertEquals(longitude, latLng.longitude, 1e-15); } + + @Test + public void ConvertClustersToJsonReturnsCorrectData() { + String clusterManagerId = "cm_1"; + LatLng clusterPosition = new LatLng(43.00, -87.90); + LatLng markerPosition1 = new LatLng(43.05, -87.95); + LatLng markerPosition2 = new LatLng(43.02, -87.92); + + StaticCluster cluster = new StaticCluster<>(clusterPosition); + + MarkerBuilder marker1 = new MarkerBuilder("m_1", clusterManagerId); + marker1.setPosition(markerPosition1); + cluster.add(marker1); + + MarkerBuilder marker2 = new MarkerBuilder("m_2", clusterManagerId); + marker2.setPosition(markerPosition2); + cluster.add(marker2); + + Set> clusters = new HashSet<>(); + clusters.add(cluster); + + Object result = Convert.clustersToJson(clusterManagerId, clusters); + + Assert.assertTrue(result instanceof List); + + List data = (List) result; + Assert.assertEquals(1, data.size()); + + Map clusterData = (Map) data.get(0); + Assert.assertEquals(clusterManagerId, clusterData.get("clusterManagerId")); + + List position = (List) clusterData.get("position"); + Assert.assertTrue(position instanceof List); + Assert.assertEquals(clusterPosition.latitude, (double) position.get(0), 1e-15); + Assert.assertEquals(clusterPosition.longitude, (double) position.get(1), 1e-15); + + Map bounds = (Map) clusterData.get("bounds"); + Assert.assertTrue(bounds instanceof Map); + List southwest = (List) bounds.get("southwest"); + List northeast = (List) bounds.get("northeast"); + Assert.assertTrue(southwest instanceof List); + Assert.assertTrue(northeast instanceof List); + + // bounding data should combine data from marker positions markerPosition1 and markerPosition2 + Assert.assertEquals(markerPosition2.latitude, (double) southwest.get(0), 1e-15); + Assert.assertEquals(markerPosition1.longitude, (double) southwest.get(1), 1e-15); + Assert.assertEquals(markerPosition1.latitude, (double) northeast.get(0), 1e-15); + Assert.assertEquals(markerPosition2.longitude, (double) northeast.get(1), 1e-15); + + Object markerIds = clusterData.get("markerIds"); + Assert.assertTrue(markerIds instanceof List); + List markerIdList = (List) markerIds; + Assert.assertEquals(2, markerIdList.size()); + Assert.assertEquals(marker1.markerId(), markerIdList.get(0)); + Assert.assertEquals(marker2.markerId(), markerIdList.get(1)); + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java b/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java index 0b940b9317d7..0c7e1946e295 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java +++ b/packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java @@ -7,6 +7,9 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -15,7 +18,14 @@ import androidx.activity.ComponentActivity; import androidx.test.core.app.ApplicationProvider; import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.model.Marker; +import com.google.maps.android.clustering.ClusterManager; import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodChannel; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -33,20 +43,51 @@ public class GoogleMapControllerTest { private Context context; private ComponentActivity activity; - private GoogleMapController googleMapController; AutoCloseable mockCloseable; @Mock BinaryMessenger mockMessenger; @Mock GoogleMap mockGoogleMap; + @Mock MethodChannel mockMethodChannel; + @Mock ClusterManagersController mockClusterManagersController; + @Mock MarkersController mockMarkersController; + @Mock PolygonsController mockPolygonsController; + @Mock PolylinesController mockPolylinesController; + @Mock CirclesController mockCirclesController; + @Mock TileOverlaysController mockTileOverlaysController; @Before public void before() { mockCloseable = MockitoAnnotations.openMocks(this); context = ApplicationProvider.getApplicationContext(); setUpActivityLegacy(); - googleMapController = + } + + // Returns GoogleMapController instance. + // See getGoogleMapControllerWithMockedDependencies for version with dependency injections. + public GoogleMapController getGoogleMapController() { + GoogleMapController googleMapController = new GoogleMapController(0, context, mockMessenger, activity::getLifecycle, null); googleMapController.init(); + return googleMapController; + } + + // Returns GoogleMapController instance with mocked dependency injections. + public GoogleMapController getGoogleMapControllerWithMockedDependencies() { + GoogleMapController googleMapController = + new GoogleMapController( + 0, + context, + mockMethodChannel, + activity::getLifecycle, + null, + mockClusterManagersController, + mockMarkersController, + mockPolygonsController, + mockPolylinesController, + mockCirclesController, + mockTileOverlaysController); + googleMapController.init(); + return googleMapController; } // TODO(stuartmorgan): Update this to a non-deprecated test API. @@ -63,6 +104,7 @@ public void tearDown() throws Exception { @Test public void DisposeReleaseTheMap() throws InterruptedException { + GoogleMapController googleMapController = getGoogleMapController(); googleMapController.onMapReady(mockGoogleMap); assertTrue(googleMapController != null); googleMapController.dispose(); @@ -71,6 +113,7 @@ public void DisposeReleaseTheMap() throws InterruptedException { @Test public void OnDestroyReleaseTheMap() throws InterruptedException { + GoogleMapController googleMapController = getGoogleMapController(); googleMapController.onMapReady(mockGoogleMap); assertTrue(googleMapController != null); googleMapController.onDestroy(activity); @@ -79,6 +122,7 @@ public void OnDestroyReleaseTheMap() throws InterruptedException { @Test public void OnMapReadySetsPaddingIfInitialPaddingIsThere() { + GoogleMapController googleMapController = getGoogleMapController(); float padding = 10f; int paddingWithDensity = (int) (padding * googleMapController.density); googleMapController.setInitialPadding(padding, padding, padding, padding); @@ -89,9 +133,93 @@ public void OnMapReadySetsPaddingIfInitialPaddingIsThere() { @Test public void SetPaddingStoresThePaddingValuesInInInitialPaddingWhenGoogleMapIsNull() { + GoogleMapController googleMapController = getGoogleMapController(); assertNull(googleMapController.initialPadding); googleMapController.setPadding(0f, 0f, 0f, 0f); assertNotNull(googleMapController.initialPadding); Assert.assertEquals(4, googleMapController.initialPadding.size()); } + + @Test + public void OnMapReadySetsMarkerCollectionListener() { + GoogleMapController googleMapController = getGoogleMapController(); + GoogleMapController spyGoogleMapController = spy(googleMapController); + // setMarkerCollectionListener method should be called when map is ready + spyGoogleMapController.onMapReady(mockGoogleMap); + + // Verify if the setMarkerCollectionListener method is called with listener + verify(spyGoogleMapController, times(1)) + .setMarkerCollectionListener(any(GoogleMapListener.class)); + + spyGoogleMapController.dispose(); + // Verify if the setMarkerCollectionListener is cleared on dispose + verify(spyGoogleMapController, times(1)).setMarkerCollectionListener(null); + } + + @Test + @SuppressWarnings("unchecked") + public void OnMapReadySetsClusterItemClickListener() { + GoogleMapController googleMapController = getGoogleMapController(); + GoogleMapController spyGoogleMapController = spy(googleMapController); + // setMarkerCollectionListener method should be called when map is ready + spyGoogleMapController.onMapReady(mockGoogleMap); + + // Verify if the setMarkerCollectionListener method is called with listener + verify(spyGoogleMapController, times(1)) + .setClusterItemClickListener(any(ClusterManager.OnClusterItemClickListener.class)); + + spyGoogleMapController.dispose(); + // Verify if the setMarkerCollectionListener is cleared on dispose + verify(spyGoogleMapController, times(1)).setClusterItemClickListener(null); + } + + @Test + @SuppressWarnings("unchecked") + public void OnMapReadySetsClusterItemRenderedListener() { + GoogleMapController googleMapController = getGoogleMapController(); + GoogleMapController spyGoogleMapController = spy(googleMapController); + // setMarkerCollectionListener method should be called when map is ready + spyGoogleMapController.onMapReady(mockGoogleMap); + + // Verify if the setMarkerCollectionListener method is called with listener + + verify(spyGoogleMapController, times(1)) + .setClusterItemRenderedListener(any(ClusterManagersController.OnClusterItemRendered.class)); + + spyGoogleMapController.dispose(); + // Verify if the setMarkerCollectionListener is cleared on dispose + verify(spyGoogleMapController, times(1)).setClusterItemRenderedListener(null); + } + + @Test + public void SetInitialClusterManagers() { + GoogleMapController googleMapController = getGoogleMapControllerWithMockedDependencies(); + Map initialClusterManager = new HashMap<>(); + initialClusterManager.put("clusterManagerId", "cm_1"); + List initialClusterManagers = new ArrayList<>(); + initialClusterManagers.add(initialClusterManager); + googleMapController.setInitialClusterManagers(initialClusterManagers); + googleMapController.onMapReady(mockGoogleMap); + + // Verify if the ClusterManagersController.addClusterManagers method is called with initial cluster managers. + verify(mockClusterManagersController, times(1)).addClusterManagers(any()); + } + + @Test + public void OnClusterItemRenderedCallsMarkersController() { + GoogleMapController googleMapController = getGoogleMapControllerWithMockedDependencies(); + MarkerBuilder markerBuilder = new MarkerBuilder("m_1", "cm_1"); + final Marker marker = mock(Marker.class); + googleMapController.onClusterItemRendered(markerBuilder, marker); + verify(mockMarkersController, times(1)).onClusterItemRendered(markerBuilder, marker); + } + + @Test + public void OnClusterItemClickCallsMarkersController() { + GoogleMapController googleMapController = getGoogleMapControllerWithMockedDependencies(); + MarkerBuilder markerBuilder = new MarkerBuilder("m_1", "cm_1"); + + googleMapController.onClusterItemClick(markerBuilder); + verify(mockMarkersController, times(1)).onMarkerTap(markerBuilder.markerId()); + } } 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 08b157435672..581a0dbfc5c6 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 @@ -4,15 +4,21 @@ package io.flutter.plugins.googlemaps; +import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; 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,19 +27,41 @@ 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.ArgumentCaptor; 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; + private MethodChannel methodChannel; + private ClusterManagersController clusterManagersController; + private MarkersController controller; + private GoogleMap googleMap; + private MarkerManager markerManager; + private MarkerManager.Collection markerCollection; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + methodChannel = + spy(new MethodChannel(mock(BinaryMessenger.class), "no-name", mock(MethodCodec.class))); + clusterManagersController = spy(new ClusterManagersController(methodChannel, context)); + controller = new MarkersController(methodChannel, clusterManagersController); + googleMap = mock(GoogleMap.class); + markerManager = new MarkerManager(googleMap); + markerCollection = markerManager.newCollection(); + controller.setCollection(markerCollection); + clusterManagersController.init(googleMap, markerManager); + } @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 GoogleMap googleMap = mock(GoogleMap.class); - controller.setGoogleMap(googleMap); - final Marker marker = mock(Marker.class); final String googleMarkerId = "abc123"; @@ -61,12 +89,6 @@ public void controller_OnMarkerDragStart() { @Test 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 GoogleMap googleMap = mock(GoogleMap.class); - controller.setGoogleMap(googleMap); - final Marker marker = mock(Marker.class); final String googleMarkerId = "abc123"; @@ -94,12 +116,6 @@ public void controller_OnMarkerDragEnd() { @Test 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 GoogleMap googleMap = mock(GoogleMap.class); - controller.setGoogleMap(googleMap); - final Marker marker = mock(Marker.class); final String googleMarkerId = "abc123"; @@ -124,4 +140,112 @@ public void controller_OnMarkerDrag() { data.put("position", points); Mockito.verify(methodChannel).invokeMethod("marker#onDrag", data); } + + @Test(expected = IllegalArgumentException.class) + public void controller_AddMarkerThrowsErrorIfMarkerIdIsNull() { + final Map markerOptions = new HashMap<>(); + + final List markers = Arrays.asList(markerOptions); + try { + controller.addMarkers(markers); + } catch (IllegalArgumentException e) { + assertEquals("markerId was null", e.getMessage()); + throw e; + } + } + + @Test + public void controller_AddChangeAndRemoveMarkerWithClusterManagerId() { + final Marker marker = mock(Marker.class); + + final String googleMarkerId = "abc123"; + final String clusterManagerId = "cm123"; + + when(marker.getId()).thenReturn(googleMarkerId); + + final LatLng latLng1 = new LatLng(1.1, 2.2); + final List location1 = new ArrayList<>(); + location1.add(latLng1.latitude); + location1.add(latLng1.longitude); + + final Map markerOptions1 = new HashMap<>(); + markerOptions1.put("markerId", googleMarkerId); + markerOptions1.put("position", location1); + markerOptions1.put("clusterManagerId", clusterManagerId); + + final List markers = Arrays.asList(markerOptions1); + + // Add marker and capture the markerBuilder + controller.addMarkers(markers); + ArgumentCaptor captor = ArgumentCaptor.forClass(MarkerBuilder.class); + Mockito.verify(clusterManagersController, times(1)).addItem(captor.capture()); + MarkerBuilder capturedMarkerBuilder = captor.getValue(); + assertEquals(clusterManagerId, capturedMarkerBuilder.clusterManagerId()); + + // clusterManagersController calls onClusterItemRendered with created marker. + controller.onClusterItemRendered(capturedMarkerBuilder, marker); + + // Change marker to test that markerController is created and the marker can be updated + final LatLng latLng2 = new LatLng(3.3, 4.4); + final List location2 = new ArrayList<>(); + location2.add(latLng2.latitude); + location2.add(latLng2.longitude); + + final Map markerOptions2 = new HashMap<>(); + markerOptions2.put("markerId", googleMarkerId); + markerOptions2.put("position", location2); + markerOptions2.put("clusterManagerId", clusterManagerId); + final List updatedMarkers = Arrays.asList(markerOptions2); + + controller.changeMarkers(updatedMarkers); + Mockito.verify(marker, times(1)).setPosition(latLng2); + + // Remove marker + controller.removeMarkers(Arrays.asList(googleMarkerId)); + + Mockito.verify(clusterManagersController, times(1)) + .removeItem( + Mockito.argThat( + markerBuilder -> markerBuilder.clusterManagerId().equals(clusterManagerId))); + } + + @Test + public void controller_AddChangeAndRemoveMarkerWithoutClusterManagerId() { + MarkerManager.Collection spyMarkerCollection = spy(markerCollection); + controller.setCollection(spyMarkerCollection); + + final Marker marker = mock(Marker.class); + + final String googleMarkerId = "abc123"; + + when(marker.getId()).thenReturn(googleMarkerId); + when(googleMap.addMarker(any(MarkerOptions.class))).thenReturn(marker); + + final Map markerOptions1 = new HashMap<>(); + markerOptions1.put("markerId", googleMarkerId); + + final List markers = Arrays.asList(markerOptions1); + controller.addMarkers(markers); + + // clusterManagersController should not be called when adding the marker + Mockito.verify(clusterManagersController, times(0)).addItem(any()); + + Mockito.verify(spyMarkerCollection, times(1)).addMarker(any(MarkerOptions.class)); + + final float alpha = 0.1f; + final Map markerOptions2 = new HashMap<>(); + markerOptions2.put("markerId", googleMarkerId); + markerOptions2.put("alpha", alpha); + + final List markerUpdates = Arrays.asList(markerOptions2); + controller.changeMarkers(markerUpdates); + Mockito.verify(marker, times(1)).setAlpha(alpha); + + controller.removeMarkers(Arrays.asList(googleMarkerId)); + + // clusterManagersController should not be called when removing the marker + Mockito.verify(clusterManagersController, times(0)).removeItem(any()); + + Mockito.verify(spyMarkerCollection, times(1)).remove(marker); + } } 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 e153293498e4..96f5b50f286d 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 @@ -63,6 +63,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:3.6.0' } lint { disable 'InvalidPackage' 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 e03fe8f8851b..858d1fbc44a1 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 @@ -1190,6 +1190,85 @@ void googleMapsTests() { }, ); + 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); + } + }); + testWidgets( 'testCloudMapId', (WidgetTester tester) async { @@ -1312,3 +1391,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..1cd95118eb1c --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/example/lib/clustering.dart @@ -0,0 +1,279 @@ +// 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 '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'; + +/// Page for demonstrating marker clustering support. +class ClusteringPage extends GoogleMapExampleAppPage { + /// Default Constructor. + const ClusteringPage({Key? key}) + : super(const Icon(Icons.place), 'Manage clustering', key: key); + + @override + Widget build(BuildContext context) { + return const ClusteringBody(); + } +} + +/// Body of the clustering page. +class ClusteringBody extends StatefulWidget { + /// Default Constructor. + const ClusteringBody({super.key}); + + @override + State createState() => ClusteringBodyState(); +} + +/// State of the clustering page. +class ClusteringBodyState extends State { + /// Default Constructor. + ClusteringBodyState(); + + /// Starting point from where markers are added. + static const LatLng center = LatLng(-33.86, 151.1547171); + + /// Initial camera position. + static const CameraPosition initialCameraPosition = CameraPosition( + target: LatLng(-33.852, 151.25), + zoom: 11.0, + ); + + /// Marker offset factor for randomizing marker placing. + static const double _markerOffsetFactor = 0.05; + + /// Offset for longitude when placing markers to different cluster managers. + static const double _clusterManagerLongitudeOffset = 0.1; + + /// Maximum amount of cluster managers. + static const int _clusterManagerMaxCount = 3; + + /// Amount of markers to be added to the cluster manager at once. + static const int _markersToAddToClusterManagerCount = 10; + + /// Fully visible alpha value. + static const double _fullyVisibleAlpha = 1.0; + + /// Half visible alpha value. + static const double _halfVisibleAlpha = 0.5; + + /// Google map controller. + ExampleGoogleMapController? controller; + + /// Map of clusterManagers with identifier as the key. + Map clusterManagers = + {}; + + /// Map of markers with identifier as the key. + Map markers = {}; + + /// Id of the currently selected marker. + MarkerId? selectedMarker; + + /// Counter for added cluster manager ids. + int _clusterManagerIdCounter = 1; + + /// Counter for added markers ids. + int _markerIdCounter = 1; + + /// Cluster that was tapped most recently. + 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() { + if (clusterManagers.length == _clusterManagerMaxCount) { + 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 < _markersToAddToClusterManagerCount; i++) { + final String markerIdVal = + '${clusterManager.clusterManagerId.value}_marker_id_$_markerIdCounter'; + _markerIdCounter++; + final MarkerId markerId = MarkerId(markerIdVal); + + final int clusterManagerIndex = + clusterManagers.values.toList().indexOf(clusterManager); + + // Add additional offset to longitude for each cluster manager to space + // out markers in different cluster managers. + final double clusterManagerLongitudeOffset = + clusterManagerIndex * _clusterManagerLongitudeOffset; + + final Marker marker = Marker( + clusterManagerId: clusterManager.clusterManagerId, + markerId: markerId, + position: LatLng( + center.latitude + _getRandomOffset(), + center.longitude + _getRandomOffset() + clusterManagerLongitudeOffset, + ), + infoWindow: InfoWindow(title: markerIdVal, snippet: '*'), + onTap: () => _onMarkerTapped(markerId), + ); + markers[markerId] = marker; + } + setState(() {}); + } + + double _getRandomOffset() { + return (Random().nextDouble() - 0.5) * _markerOffsetFactor; + } + + void _remove(MarkerId markerId) { + setState(() { + if (markers.containsKey(markerId)) { + markers.remove(markerId); + } + }); + } + + void _changeMarkersAlpha() { + for (final MarkerId markerId in markers.keys) { + final Marker marker = markers[markerId]!; + final double current = marker.alpha; + markers[markerId] = marker.copyWith( + alphaParam: current == _fullyVisibleAlpha + ? _halfVisibleAlpha + : _fullyVisibleAlpha, + ); + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final MarkerId? selectedId = selectedMarker; + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ExampleGoogleMap( + onMapCreated: _onMapCreated, + initialCameraPosition: initialCameraPosition, + markers: Set.of(markers.values), + clusterManagers: Set.of(clusterManagers.values), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: clusterManagers.length >= _clusterManagerMaxCount + ? null + : () => _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 : () => _changeMarkersAlpha(), + child: const Text('Change all markers alpha'), + ), + ], + ), + 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 0734731af7c3..fcf24452c875 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 @@ -87,6 +87,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. @@ -101,6 +104,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 @@ -237,6 +247,7 @@ class ExampleGoogleMap extends StatefulWidget { this.polygons = const {}, this.polylines = const {}, this.circles = const {}, + this.clusterManagers = const {}, this.onCameraMoveStarted, this.tileOverlays = const {}, this.onCameraMove, @@ -312,6 +323,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; @@ -371,6 +385,8 @@ class _ExampleGoogleMapState extends State { Map _polygons = {}; Map _polylines = {}; Map _circles = {}; + Map _clusterManagers = + {}; late MapConfiguration _mapConfiguration; @override @@ -390,6 +406,7 @@ class _ExampleGoogleMapState extends State { polygons: widget.polygons, polylines: widget.polylines, circles: widget.circles, + clusterManagers: widget.clusterManagers, ), mapConfiguration: _mapConfiguration, ); @@ -399,6 +416,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); @@ -416,6 +434,7 @@ class _ExampleGoogleMapState extends State { void didUpdateWidget(ExampleGoogleMap oldWidget) { super.didUpdateWidget(oldWidget); _updateOptions(); + _updateClusterManagers(); _updateMarkers(); _updatePolygons(); _updatePolylines(); @@ -441,6 +460,13 @@ class _ExampleGoogleMapState extends State { _markers = keyByMarkerId(widget.markers); } + Future _updateClusterManagers() async { + final ExampleGoogleMapController controller = await _controller.future; + unawaited(controller._updateClusterManagers(ClusterManagerUpdates.from( + _clusterManagers.values.toSet(), widget.clusterManagers))); + _clusterManagers = keyByClusterManagerId(widget.clusterManagers); + } + Future _updatePolygons() async { final ExampleGoogleMapController controller = await _controller.future; unawaited(controller._updatePolygons( @@ -518,6 +544,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 9b9c32aad963..16e1dfd59bb6 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 @@ -9,6 +9,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'; @@ -42,6 +43,7 @@ final List _allPages = [ const SnapshotPage(), const LiteModePage(), const TileOverlayPage(), + const ClusteringPage(), const MapIdPage(), ]; 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 533ca53c2914..2bb74ac7003b 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 @@ -18,7 +18,7 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ - google_maps_flutter_platform_interface: ^2.5.0 + google_maps_flutter_platform_interface: ^2.6.0 dev_dependencies: build_runner: ^2.1.10 diff --git a/packages/google_maps_flutter/google_maps_flutter_android/example/test/fake_google_maps_flutter_platform.dart b/packages/google_maps_flutter/google_maps_flutter_android/example/test/fake_google_maps_flutter_platform.dart index 22447ba5ecad..9ac70ab760fe 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/example/test/fake_google_maps_flutter_platform.dart +++ b/packages/google_maps_flutter/google_maps_flutter_android/example/test/fake_google_maps_flutter_platform.dart @@ -94,6 +94,15 @@ class FakeGoogleMapsFlutterPlatform extends GoogleMapsFlutterPlatform { await _fakeDelay(); } + @override + Future updateClusterManagers( + ClusterManagerUpdates clusterManagerUpdates, { + required int mapId, + }) async { + mapInstances[mapId]?.clusterManagerUpdates.add(clusterManagerUpdates); + await _fakeDelay(); + } + @override Future clearTileCache( TileOverlayId tileOverlayId, { @@ -241,6 +250,11 @@ class FakeGoogleMapsFlutterPlatform extends GoogleMapsFlutterPlatform { return mapEventStreamController.stream.whereType(); } + @override + Stream onClusterTap({required int mapId}) { + return mapEventStreamController.stream.whereType(); + } + @override void dispose({required int mapId}) { disposed = true; @@ -282,6 +296,8 @@ class PlatformMapStateRecorder { this.mapObjects = const MapObjects(), this.mapConfiguration = const MapConfiguration(), }) { + clusterManagerUpdates.add(ClusterManagerUpdates.from( + const {}, mapObjects.clusterManagers)); markerUpdates.add(MarkerUpdates.from(const {}, mapObjects.markers)); polygonUpdates .add(PolygonUpdates.from(const {}, mapObjects.polygons)); @@ -300,4 +316,6 @@ class PlatformMapStateRecorder { final List polylineUpdates = []; final List circleUpdates = []; final List> tileOverlaySets = >[]; + final List clusterManagerUpdates = + []; } 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 d40d795d6a32..76b258d651c1 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 @@ -13,6 +13,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_utils.dart'; // TODO(stuartmorgan): Remove the dependency on platform interface toJson // methods. Channel serialization details should all be package-internal. @@ -181,6 +182,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': @@ -273,6 +279,17 @@ 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, + )); default: throw MissingPluginException(); } @@ -362,6 +379,17 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { ); } + @override + Future updateClusterManagers( + ClusterManagerUpdates clusterManagerUpdates, { + required int mapId, + }) { + return _channel(mapId).invokeMethod( + 'clusterManagers#update', + serializeClusterManagerUpdates(clusterManagerUpdates), + ); + } + @override Future clearTileCache( TileOverlayId tileOverlayId, { @@ -558,6 +586,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'; @@ -635,6 +665,7 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { Set polylines = const {}, Set circles = const {}, Set tileOverlays = const {}, + Set clusterManagers = const {}, Set>? gestureRecognizers, Map mapOptions = const {}, }) { @@ -649,6 +680,7 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { polygons: polygons, polylines: polylines, circles: circles, + clusterManagers: clusterManagers, tileOverlays: tileOverlays), mapOptions: mapOptions, ); @@ -664,6 +696,7 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { Set polylines = const {}, Set circles = const {}, Set tileOverlays = const {}, + Set clusterManagers = const {}, Set>? gestureRecognizers, Map mapOptions = const {}, }) { @@ -677,6 +710,7 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { polylines: polylines, circles: circles, tileOverlays: tileOverlays, + clusterManagers: clusterManagers, gestureRecognizers: gestureRecognizers, mapOptions: mapOptions, ); @@ -688,6 +722,38 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform { GoogleMapsInspectorPlatform.instance = GoogleMapsInspectorAndroid((int mapId) => _channel(mapId)); } + + /// Parses cluster data from dynamic json objects and returns [Cluster] object. + /// Used by the `cluster#onTap` method call handler and the + /// [GoogleMapsInspectorAndroid.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, + markerIds, + position: position, + bounds: bounds, + ); + } } Map _jsonForMapConfiguration(MapConfiguration config) { diff --git a/packages/google_maps_flutter/google_maps_flutter_android/lib/src/utils/cluster_manager_utils.dart b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/utils/cluster_manager_utils.dart new file mode 100644 index 000000000000..cdb1d2c9244a --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/lib/src/utils/cluster_manager_utils.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 1f2a3be877c1..565f92ae596c 100644 --- a/packages/google_maps_flutter/google_maps_flutter_android/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_android/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_android description: Android implementation of the google_maps_flutter plugin. repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.7.0 +version: 2.8.0 environment: sdk: ^3.1.0 @@ -21,7 +21,7 @@ dependencies: flutter: sdk: flutter flutter_plugin_android_lifecycle: ^2.0.1 - google_maps_flutter_platform_interface: ^2.5.0 + google_maps_flutter_platform_interface: ^2.6.0 stream_transform: ^2.0.0 dev_dependencies: diff --git a/packages/google_maps_flutter/google_maps_flutter_android/test/cluster_manager_utils_test.dart b/packages/google_maps_flutter/google_maps_flutter_android/test/cluster_manager_utils_test.dart new file mode 100644 index 000000000000..56963ddbdd91 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_android/test/cluster_manager_utils_test.dart @@ -0,0 +1,59 @@ +// 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_test/flutter_test.dart'; +import 'package:google_maps_flutter_android/src/utils/cluster_manager_utils.dart'; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +void main() { + test('serializeClusterManagerUpdates', () async { + const ClusterManagerId clusterManagerId1 = ClusterManagerId('cm1'); + const ClusterManagerId clusterManagerId2 = ClusterManagerId('cm2'); + + const ClusterManager clusterManager1 = ClusterManager( + clusterManagerId: clusterManagerId1, + ); + const ClusterManager clusterManager2 = ClusterManager( + clusterManagerId: clusterManagerId2, + ); + + final Set clusterManagersSet1 = {}; + final Set clusterManagersSet2 = { + clusterManager1, + clusterManager2 + }; + final Set clusterManagersSet3 = { + clusterManager1 + }; + + final ClusterManagerUpdates clusterManagerUpdates1 = + ClusterManagerUpdates.from(clusterManagersSet1, clusterManagersSet2); + final Map serializedData1 = + serializeClusterManagerUpdates(clusterManagerUpdates1) + as Map; + expect(serializedData1['clusterManagersToAdd'], isNotNull); + final List clusterManagersToAdd1 = + serializedData1['clusterManagersToAdd']! as List; + expect(clusterManagersToAdd1.length, 2); + expect(serializedData1['clusterManagerIdsToRemove'], isNotNull); + final List clusterManagersToRemove1 = + serializedData1['clusterManagerIdsToRemove']! as List; + expect(clusterManagersToRemove1.length, 0); + + final ClusterManagerUpdates clusterManagerUpdates2 = + ClusterManagerUpdates.from(clusterManagersSet2, clusterManagersSet3); + serializeClusterManagerUpdates(clusterManagerUpdates2); + final Map serializedData2 = + serializeClusterManagerUpdates(clusterManagerUpdates2) + as Map; + expect(serializedData2['clusterManagersToAdd'], isNotNull); + final List clusterManagersToAdd2 = + serializedData2['clusterManagersToAdd']! as List; + expect(clusterManagersToAdd2.length, 0); + expect(serializedData1['clusterManagerIdsToRemove'], isNotNull); + final List clusterManagersToRemove2 = + serializedData2['clusterManagerIdsToRemove']! as List; + expect(clusterManagersToRemove2.length, 1); + expect(clusterManagersToRemove2.first as String, equals('cm2')); + }); +}