Skip to content

Commit

Permalink
[google_maps_flutter_android] Add marker clustering support (flutter#…
Browse files Browse the repository at this point in the history
…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: flutter#4319
and sequel PR for: flutter#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
  • Loading branch information
jokerttu committed Apr 30, 2024
1 parent 9aa04eb commit bc6c186
Show file tree
Hide file tree
Showing 28 changed files with 1,810 additions and 67 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@ Anton Borries <mail@antonborri.es>
Alex Li <google@alexv525.com>
Rahul Raj <64.rahulraj@gmail.com>
Taha Tesser <tesser@gmail.com>
Joonas Kerttula <joonas.kerttula@codemate.com>
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2.8.0

* Adds support for marker clustering.

## 2.7.0

* Adds support for `MapConfiguration.style`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MarkerBuilder> {
@NonNull private final Context context;
@NonNull private final HashMap<String, ClusterManager<MarkerBuilder>> clusterManagerIdToManager;
@NonNull private final MethodChannel methodChannel;
@Nullable private MarkerManager markerManager;
@Nullable private GoogleMap googleMap;

@Nullable
private ClusterManager.OnClusterItemClickListener<MarkerBuilder> clusterItemClickListener;

@Nullable
private ClusterManagersController.OnClusterItemRendered<MarkerBuilder>
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<MarkerBuilder> listener) {
clusterItemClickListener = listener;
initListenersForClusterManagers();
}

void setClusterItemRenderedListener(
@Nullable ClusterManagersController.OnClusterItemRendered<MarkerBuilder> listener) {
clusterItemRenderedListener = listener;
}

private void initListenersForClusterManagers() {
for (Map.Entry<String, ClusterManager<MarkerBuilder>> entry :
clusterManagerIdToManager.entrySet()) {
initListenersForClusterManager(entry.getValue(), this, clusterItemClickListener);
}
}

private void initListenersForClusterManager(
ClusterManager<MarkerBuilder> clusterManager,
@Nullable ClusterManager.OnClusterClickListener<MarkerBuilder> clusterClickListener,
@Nullable ClusterManager.OnClusterItemClickListener<MarkerBuilder> clusterItemClickListener) {
clusterManager.setOnClusterClickListener(clusterClickListener);
clusterManager.setOnClusterItemClickListener(clusterItemClickListener);
}

/** Adds new ClusterManagers to the controller. */
void addClusterManagers(@NonNull List<Object> 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<MarkerBuilder> clusterManager =
new ClusterManager<MarkerBuilder>(context, googleMap, markerManager);
ClusterRenderer<MarkerBuilder> clusterRenderer =
new ClusterRenderer<MarkerBuilder>(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<Object> 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<MarkerBuilder> 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<MarkerBuilder> 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<MarkerBuilder> 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<String, Object> clusterMap = (Map<String, Object>) 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<MarkerBuilder> clusterManager = clusterManagerIdToManager.get(clusterManagerId);
if (clusterManager == null) {
result.error(
"Invalid clusterManagerId",
"getClusters called with invalid clusterManagerId:" + clusterManagerId,
null);
return;
}

final Set<? extends Cluster<MarkerBuilder>> clusters =
clusterManager.getAlgorithm().getClusters(googleMap.getCameraPosition().zoom);
result.success(Convert.clustersToJson(clusterManagerId, clusters));
}

@Override
public void onCameraIdle() {
for (Map.Entry<String, ClusterManager<MarkerBuilder>> entry :
clusterManagerIdToManager.entrySet()) {
entry.getValue().onCameraIdle();
}
}

@Override
public boolean onClusterClick(Cluster<MarkerBuilder> 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<T extends MarkerBuilder> extends DefaultClusterRenderer<T> {
private final ClusterManagersController clusterManagersController;

public ClusterRenderer(
Context context,
GoogleMap map,
ClusterManager<T> 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<T extends ClusterItem> {
void onClusterItemRendered(@NonNull T item, @NonNull Marker marker);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -160,7 +162,7 @@ static Object cameraPositionToJson(CameraPosition position) {
return data;
}

static Object latlngBoundsToJson(LatLngBounds latLngBounds) {
static Object latLngBoundsToJson(LatLngBounds latLngBounds) {
final Map<String, Object> arguments = new HashMap<>(2);
arguments.put("southwest", latLngToJson(latLngBounds.southwest));
arguments.put("northeast", latLngToJson(latLngBounds.northeast));
Expand Down Expand Up @@ -221,6 +223,44 @@ static Object latLngToJson(LatLng latLng) {
return Arrays.asList(latLng.latitude, latLng.longitude);
}

static Object clustersToJson(
String clusterManagerId, Set<? extends Cluster<MarkerBuilder>> clusters) {
List<Object> data = new ArrayList<>(clusters.size());
for (Cluster<MarkerBuilder> cluster : clusters) {
data.add(clusterToJson(clusterManagerId, cluster));
}
return data;
}

static Object clusterToJson(String clusterManagerId, Cluster<MarkerBuilder> 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<String, Object> 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)));
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Loading

0 comments on commit bc6c186

Please sign in to comment.