Skip to content

Commit bc6c186

Browse files
authored
[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: #4319 and sequel PR for: #6158 Containing only changes to `google_maps_flutter_android` package. Follow up PR will hold the app-facing plugin implementation. Linked issue: flutter/flutter#26863
1 parent 9aa04eb commit bc6c186

28 files changed

+1810
-67
lines changed

packages/google_maps_flutter/google_maps_flutter_android/AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,4 @@ Anton Borries <[email protected]>
6565
6666
Rahul Raj <[email protected]>
6767
Taha Tesser <[email protected]>
68+
Joonas Kerttula <[email protected]>

packages/google_maps_flutter/google_maps_flutter_android/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 2.8.0
2+
3+
* Adds support for marker clustering.
4+
15
## 2.7.0
26

37
* Adds support for `MapConfiguration.style`.

packages/google_maps_flutter/google_maps_flutter_android/android/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ android {
4141
dependencies {
4242
implementation "androidx.annotation:annotation:1.7.0"
4343
implementation 'com.google.android.gms:play-services-maps:18.2.0'
44+
implementation 'com.google.maps.android:android-maps-utils:3.6.0'
4445
androidTestImplementation 'androidx.test:runner:1.2.0'
4546
androidTestImplementation 'androidx.test:rules:1.4.0'
4647
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
package io.flutter.plugins.googlemaps;
6+
7+
import android.content.Context;
8+
import androidx.annotation.NonNull;
9+
import androidx.annotation.Nullable;
10+
import com.google.android.gms.maps.GoogleMap;
11+
import com.google.android.gms.maps.model.Marker;
12+
import com.google.android.gms.maps.model.MarkerOptions;
13+
import com.google.maps.android.clustering.Cluster;
14+
import com.google.maps.android.clustering.ClusterItem;
15+
import com.google.maps.android.clustering.ClusterManager;
16+
import com.google.maps.android.clustering.view.DefaultClusterRenderer;
17+
import com.google.maps.android.collections.MarkerManager;
18+
import io.flutter.plugin.common.MethodChannel;
19+
import java.util.HashMap;
20+
import java.util.List;
21+
import java.util.Map;
22+
import java.util.Set;
23+
24+
/**
25+
* Controls cluster managers and exposes interfaces for adding and removing cluster items for
26+
* specific cluster managers.
27+
*/
28+
class ClusterManagersController
29+
implements GoogleMap.OnCameraIdleListener,
30+
ClusterManager.OnClusterClickListener<MarkerBuilder> {
31+
@NonNull private final Context context;
32+
@NonNull private final HashMap<String, ClusterManager<MarkerBuilder>> clusterManagerIdToManager;
33+
@NonNull private final MethodChannel methodChannel;
34+
@Nullable private MarkerManager markerManager;
35+
@Nullable private GoogleMap googleMap;
36+
37+
@Nullable
38+
private ClusterManager.OnClusterItemClickListener<MarkerBuilder> clusterItemClickListener;
39+
40+
@Nullable
41+
private ClusterManagersController.OnClusterItemRendered<MarkerBuilder>
42+
clusterItemRenderedListener;
43+
44+
ClusterManagersController(MethodChannel methodChannel, Context context) {
45+
this.clusterManagerIdToManager = new HashMap<>();
46+
this.context = context;
47+
this.methodChannel = methodChannel;
48+
}
49+
50+
void init(GoogleMap googleMap, MarkerManager markerManager) {
51+
this.markerManager = markerManager;
52+
this.googleMap = googleMap;
53+
}
54+
55+
void setClusterItemClickListener(
56+
@Nullable ClusterManager.OnClusterItemClickListener<MarkerBuilder> listener) {
57+
clusterItemClickListener = listener;
58+
initListenersForClusterManagers();
59+
}
60+
61+
void setClusterItemRenderedListener(
62+
@Nullable ClusterManagersController.OnClusterItemRendered<MarkerBuilder> listener) {
63+
clusterItemRenderedListener = listener;
64+
}
65+
66+
private void initListenersForClusterManagers() {
67+
for (Map.Entry<String, ClusterManager<MarkerBuilder>> entry :
68+
clusterManagerIdToManager.entrySet()) {
69+
initListenersForClusterManager(entry.getValue(), this, clusterItemClickListener);
70+
}
71+
}
72+
73+
private void initListenersForClusterManager(
74+
ClusterManager<MarkerBuilder> clusterManager,
75+
@Nullable ClusterManager.OnClusterClickListener<MarkerBuilder> clusterClickListener,
76+
@Nullable ClusterManager.OnClusterItemClickListener<MarkerBuilder> clusterItemClickListener) {
77+
clusterManager.setOnClusterClickListener(clusterClickListener);
78+
clusterManager.setOnClusterItemClickListener(clusterItemClickListener);
79+
}
80+
81+
/** Adds new ClusterManagers to the controller. */
82+
void addClusterManagers(@NonNull List<Object> clusterManagersToAdd) {
83+
for (Object clusterToAdd : clusterManagersToAdd) {
84+
addClusterManager(clusterToAdd);
85+
}
86+
}
87+
88+
/** Adds new ClusterManager to the controller. */
89+
void addClusterManager(Object clusterManagerData) {
90+
String clusterManagerId = getClusterManagerId(clusterManagerData);
91+
if (clusterManagerId == null) {
92+
throw new IllegalArgumentException("clusterManagerId was null");
93+
}
94+
ClusterManager<MarkerBuilder> clusterManager =
95+
new ClusterManager<MarkerBuilder>(context, googleMap, markerManager);
96+
ClusterRenderer<MarkerBuilder> clusterRenderer =
97+
new ClusterRenderer<MarkerBuilder>(context, googleMap, clusterManager, this);
98+
clusterManager.setRenderer(clusterRenderer);
99+
initListenersForClusterManager(clusterManager, this, clusterItemClickListener);
100+
clusterManagerIdToManager.put(clusterManagerId, clusterManager);
101+
}
102+
103+
/** Removes ClusterManagers by given cluster manager IDs from the controller. */
104+
public void removeClusterManagers(@NonNull List<Object> clusterManagerIdsToRemove) {
105+
for (Object rawClusterManagerId : clusterManagerIdsToRemove) {
106+
if (rawClusterManagerId == null) {
107+
continue;
108+
}
109+
String clusterManagerId = (String) rawClusterManagerId;
110+
removeClusterManager(clusterManagerId);
111+
}
112+
}
113+
114+
/**
115+
* Removes the ClusterManagers by the given cluster manager ID from the controller. The reference
116+
* to this cluster manager is removed from the clusterManagerIdToManager and it will be garbage
117+
* collected later.
118+
*/
119+
private void removeClusterManager(Object clusterManagerId) {
120+
// Remove the cluster manager from the hash map to allow it to be garbage collected.
121+
final ClusterManager<MarkerBuilder> clusterManager =
122+
clusterManagerIdToManager.remove(clusterManagerId);
123+
if (clusterManager == null) {
124+
return;
125+
}
126+
initListenersForClusterManager(clusterManager, null, null);
127+
clusterManager.clearItems();
128+
clusterManager.cluster();
129+
}
130+
131+
/** Adds item to the ClusterManager it belongs to. */
132+
public void addItem(MarkerBuilder item) {
133+
ClusterManager<MarkerBuilder> clusterManager =
134+
clusterManagerIdToManager.get(item.clusterManagerId());
135+
if (clusterManager != null) {
136+
clusterManager.addItem(item);
137+
clusterManager.cluster();
138+
}
139+
}
140+
141+
/** Removes item from the ClusterManager it belongs to. */
142+
public void removeItem(MarkerBuilder item) {
143+
ClusterManager<MarkerBuilder> clusterManager =
144+
clusterManagerIdToManager.get(item.clusterManagerId());
145+
if (clusterManager != null) {
146+
clusterManager.removeItem(item);
147+
clusterManager.cluster();
148+
}
149+
}
150+
151+
/** Called when ClusterRenderer has rendered new visible marker to the map. */
152+
void onClusterItemRendered(@NonNull MarkerBuilder item, @NonNull Marker marker) {
153+
// If map is being disposed, clusterItemRenderedListener might have been cleared and
154+
// set to null.
155+
if (clusterItemRenderedListener != null) {
156+
clusterItemRenderedListener.onClusterItemRendered(item, marker);
157+
}
158+
}
159+
160+
/** Reads clusterManagerId from object data. */
161+
@SuppressWarnings("unchecked")
162+
private static String getClusterManagerId(Object clusterManagerData) {
163+
Map<String, Object> clusterMap = (Map<String, Object>) clusterManagerData;
164+
// Ref: google_maps_flutter_platform_interface/lib/src/types/cluster_manager.dart ClusterManager.toJson() method.
165+
return (String) clusterMap.get("clusterManagerId");
166+
}
167+
168+
/**
169+
* Requests all current clusters from the algorithm of the requested ClusterManager and converts
170+
* them to result response.
171+
*/
172+
public void getClustersWithClusterManagerId(
173+
String clusterManagerId, MethodChannel.Result result) {
174+
ClusterManager<MarkerBuilder> clusterManager = clusterManagerIdToManager.get(clusterManagerId);
175+
if (clusterManager == null) {
176+
result.error(
177+
"Invalid clusterManagerId",
178+
"getClusters called with invalid clusterManagerId:" + clusterManagerId,
179+
null);
180+
return;
181+
}
182+
183+
final Set<? extends Cluster<MarkerBuilder>> clusters =
184+
clusterManager.getAlgorithm().getClusters(googleMap.getCameraPosition().zoom);
185+
result.success(Convert.clustersToJson(clusterManagerId, clusters));
186+
}
187+
188+
@Override
189+
public void onCameraIdle() {
190+
for (Map.Entry<String, ClusterManager<MarkerBuilder>> entry :
191+
clusterManagerIdToManager.entrySet()) {
192+
entry.getValue().onCameraIdle();
193+
}
194+
}
195+
196+
@Override
197+
public boolean onClusterClick(Cluster<MarkerBuilder> cluster) {
198+
if (cluster.getSize() > 0) {
199+
MarkerBuilder[] builders = cluster.getItems().toArray(new MarkerBuilder[0]);
200+
String clusterManagerId = builders[0].clusterManagerId();
201+
methodChannel.invokeMethod("cluster#onTap", Convert.clusterToJson(clusterManagerId, cluster));
202+
}
203+
204+
// Return false to allow the default behavior of the cluster click event to occur.
205+
return false;
206+
}
207+
208+
/**
209+
* ClusterRenderer builds marker options for new markers to be rendered to the map. After cluster
210+
* item (marker) is rendered, it is sent to the listeners for control.
211+
*/
212+
private static class ClusterRenderer<T extends MarkerBuilder> extends DefaultClusterRenderer<T> {
213+
private final ClusterManagersController clusterManagersController;
214+
215+
public ClusterRenderer(
216+
Context context,
217+
GoogleMap map,
218+
ClusterManager<T> clusterManager,
219+
ClusterManagersController clusterManagersController) {
220+
super(context, map, clusterManager);
221+
this.clusterManagersController = clusterManagersController;
222+
}
223+
224+
@Override
225+
protected void onBeforeClusterItemRendered(
226+
@NonNull T item, @NonNull MarkerOptions markerOptions) {
227+
// Builds new markerOptions for new marker created by the ClusterRenderer under
228+
// ClusterManager.
229+
item.update(markerOptions);
230+
}
231+
232+
@Override
233+
protected void onClusterItemRendered(@NonNull T item, @NonNull Marker marker) {
234+
super.onClusterItemRendered(item, marker);
235+
clusterManagersController.onClusterItemRendered(item, marker);
236+
}
237+
}
238+
239+
/** Interface for handling situations where clusterManager adds new visible marker to the map. */
240+
public interface OnClusterItemRendered<T extends ClusterItem> {
241+
void onClusterItemRendered(@NonNull T item, @NonNull Marker marker);
242+
}
243+
}

packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/Convert.java

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@
2525
import com.google.android.gms.maps.model.RoundCap;
2626
import com.google.android.gms.maps.model.SquareCap;
2727
import com.google.android.gms.maps.model.Tile;
28+
import com.google.maps.android.clustering.Cluster;
2829
import java.util.ArrayList;
2930
import java.util.Arrays;
3031
import java.util.HashMap;
3132
import java.util.List;
3233
import java.util.Map;
34+
import java.util.Set;
3335

3436
/** Conversions between JSON-like values and GoogleMaps data types. */
3537
class Convert {
@@ -160,7 +162,7 @@ static Object cameraPositionToJson(CameraPosition position) {
160162
return data;
161163
}
162164

163-
static Object latlngBoundsToJson(LatLngBounds latLngBounds) {
165+
static Object latLngBoundsToJson(LatLngBounds latLngBounds) {
164166
final Map<String, Object> arguments = new HashMap<>(2);
165167
arguments.put("southwest", latLngToJson(latLngBounds.southwest));
166168
arguments.put("northeast", latLngToJson(latLngBounds.northeast));
@@ -221,6 +223,44 @@ static Object latLngToJson(LatLng latLng) {
221223
return Arrays.asList(latLng.latitude, latLng.longitude);
222224
}
223225

226+
static Object clustersToJson(
227+
String clusterManagerId, Set<? extends Cluster<MarkerBuilder>> clusters) {
228+
List<Object> data = new ArrayList<>(clusters.size());
229+
for (Cluster<MarkerBuilder> cluster : clusters) {
230+
data.add(clusterToJson(clusterManagerId, cluster));
231+
}
232+
return data;
233+
}
234+
235+
static Object clusterToJson(String clusterManagerId, Cluster<MarkerBuilder> cluster) {
236+
int clusterSize = cluster.getSize();
237+
LatLngBounds.Builder latLngBoundsBuilder = LatLngBounds.builder();
238+
239+
String[] markerIds = new String[clusterSize];
240+
MarkerBuilder[] markerBuilders = cluster.getItems().toArray(new MarkerBuilder[clusterSize]);
241+
242+
// Loops though cluster items and reads markers position for the LatLngBounds builder
243+
// and also builds list of marker ids on the cluster.
244+
for (int i = 0; i < clusterSize; i++) {
245+
MarkerBuilder markerBuilder = markerBuilders[i];
246+
latLngBoundsBuilder.include(markerBuilder.getPosition());
247+
markerIds[i] = markerBuilder.markerId();
248+
}
249+
250+
Object position = latLngToJson(cluster.getPosition());
251+
Object bounds = latLngBoundsToJson(latLngBoundsBuilder.build());
252+
253+
final Map<String, Object> data = new HashMap<>(4);
254+
255+
// For dart side implementation see parseCluster method at
256+
// packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_maps_flutter_android.dart
257+
data.put("clusterManagerId", clusterManagerId);
258+
data.put("position", position);
259+
data.put("bounds", bounds);
260+
data.put("markerIds", Arrays.asList(markerIds));
261+
return data;
262+
}
263+
224264
static LatLng toLatLng(Object o) {
225265
final List<?> data = toList(o);
226266
return new LatLng(toDouble(data.get(0)), toDouble(data.get(1)));
@@ -383,8 +423,8 @@ static void interpretGoogleMapOptions(Object o, GoogleMapOptionsSink sink) {
383423
}
384424
}
385425

386-
/** Returns the dartMarkerId of the interpreted marker. */
387-
static String interpretMarkerOptions(Object o, MarkerOptionsSink sink) {
426+
/** Set the options in the given object to marker options sink. */
427+
static void interpretMarkerOptions(Object o, MarkerOptionsSink sink) {
388428
final Map<?, ?> data = toMap(o);
389429
final Object alpha = data.get("alpha");
390430
if (alpha != null) {
@@ -432,12 +472,6 @@ static String interpretMarkerOptions(Object o, MarkerOptionsSink sink) {
432472
if (zIndex != null) {
433473
sink.setZIndex(toFloat(zIndex));
434474
}
435-
final String markerId = (String) data.get("markerId");
436-
if (markerId == null) {
437-
throw new IllegalArgumentException("markerId was null");
438-
} else {
439-
return markerId;
440-
}
441475
}
442476

443477
private static void interpretInfoWindowOptions(

packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapBuilder.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class GoogleMapBuilder implements GoogleMapOptionsSink {
2323
private boolean trafficEnabled = false;
2424
private boolean buildingsEnabled = true;
2525
private Object initialMarkers;
26+
private Object initialClusterManagers;
2627
private Object initialPolygons;
2728
private Object initialPolylines;
2829
private Object initialCircles;
@@ -44,6 +45,7 @@ GoogleMapController build(
4445
controller.setTrafficEnabled(trafficEnabled);
4546
controller.setBuildingsEnabled(buildingsEnabled);
4647
controller.setTrackCameraPosition(trackCameraPosition);
48+
controller.setInitialClusterManagers(initialClusterManagers);
4749
controller.setInitialMarkers(initialMarkers);
4850
controller.setInitialPolygons(initialPolygons);
4951
controller.setInitialPolylines(initialPolylines);
@@ -162,6 +164,11 @@ public void setInitialMarkers(Object initialMarkers) {
162164
this.initialMarkers = initialMarkers;
163165
}
164166

167+
@Override
168+
public void setInitialClusterManagers(Object initialClusterManagers) {
169+
this.initialClusterManagers = initialClusterManagers;
170+
}
171+
165172
@Override
166173
public void setInitialPolygons(Object initialPolygons) {
167174
this.initialPolygons = initialPolygons;

0 commit comments

Comments
 (0)