Skip to content

Commit

Permalink
feat: Add overridable shouldRender() method for use case of rendering…
Browse files Browse the repository at this point in the history
… when cluster doesn't change (#996)

In the current version of DefaultClusterRenderer, there is a built-in optimization to avoid re-rendering items when there isn't a change in the clusters.

However, as discussed in #774, this prevents use cases where the clusters/markers should be re-rendered even if the cluster contents don't change.

For example, if you want a single item to render as a cluster above a certain zoom level and as a marker below a certain zoom level, this isn't possible - the item will never be updated from the cluster to the marker because that rendering pass is skipped (because the cluster contents didn't change).

This PR adds a new DefaultClusterRenderer.shouldRender() method that apps can override to control this optimization when necessary. This allows the above use case as the app can track the previous and current zoom levels and force the render to occur when the transition over the zoom threshold is made.

A new demo activity ZoomClusteringDemoActivity (shown as "Clustering: Force on Zoom" in the demo app UI) (gms and v3 flavors) is added as part of this PR to clearly illustrate how shouldRender() is used. This demo also illustrates what I believe is an undocumented feature of the library, which is that the ClusterManager will call the onCameraIdle() implementation of any Renderer that implements GoogleMap.OnCameraIdleListener before clustering and rendering takes place. This allows us to capture the zoom level that can then be used in shouldRender().

This feature is backwards-compatible, as the current behavior of apps will not change - the default implementation of shouldRender() is the same as the current version of the library.

Closes #774 🦕
  • Loading branch information
barbeau committed Oct 19, 2021
1 parent d9318d4 commit 165534c
Show file tree
Hide file tree
Showing 9 changed files with 438 additions and 6 deletions.
5 changes: 2 additions & 3 deletions demo/build.gradle
Expand Up @@ -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"
Expand Down
Expand Up @@ -250,4 +250,4 @@ private LatLng position() {
private double random(double min, double max) {
return mRandom.nextDouble() * (max - min) + min;
}
}
}
Expand Up @@ -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);
Expand Down
@@ -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<MyItem>, ClusterManager.OnClusterInfoWindowClickListener<MyItem>, ClusterManager.OnClusterItemClickListener<MyItem>, ClusterManager.OnClusterItemInfoWindowClickListener<MyItem> {

@Override
public boolean onClusterClick(Cluster<MyItem> 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<MyItem> 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<MyItem> 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<MyItem> implements GoogleMap.OnCameraIdleListener {
private Float zoom = 15f;
private Float oldZoom;
private static final float ZOOM_THRESHOLD = 12f;

public ZoomBasedRenderer(Context context, GoogleMap map, ClusterManager<MyItem> clusterManager) {
super(context, map, clusterManager);
}

/**
* The {@link ClusterManager} will call the {@link this.onCameraIdle()} implementation of
* any Renderer that implements {@link GoogleMap.OnCameraIdleListener} <i>before</i>
* 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).
* <p>
* 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<MyItem> 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.
* <p>
* 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 <i>even if the contents of
* the clusters themselves did not change</i>. 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<? extends Cluster<MyItem>> oldClusters, @NonNull Set<? extends Cluster<MyItem>> 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);
}
}
}
1 change: 1 addition & 0 deletions demo/src/main/AndroidManifest.xml
Expand Up @@ -59,6 +59,7 @@
<activity android:name=".BigClusteringDemoActivity" />
<activity android:name=".VisibleClusteringDemoActivity" />
<activity android:name=".CustomMarkerClusteringDemoActivity" />
<activity android:name=".ZoomClusteringDemoActivity" />
<activity android:name=".ClusteringViewModelDemoActivity"/>
<activity android:name=".TileProviderAndProjectionDemo" />
<activity android:name=".HeatmapsDemoActivity" />
Expand Down
Expand Up @@ -257,4 +257,4 @@ private LatLng position() {
private double random(double min, double max) {
return mRandom.nextDouble() * (max - min) + min;
}
}
}
Expand Up @@ -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);
Expand Down

0 comments on commit 165534c

Please sign in to comment.