diff --git a/demo/build.gradle b/demo/build.gradle index 1300b4a27..1ec5a9527 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -63,18 +63,17 @@ android { dependencies { // [START_EXCLUDE silent] - // implementation project(':library') implementation 'androidx.appcompat:appcompat:1.4.0-alpha03' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' // GMS gmsImplementation 'com.google.android.gms:play-services-maps:17.0.1' - gmsImplementation 'com.google.maps.android:android-maps-utils:2.2.6' + gmsImplementation project(':library') // V3 v3Implementation 'com.google.android.libraries.maps:maps:3.1.0-beta' v3Implementation 'com.android.volley:volley:1.2.1' // TODO - Remove this after Maps SDK v3 beta includes Volley versions on Maven Central - v3Implementation 'com.google.maps.android:android-maps-utils-v3:2.2.6' + v3Implementation project(':library-v3') v3Implementation 'androidx.multidex:multidex:2.0.1' implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" diff --git a/demo/src/gms/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java b/demo/src/gms/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java index 61abb01b5..6a27fd9fe 100644 --- a/demo/src/gms/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java +++ b/demo/src/gms/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java @@ -250,4 +250,4 @@ private LatLng position() { private double random(double min, double max) { return mRandom.nextDouble() * (max - min) + min; } -} +} \ No newline at end of file diff --git a/demo/src/gms/java/com/google/maps/android/utils/demo/MainActivity.java b/demo/src/gms/java/com/google/maps/android/utils/demo/MainActivity.java index cc22b6e23..fba59bbde 100644 --- a/demo/src/gms/java/com/google/maps/android/utils/demo/MainActivity.java +++ b/demo/src/gms/java/com/google/maps/android/utils/demo/MainActivity.java @@ -45,6 +45,7 @@ protected void onCreate(Bundle savedInstanceState) { addDemo("Clustering: 2K markers", BigClusteringDemoActivity.class); addDemo("Clustering: 20K only visible markers", VisibleClusteringDemoActivity.class); addDemo("Clustering: ViewModel", ClusteringViewModelDemoActivity.class); + addDemo("Clustering: Force on Zoom", ZoomClusteringDemoActivity.class); addDemo("PolyUtil.decode", PolyDecodeDemoActivity.class); addDemo("PolyUtil.simplify", PolySimplifyDemoActivity.class); addDemo("IconGenerator", IconGeneratorDemoActivity.class); diff --git a/demo/src/gms/java/com/google/maps/android/utils/demo/ZoomClusteringDemoActivity.java b/demo/src/gms/java/com/google/maps/android/utils/demo/ZoomClusteringDemoActivity.java new file mode 100644 index 000000000..83c5a3b29 --- /dev/null +++ b/demo/src/gms/java/com/google/maps/android/utils/demo/ZoomClusteringDemoActivity.java @@ -0,0 +1,197 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.utils.demo; + +import android.content.Context; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.LatLngBounds; +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.utils.demo.model.MyItem; + +import java.util.Set; + +/** + * Demonstrates how to force re-rendering of clusters even when the contents don't change. For + * example, when changing zoom levels. + */ +public class ZoomClusteringDemoActivity extends BaseDemoActivity implements ClusterManager.OnClusterClickListener, ClusterManager.OnClusterInfoWindowClickListener, ClusterManager.OnClusterItemClickListener, ClusterManager.OnClusterItemInfoWindowClickListener { + + @Override + public boolean onClusterClick(Cluster cluster) { + // Show a toast with some info when the cluster is clicked. + String title = cluster.getItems().iterator().next().getTitle(); + Toast.makeText(this, cluster.getSize() + " (including " + title + ")", Toast.LENGTH_SHORT).show(); + + // Zoom in the cluster. Need to create LatLngBounds and including all the cluster items + // inside of bounds, then animate to center of the bounds. + + // Create the builder to collect all essential cluster items for the bounds. + LatLngBounds.Builder builder = LatLngBounds.builder(); + for (ClusterItem item : cluster.getItems()) { + builder.include(item.getPosition()); + } + + // Animate camera to the bounds + try { + getMap().animateCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), 100)); + } catch (Exception e) { + e.printStackTrace(); + } + + return true; + } + + @Override + public void onClusterInfoWindowClick(Cluster cluster) { + // Does nothing, but you could go to a list of the users. + } + + @Override + public boolean onClusterItemClick(MyItem item) { + // Does nothing, but you could go into a user's profile page, for example. + return false; + } + + @Override + public void onClusterItemInfoWindowClick(MyItem item) { + // Does nothing, but you could go into a user's profile page, for example. + } + + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(18.528146, 73.797726), 9.5f)); + } + + ClusterManager clusterManager = new ClusterManager<>(this, getMap()); + getMap().setOnCameraIdleListener(clusterManager); + + // Initialize renderer + ZoomBasedRenderer renderer = new ZoomBasedRenderer(this, getMap(), clusterManager); + clusterManager.setRenderer(renderer); + + // Set click listeners + clusterManager.setOnClusterClickListener(this); + clusterManager.setOnClusterInfoWindowClickListener(this); + clusterManager.setOnClusterItemClickListener(this); + clusterManager.setOnClusterItemInfoWindowClickListener(this); + + String snippet = "This item wouldn't have changed to a marker if we didn't override shouldRenderAsCluster() AND shouldRender()"; + + // Add items + clusterManager.addItem(new MyItem(18.528146, 73.797726, "Loc1", snippet)); + clusterManager.addItem(new MyItem(18.545723, 73.917202, "Loc2", snippet)); + } + + private class ZoomBasedRenderer extends DefaultClusterRenderer implements GoogleMap.OnCameraIdleListener { + private Float zoom = 15f; + private Float oldZoom; + private static final float ZOOM_THRESHOLD = 12f; + + public ZoomBasedRenderer(Context context, GoogleMap map, ClusterManager clusterManager) { + super(context, map, clusterManager); + } + + /** + * The {@link ClusterManager} will call the {@link this.onCameraIdle()} implementation of + * any Renderer that implements {@link GoogleMap.OnCameraIdleListener} before + * clustering and rendering takes place. This allows us to capture metrics that may be + * useful for clustering, such as the zoom level. + */ + @Override + public void onCameraIdle() { + // Remember the previous zoom level, capture the new zoom level. + oldZoom = zoom; + zoom = getMap().getCameraPosition().zoom; + } + + /** + * You can override this method to control when the cluster manager renders a group of + * items as a cluster (vs. as a set of individual markers). + *

+ * In this case, we want single markers to show up as a cluster when zoomed out, but + * individual markers when zoomed in. + * + * @param cluster cluster to examine for rendering + * @return true when zoom level is less than the threshold (show as cluster when zoomed out), + * and false when the the zoom level is more than or equal to the threshold (show as marker + * when zoomed in) + */ + @Override + protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { + // Show as cluster when zoom is less than the threshold, otherwise show as marker + return zoom < ZOOM_THRESHOLD; + } + + /** + * You can override this method to control optimizations surrounding rendering. The default + * implementation in the library simply checks if the new clusters are equal to the old + * clusters, and if so, it returns false to avoid re-rendering the same content. + *

+ * However, in our case we need to change this behavior. As defined in + * {@link this.shouldRenderAsCluster()}, we want an item to render as a cluster above a + * certain zoom level and as a marker below a certain zoom level even if the contents of + * the clusters themselves did not change. In this case, we need to override this method + * to implement this new optimization behavior. + * + * Note that always returning true from this method could potentially have negative + * performance implications as clusters will be re-rendered on each pass even if they don't + * change. + * + * @param oldClusters The clusters from the previous iteration of the clustering algorithm + * @param newClusters The clusters from the current iteration of the clustering algorithm + * @return true if the new clusters should be rendered on the map, and false if they should + * not. + */ + @Override + protected boolean shouldRender(@NonNull Set> oldClusters, @NonNull Set> newClusters) { + if (crossedZoomThreshold(oldZoom, zoom)) { + // Render when the zoom level crosses the threshold, even if the clusters don't change + return true; + } else { + // If clusters didn't change, skip render for optimization using default super implementation + return super.shouldRender(oldClusters, newClusters); + } + } + + /** + * Returns true if the transition between the two zoom levels crossed a defined threshold, + * false if it did not. + * + * @param oldZoom zoom level from the previous time the camera stopped moving + * @param newZoom zoom level from the most recent time the camera stopped moving + * @return true if the transition between the two zoom levels crossed a defined threshold, + * false if it did not. + */ + private boolean crossedZoomThreshold(Float oldZoom, Float newZoom) { + if (oldZoom == null || newZoom == null) { + return true; + } + return (oldZoom < ZOOM_THRESHOLD && newZoom > ZOOM_THRESHOLD) || + (oldZoom > ZOOM_THRESHOLD && newZoom < ZOOM_THRESHOLD); + } + } +} diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index f253c6897..6bb2bacd0 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -59,6 +59,7 @@ + diff --git a/demo/src/v3/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java b/demo/src/v3/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java index a606db300..eafb5241a 100644 --- a/demo/src/v3/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java +++ b/demo/src/v3/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java @@ -257,4 +257,4 @@ private LatLng position() { private double random(double min, double max) { return mRandom.nextDouble() * (max - min) + min; } -} +} \ No newline at end of file diff --git a/demo/src/v3/java/com/google/maps/android/utils/demo/MainActivity.java b/demo/src/v3/java/com/google/maps/android/utils/demo/MainActivity.java index 33db20aaa..84778d1d9 100644 --- a/demo/src/v3/java/com/google/maps/android/utils/demo/MainActivity.java +++ b/demo/src/v3/java/com/google/maps/android/utils/demo/MainActivity.java @@ -52,6 +52,7 @@ protected void onCreate(Bundle savedInstanceState) { addDemo("Clustering: 2K markers", BigClusteringDemoActivity.class); addDemo("Clustering: 20K only visible markers", VisibleClusteringDemoActivity.class); addDemo("Clustering: ViewModel", ClusteringViewModelDemoActivity.class); + addDemo("Clustering: Force on Zoom", ZoomClusteringDemoActivity.class); addDemo("PolyUtil.decode", PolyDecodeDemoActivity.class); addDemo("PolyUtil.simplify", PolySimplifyDemoActivity.class); addDemo("IconGenerator", IconGeneratorDemoActivity.class); diff --git a/demo/src/v3/java/com/google/maps/android/utils/demo/ZoomClusteringDemoActivity.java b/demo/src/v3/java/com/google/maps/android/utils/demo/ZoomClusteringDemoActivity.java new file mode 100644 index 000000000..c6adb83e2 --- /dev/null +++ b/demo/src/v3/java/com/google/maps/android/utils/demo/ZoomClusteringDemoActivity.java @@ -0,0 +1,204 @@ +/** + * DO NOT EDIT THIS FILE. + * + * This source code was autogenerated from source code within the `demo/src/gms` directory + * and is not intended for modifications. If any edits should be made, please do so in the + * corresponding file under the `demo/src/gms` directory. + */ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.utils.demo; + +import android.content.Context; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import com.google.android.libraries.maps.CameraUpdateFactory; +import com.google.android.libraries.maps.GoogleMap; +import com.google.android.libraries.maps.model.LatLng; +import com.google.android.libraries.maps.model.LatLngBounds; +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.utils.demo.model.MyItem; + +import java.util.Set; + +/** + * Demonstrates how to force re-rendering of clusters even when the contents don't change. For + * example, when changing zoom levels. + */ +public class ZoomClusteringDemoActivity extends BaseDemoActivity implements ClusterManager.OnClusterClickListener, ClusterManager.OnClusterInfoWindowClickListener, ClusterManager.OnClusterItemClickListener, ClusterManager.OnClusterItemInfoWindowClickListener { + + @Override + public boolean onClusterClick(Cluster cluster) { + // Show a toast with some info when the cluster is clicked. + String title = cluster.getItems().iterator().next().getTitle(); + Toast.makeText(this, cluster.getSize() + " (including " + title + ")", Toast.LENGTH_SHORT).show(); + + // Zoom in the cluster. Need to create LatLngBounds and including all the cluster items + // inside of bounds, then animate to center of the bounds. + + // Create the builder to collect all essential cluster items for the bounds. + LatLngBounds.Builder builder = LatLngBounds.builder(); + for (ClusterItem item : cluster.getItems()) { + builder.include(item.getPosition()); + } + + // Animate camera to the bounds + try { + getMap().animateCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), 100)); + } catch (Exception e) { + e.printStackTrace(); + } + + return true; + } + + @Override + public void onClusterInfoWindowClick(Cluster cluster) { + // Does nothing, but you could go to a list of the users. + } + + @Override + public boolean onClusterItemClick(MyItem item) { + // Does nothing, but you could go into a user's profile page, for example. + return false; + } + + @Override + public void onClusterItemInfoWindowClick(MyItem item) { + // Does nothing, but you could go into a user's profile page, for example. + } + + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(18.528146, 73.797726), 9.5f)); + } + + ClusterManager clusterManager = new ClusterManager<>(this, getMap()); + getMap().setOnCameraIdleListener(clusterManager); + + // Initialize renderer + ZoomBasedRenderer renderer = new ZoomBasedRenderer(this, getMap(), clusterManager); + clusterManager.setRenderer(renderer); + + // Set click listeners + clusterManager.setOnClusterClickListener(this); + clusterManager.setOnClusterInfoWindowClickListener(this); + clusterManager.setOnClusterItemClickListener(this); + clusterManager.setOnClusterItemInfoWindowClickListener(this); + + String snippet = "This item wouldn't have changed to a marker if we didn't override shouldRenderAsCluster() AND shouldRender()"; + + // Add items + clusterManager.addItem(new MyItem(18.528146, 73.797726, "Loc1", snippet)); + clusterManager.addItem(new MyItem(18.545723, 73.917202, "Loc2", snippet)); + } + + private class ZoomBasedRenderer extends DefaultClusterRenderer implements GoogleMap.OnCameraIdleListener { + private Float zoom = 15f; + private Float oldZoom; + private static final float ZOOM_THRESHOLD = 12f; + + public ZoomBasedRenderer(Context context, GoogleMap map, ClusterManager clusterManager) { + super(context, map, clusterManager); + } + + /** + * The {@link ClusterManager} will call the {@link this.onCameraIdle()} implementation of + * any Renderer that implements {@link GoogleMap.OnCameraIdleListener} before + * clustering and rendering takes place. This allows us to capture metrics that may be + * useful for clustering, such as the zoom level. + */ + @Override + public void onCameraIdle() { + // Remember the previous zoom level, capture the new zoom level. + oldZoom = zoom; + zoom = getMap().getCameraPosition().zoom; + } + + /** + * You can override this method to control when the cluster manager renders a group of + * items as a cluster (vs. as a set of individual markers). + *

+ * In this case, we want single markers to show up as a cluster when zoomed out, but + * individual markers when zoomed in. + * + * @param cluster cluster to examine for rendering + * @return true when zoom level is less than the threshold (show as cluster when zoomed out), + * and false when the the zoom level is more than or equal to the threshold (show as marker + * when zoomed in) + */ + @Override + protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { + // Show as cluster when zoom is less than the threshold, otherwise show as marker + return zoom < ZOOM_THRESHOLD; + } + + /** + * You can override this method to control optimizations surrounding rendering. The default + * implementation in the library simply checks if the new clusters are equal to the old + * clusters, and if so, it returns false to avoid re-rendering the same content. + *

+ * However, in our case we need to change this behavior. As defined in + * {@link this.shouldRenderAsCluster()}, we want an item to render as a cluster above a + * certain zoom level and as a marker below a certain zoom level even if the contents of + * the clusters themselves did not change. In this case, we need to override this method + * to implement this new optimization behavior. + * + * Note that always returning true from this method could potentially have negative + * performance implications as clusters will be re-rendered on each pass even if they don't + * change. + * + * @param oldClusters The clusters from the previous iteration of the clustering algorithm + * @param newClusters The clusters from the current iteration of the clustering algorithm + * @return true if the new clusters should be rendered on the map, and false if they should + * not. + */ + @Override + protected boolean shouldRender(@NonNull Set> oldClusters, @NonNull Set> newClusters) { + if (crossedZoomThreshold(oldZoom, zoom)) { + // Render when the zoom level crosses the threshold, even if the clusters don't change + return true; + } else { + // If clusters didn't change, skip render for optimization + return !newClusters.equals(oldClusters); + } + } + + /** + * Returns true if the transition between the two zoom levels crossed a defined threshold, + * false if it did not. + * + * @param oldZoom zoom level from the previous time the camera stopped moving + * @param newZoom zoom level from the most recent time the camera stopped moving + * @return true if the transition between the two zoom levels crossed a defined threshold, + * false if it did not. + */ + private boolean crossedZoomThreshold(Float oldZoom, Float newZoom) { + if (oldZoom == null || newZoom == null) { + return true; + } + return (oldZoom < ZOOM_THRESHOLD && newZoom > ZOOM_THRESHOLD) || + (oldZoom > ZOOM_THRESHOLD && newZoom < ZOOM_THRESHOLD); + } + } +} diff --git a/library/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.java b/library/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.java index b56683c78..5854486be 100644 --- a/library/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.java +++ b/library/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.java @@ -356,6 +356,31 @@ protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { return cluster.getSize() >= mMinClusterSize; } + /** + * Determines if the new clusters should be rendered on the map, given the old clusters. This + * method is primarily for optimization of performance, and the default implementation simply + * checks if the new clusters are equal to the old clusters, and if so, it returns false. + * + * However, there are cases where you may want to re-render the clusters even if they didn't + * change. For example, if you want a cluster with one item to render as a cluster above + * a certain zoom level and as a marker below a certain zoom level (even if the contents of the + * clusters themselves did not change). In this case, you could check the zoom level in an + * implementation of this method and if that zoom level threshold is crossed return true, else + * {@code return super.shouldRender(oldClusters, newClusters)}. + * + * Note that always returning true from this method could potentially have negative performance + * implications as clusters will be re-rendered on each pass even if they don't change. + * + * @param oldClusters The clusters from the previous iteration of the clustering algorithm + * @param newClusters The clusters from the current iteration of the clustering algorithm + * @return true if the new clusters should be rendered on the map, and false if they should not. This + * method is primarily for optimization of performance, and the default implementation simply + * checks if the new clusters are equal to the old clusters, and if so, it returns false. + */ + protected boolean shouldRender(@NonNull Set> oldClusters, @NonNull Set> newClusters) { + return !newClusters.equals(oldClusters); + } + /** * Transforms the current view (represented by DefaultClusterRenderer.mClusters and DefaultClusterRenderer.mZoom) to a * new zoom level and set of clusters. @@ -405,7 +430,7 @@ public void setMapZoom(float zoom) { @SuppressLint("NewApi") public void run() { - if (clusters.equals(DefaultClusterRenderer.this.mClusters)) { + if (!shouldRender(immutableOf(DefaultClusterRenderer.this.mClusters), immutableOf(clusters))) { mCallback.run(); return; } @@ -550,6 +575,10 @@ public void setAnimation(boolean animate) { mAnimate = animate; } + private Set> immutableOf(Set> clusters) { + return clusters != null ? Collections.unmodifiableSet(clusters) : Collections.emptySet(); + } + private static double distanceSquared(Point a, Point b) { return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y); }