diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml deleted file mode 100644 index 2c0ccce0c..000000000 --- a/.github/workflows/android.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Android CI - -on: - push: - branches: [ '*' ] - pull_request: - branches: [ '*' ] - workflow_dispatch: - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - uses: gradle/wrapper-validation-action@v1.0.4 - - name: set up JDK 11 - uses: actions/setup-java@v2.3.0 - with: - java-version: '11' - distribution: 'adopt' - - name: Build with Gradle - run: ./gradlew build jacocoTestReport -x lint --stacktrace diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..50623fe17 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,46 @@ +# Copyright 2020 Google LLC +# +# 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. + +# A workflow that runs tests on every new pull request +name: Run unit tests + +on: + repository_dispatch: + types: [test] + push: + branches: ['*'] + pull_request: + branches: ['*'] + workflow_dispatch: + +jobs: + run-unit-test: + runs-on: ubuntu-latest + + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - name: Checkout Repo + uses: actions/checkout@v2 + + - name: Gradle Wrapper Validation + uses: gradle/wrapper-validation-action@v1.0.4 + + - name: Set up JDK 11 + uses: actions/setup-java@v2.3.1 + with: + java-version: '11' + distribution: 'adopt' + + - name: Build modules + run: ./gradlew build jacocoTestReport --stacktrace diff --git a/.gitignore b/.gitignore index 30cd9c0d5..cf6fde885 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ library/bin library/gen project.properties .DS_Store +.java-version diff --git a/.releaserc b/.releaserc index d60a950bd..1deff5cf7 100644 --- a/.releaserc +++ b/.releaserc @@ -6,20 +6,19 @@ plugins: - - "@google/semantic-release-replace-plugin" - replacements: - files: - - "./build.gradle" + - "build.gradle" from: "\\bversion = '.*'" to: "version = '${nextRelease.version}'" - - files: - "README.md" from: ":[0-9].[0-9].[0-9]" to: ":${nextRelease.version}" - - "@semantic-release/exec" - - prepareCmd: "./gradlew build --info --stacktrace" + - prepareCmd: "./gradlew build --warn --stacktrace" publishCmd: "./gradlew publish --warn --stacktrace" - - "@semantic-release/git" - assets: - - "./build.gradle" + - "build.gradle" - "*.md" - "@semantic-release/github" options: diff --git a/README.md b/README.md index 75a237fda..2f87ad246 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Build Status](https://github.com/googlemaps/android-maps-utils/actions/workflows/android.yml/badge.svg?branch=main) +![Build Status](https://github.com/googlemaps/android-maps-utils/actions/workflows/test.yml/badge.svg?branch=main) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.google.maps.android/android-maps-utils/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.google.maps.android/android-maps-utils) ![GitHub contributors](https://img.shields.io/github/contributors/googlemaps/android-maps-utils?color=green) [![Discord](https://img.shields.io/discord/676948200904589322)](https://discord.gg/hYsWbmk) @@ -39,14 +39,14 @@ You can view the generated [reference docs][javadoc] for a full list of classes ```groovy dependencies { // Utilities for Maps SDK for Android (requires Google Play Services) - implementation 'com.google.maps.android:android-maps-utils:2.2.6' + implementation 'com.google.maps.android:android-maps-utils:2.3.0' // (Deprecated) Alternately - Utilities for Maps SDK v3 BETA for Android (does not require Google Play Services) - implementation 'com.google.maps.android:android-maps-utils-v3:2.2.6' + implementation 'com.google.maps.android:android-maps-utils-v3:2.3.0' } ``` -_**Note**: The Beta version of the SDK is deprecated and scheduled for decommissioning. A future version of the SDK will provide similar support for Beta features. See the [release notes](https://developers.devsite.corp.google.com/maps/documentation/android-sdk/releases#2021-08-18) for more information._ +_**Note**: The Beta version of the SDK is deprecated and scheduled for decommissioning. A future version of the SDK will provide similar support for Beta features. See the [release notes](https://developers.google.com/maps/documentation/android-sdk/releases#2021-08-18) for more information._ ## Demo App diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..6d19135d8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,10 @@ +# Report a security issue + +To report a security issue, please use https://g.co/vulnz. We use +https://g.co/vulnz for our intake, and do coordination and disclosure here on +GitHub (including using GitHub Security Advisory). The Google Security Team will +respond within 5 working days of your report on g.co/vulnz. + +To contact us about other bugs, please open an issue on GitHub. + +> **Note**: This file is synchronized from the https://github.com/googlemaps/.github repository. diff --git a/build.gradle b/build.gradle index a55e2707a..2ef313478 100644 --- a/build.gradle +++ b/build.gradle @@ -15,7 +15,7 @@ */ buildscript { - ext.kotlin_version = '1.5.30' + ext.kotlin_version = '1.5.31' repositories { google() mavenCentral() @@ -24,7 +24,7 @@ buildscript { } } dependencies { - classpath 'com.android.tools.build:gradle:7.0.2' + classpath 'com.android.tools.build:gradle:7.0.3' classpath 'com.hiya:jacoco-android:0.2' classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.0" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" @@ -98,7 +98,7 @@ ext.projectArtifactId = { project -> allprojects { group = 'com.google.maps.android' - version = '2.2.6' + version = '2.3.0' project.ext.artifactId = rootProject.ext.projectArtifactId(project) } diff --git a/demo/build.gradle b/demo/build.gradle index 06a787104..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.5' + 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.5' + 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); } diff --git a/lint-checks/build.gradle b/lint-checks/build.gradle index 6d658966d..eb2629753 100644 --- a/lint-checks/build.gradle +++ b/lint-checks/build.gradle @@ -11,13 +11,13 @@ lintOptions { } dependencies { - compileOnly "com.android.tools.lint:lint-api:30.0.2" - compileOnly "com.android.tools.lint:lint-checks:30.0.2" + compileOnly "com.android.tools.lint:lint-api:30.0.3" + compileOnly "com.android.tools.lint:lint-checks:30.0.3" compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" testImplementation "junit:junit:4.13.2" - testImplementation "com.android.tools.lint:lint:30.0.2" - testImplementation "com.android.tools.lint:lint-tests:30.0.2" - testImplementation "com.android.tools:testutils:30.0.2" + testImplementation "com.android.tools.lint:lint:30.0.3" + testImplementation "com.android.tools.lint:lint-tests:30.0.3" + testImplementation "com.android.tools:testutils:30.0.3" } jar {