From b7b9c94465404562e6a7c99781833119e2c8bcb8 Mon Sep 17 00:00:00 2001 From: Emmanuel Ferdman Date: Tue, 10 Dec 2024 19:11:14 +0200 Subject: [PATCH 1/7] docs: update `CustomMarkerClusteringDemoActivity` reference (#1425) Signed-off-by: Emmanuel Ferdman --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 92c8008a7..e44684ffc 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ If you're using custom clustering (i.e, if you're extending `DefaultClusterRende **Note that these methods can't be identical, as you need to use a `Marker` instead of `MarkerOptions`* -See the [`CustomMarkerClusteringDemoActivity`](demo/src/gms/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java) in the demo app for a complete example. +See the [`CustomMarkerClusteringDemoActivity`](demo/src/main/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java) in the demo app for a complete example. _New_ From cf0b32e3eb9d02df434152a1184cecde270a10e3 Mon Sep 17 00:00:00 2001 From: wesley chun Date: Wed, 11 Dec 2024 11:11:37 -0800 Subject: [PATCH 2/7] fix: update README to template (#1434) * fix: update README to template * fix: more README fixes from template * fix: more README fixes from template --- README.md | 77 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index e44684ffc..0bcc062b3 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ -![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) +[![Maven Central](https://img.shields.io/maven-central/v/com.google.maps.android/android-maps-utils)](https://maven-badges.herokuapp.com/maven-central/com.google.maps.android/android-maps-utils) +![Tests/Build Status](https://github.com/googlemaps/android-maps-utils/workflows/Test/badge.svg) +![Release](https://github.com/googlemaps/android-maps-utils/workflows/Release/badge.svg) +![Stable](https://img.shields.io/badge/stability-stable-green) + ![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) -![Apache-2.0](https://img.shields.io/badge/license-Apache-blue) +[![Discord](https://img.shields.io/discord/676948200904589322?color=6A7EC2&logo=discord&logoColor=ffffff)][Discord server] +[![GitHub License](https://img.shields.io/github/license/googlemaps/android-maps-utils?color=blue)](LICENSE) # Maps SDK for Android Utility Library ## Description This open-source library contains utilities that are useful for a wide -range of applications using the [Google Maps SDK for Android][android-site]. +range of applications using the [Google Maps SDK for Android][maps-sdk]. - **Marker animation** - animates a marker from one position to another - **Marker clustering** — handles the display of a large number of points @@ -30,7 +33,9 @@ You can also find Kotlin extensions for this library in [Maps Android KTX][andro ## Requirements * Android API level 21+ -- An [API key](https://developers.google.com/maps/documentation/android-sdk/get-api-key) +* [Sign up with Google Maps Platform] +* A Google Maps Platform [project] with the **Maps SDK for Android** enabled +* An [API key] associated with the project above ## Installation @@ -47,29 +52,27 @@ dependencies { } ``` -## Demo App +## Sample App This repository includes a [sample app](demo) that illustrates the use of this library. -To run the demo app, you'll have to: - -1. [Get a Maps API key](https://developers.google.com/maps/documentation/android-sdk/get-api-key) +To run the demo app, ensure you've met the requirements above then: +1. Clone the repository 1. Add a file `local.properties` in the root project (this file should *NOT* be under version control to protect your API key) -1. Add a single line to `local.properties` that looks like `MAPS_API_KEY=YOUR_API_KEY`, where `YOUR_API_KEY` is the API key you obtained in the first step +1. Add a single line to `local.properties` that looks like `MAPS_API_KEY=YOUR_API_KEY`, where `YOUR_API_KEY` is the API key you obtained earlier 1. Build and run the `debug` variant for the Maps SDK for Android version ## Documentation -See the [reference documentation][dokka] for a full list of classes and their methods. - -## Usage +See the [documentation] for a full list of classes and their methods. Full guides for using the utilities are published in -[Google Maps Platform documentation][devsite-guide]. +[Google Maps Platform documentation](https://developers.google.com/maps/documentation/android-sdk/utility). + +## Usage -
Marker utilities ### Marker utilities @@ -340,23 +343,43 @@ _Old_ ## Contributing -Contributions are welcome and encouraged! See the [contributing guide](CONTRIBUTING.md) for more info. +Contributions are welcome and encouraged! If you'd like to contribute, send us a [pull request] and refer to our [code of conduct] and [contributing guide]. + +## Terms of Service + +This library uses Google Maps Platform services. Use of Google Maps Platform services through this library is subject to the Google Maps Platform [Terms of Service]. + +This library is not a Google Maps Platform Core Service. Therefore, the Google Maps Platform Terms of Service (e.g. Technical Support Services, Service Level Agreements, and Deprecation Policy) do not apply to the code in this library. ## Support -This library is offered via an open source [license](LICENSE). It is not governed by the Google Maps Platform [Technical Support Services Guidelines](https://cloud.google.com/maps-platform/terms/tssg?utm_source=github&utm_medium=documentation&utm_campaign=&utm_content=web_components), the [SLA](https://cloud.google.com/maps-platform/terms/sla?utm_source=github&utm_medium=documentation&utm_campaign=&utm_content=web_components), or the [Deprecation Policy](https://cloud.google.com/maps-platform/terms?utm_source=github&utm_medium=documentation&utm_campaign=&utm_content=web_components) (however, any Google Maps Platform services used by the library remain subject to the Google Maps Platform Terms of Service). +This library is offered via an open source [license]. It is not governed by the Google Maps Platform Support [Technical Support Services Guidelines, the SLA, or the [Deprecation Policy]. However, any Google Maps Platform services used by the library remain subject to the Google Maps Platform Terms of Service. -This library adheres to [semantic versioning](https://semver.org/) to indicate when backwards-incompatible changes are introduced. +This library adheres to [semantic versioning] to indicate when backwards-incompatible changes are introduced. Accordingly, while the library is in version 0.x, backwards-incompatible changes may be introduced at any time. -If you find a bug, or have a feature request, please [file an issue] on GitHub. +If you find a bug, or have a feature request, please [file an issue] on GitHub. If you would like to get answers to technical questions from other Google Maps Platform developers, ask through one of our [developer community channels]. If you'd like to contribute, please check the [contributing guide]. -If you would like to get answers to technical questions from other Google Maps Platform developers, ask through one of our [developer community channels](https://developers.google.com/maps/developer-community?utm_source=github&utm_medium=documentation&utm_campaign=&utm_content=web_components) including the Google Maps Platform [Discord server]. +You can also discuss this library on our [Discord server]. + +[API key]: https://developers.google.com/maps/documentation/android-sdk/get-api-key +[gmp-start]: https://console.cloud.google.com/google/maps-apis/start +[maps-sdk]: https://developers.google.com/maps/documentation/android-sdk +[documentation]: https://googlemaps.github.io/android-maps-utils +[android-maps-ktx]: https://github.com/googlemaps/android-maps-ktx -[file an issue]: https://github.com/googlemaps/android-maps-utils/issues/new/choose -[pull request]: https://github.com/googlemaps/android-maps-utils/compare [code of conduct]: CODE_OF_CONDUCT.md +[contributing guide]: CONTRIBUTING.md +[Deprecation Policy]: https://cloud.google.com/maps-platform/terms +[developer community channels]: https://developers.google.com/maps/developer-community [Discord server]: https://discord.gg/hYsWbmk -[android-site]: https://developers.google.com/maps/documentation/android-sdk -[devsite-guide]: https://developers.google.com/maps/documentation/android-sdk/utility -[dokka]: https://googlemaps.github.io/android-maps-utils/ -[android-maps-ktx]: https://github.com/googlemaps/android-maps-ktx +[file an issue]: https://github.com/googlemaps/android-maps-utils/issues/new/choose +[license]: LICENSE +[project]: https://developers.google.com/maps/documentation/android-sdk/cloud-setup +[pull request]: https://github.com/googlemaps/android-maps-utils/compare +[semantic versioning]: https://semver.org +[Sign up with Google Maps Platform]: https://console.cloud.google.com/google/maps-apis/start +[similar inquiry]: https://github.com/googlemaps/android-maps-utils/issues +[SLA]: https://cloud.google.com/maps-platform/terms/sla +[Technical Support Services Guidelines]: https://cloud.google.com/maps-platform/terms/tssg +[Terms of Service]: https://cloud.google.com/maps-platform/terms + From 11d95426623bfe83bbab3f4cca19ffa6b72bd08c Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 11 Dec 2024 19:18:17 +0000 Subject: [PATCH 3/7] chore(release): 3.10.1 [skip ci] ## [3.10.1](https://github.com/googlemaps/android-maps-utils/compare/v3.10.0...v3.10.1) (2024-12-11) ### Bug Fixes * update README to template ([#1434](https://github.com/googlemaps/android-maps-utils/issues/1434)) ([cf0b32e](https://github.com/googlemaps/android-maps-utils/commit/cf0b32e3eb9d02df434152a1184cecde270a10e3)) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0bcc062b3..482f714ad 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ dependencies { // Utilities for Maps SDK for Android (requires Google Play Services) // You do not need to add a separate dependency for the Maps SDK for Android // since this library builds in the compatible version of the Maps SDK. - implementation 'com.google.maps.android:android-maps-utils:3.10.0' + implementation 'com.google.maps.android:android-maps-utils:3.10.1' // Optionally add the Kotlin Extensions (KTX) for full Kotlin language support // See latest version at https://github.com/googlemaps/android-maps-ktx From f54324108f68d9b7560e014c7e29b8a71fc34a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez=20Ma=C3=B1as?= Date: Wed, 11 Dec 2024 22:24:01 +0100 Subject: [PATCH 4/7] chore: fix README.md link (#1439) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 482f714ad..bd95332af 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![Maven Central](https://img.shields.io/maven-central/v/com.google.maps.android/android-maps-utils)](https://maven-badges.herokuapp.com/maven-central/com.google.maps.android/android-maps-utils) -![Tests/Build Status](https://github.com/googlemaps/android-maps-utils/workflows/Test/badge.svg) +![Tests/Build Status](https://github.com/googlemaps/android-maps-utils/actions/workflows/test.yml/badge.svg?branch=main) ![Release](https://github.com/googlemaps/android-maps-utils/workflows/Release/badge.svg) ![Stable](https://img.shields.io/badge/stability-stable-green) From c1edbafb8c0f023ec29f58d97838a1f988b29c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez=20Ma=C3=B1as?= Date: Thu, 16 Jan 2025 21:09:17 +0100 Subject: [PATCH 5/7] feat: implementation of diff (#1438) * feat: implementation of diff * feat: headers * feat: headers * feat: removed comments * feat: commented diff in sample * feat: removed render condition * feat: removed render condition * feat: handling individual markers * feat: removed comment * feat: header * feat: canceling animation if it is updated * fix: animation --- .gitignore | 2 +- demo/build.gradle.kts | 1 + demo/src/main/AndroidManifest.xml | 3 + .../demo/ClusteringDiffDemoActivity.java | 297 ++++ .../maps/android/utils/demo/MainActivity.java | 1 + .../maps/android/utils/demo/model/Person.java | 17 + .../res/layout/map_with_floating_button.xml | 43 + gradle/libs.versions.toml | 8 +- .../android/clustering/ClusterManager.java | 29 + .../view/ClusterRendererMultipleItems.java | 1197 +++++++++++++++++ .../view/DefaultClusterRenderer.java | 18 +- 11 files changed, 1600 insertions(+), 16 deletions(-) create mode 100644 demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDiffDemoActivity.java create mode 100644 demo/src/main/res/layout/map_with_floating_button.xml create mode 100644 library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java diff --git a/.gitignore b/.gitignore index e43020ee3..73c682328 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ project.properties .DS_Store .java-version secrets.properties -.kotlin +.kotlin \ No newline at end of file diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 46db97c5d..9556a50af 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -64,6 +64,7 @@ dependencies { implementation(libs.kotlin.stdlib.jdk8) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.core) + implementation(libs.material) testImplementation(libs.junit) testImplementation(libs.truth) diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index e6cb01240..a504a7619 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -91,6 +91,9 @@ + diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDiffDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDiffDemoActivity.java new file mode 100644 index 000000000..08f156071 --- /dev/null +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDiffDemoActivity.java @@ -0,0 +1,297 @@ +/* + * Copyright 2025 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. + */ + +package com.google.maps.android.utils.demo; + +import android.annotation.SuppressLint; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.core.content.res.ResourcesCompat; + +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.model.BitmapDescriptor; +import com.google.android.gms.maps.model.BitmapDescriptorFactory; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.LatLngBounds; +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.ClusterRendererMultipleItems; +import com.google.maps.android.ui.IconGenerator; +import com.google.maps.android.utils.demo.model.Person; + +import java.util.ArrayList; +import java.util.List; + +/** + * Demonstrates how to apply a diff to the current Cluster + */ +public class ClusteringDiffDemoActivity extends BaseDemoActivity implements ClusterManager.OnClusterClickListener, ClusterManager.OnClusterInfoWindowClickListener, ClusterManager.OnClusterItemClickListener, ClusterManager.OnClusterItemInfoWindowClickListener { + private ClusterManager mClusterManager; + private Person itemtoUpdate = new Person(ENFIELD, "Teach", R.drawable.teacher); + + private static final LatLng ENFIELD = new LatLng(51.6524, -0.0838); + private static final LatLng ILFORD = new LatLng(51.5590, -0.0815); + + private static final LatLng LONDON = new LatLng(51.5074, -0.1278); + LatLng midpoint = getMidpoint(); + private int currentLocationIndex = 0; + + protected int getLayoutId() { + return R.layout.map_with_floating_button; + } + + @Override + public void onMapReady(@NonNull GoogleMap map) { + super.onMapReady(map); + findViewById(R.id.fab_rotate_location).setOnClickListener(v -> rotateLocation()); + getMap().animateCamera(CameraUpdateFactory.newLatLngZoom(midpoint, 12)); + } + + + private LatLng getMidpoint() { + double latitude = (ClusteringDiffDemoActivity.ENFIELD.latitude + ClusteringDiffDemoActivity.ILFORD.latitude + ClusteringDiffDemoActivity.LONDON.latitude) / 3; + double longitude = (ClusteringDiffDemoActivity.ENFIELD.longitude + ClusteringDiffDemoActivity.ILFORD.longitude + ClusteringDiffDemoActivity.LONDON.longitude) / 3; + return new LatLng(latitude, longitude); + } + + /** + * Draws profile photos inside markers (using IconGenerator). + * When there are multiple people in the cluster, draw multiple photos (using MultiDrawable). + */ + @SuppressLint("InflateParams") + private class PersonRenderer extends ClusterRendererMultipleItems { + private final IconGenerator mIconGenerator = new IconGenerator(getApplicationContext()); + private final IconGenerator mClusterIconGenerator = new IconGenerator(getApplicationContext()); + private final ImageView mImageView; + private final ImageView mClusterImageView; + private final int mDimension; + + public PersonRenderer() { + super(getApplicationContext(), getMap(), mClusterManager); + + View multiProfile = getLayoutInflater().inflate(R.layout.multi_profile, null); + mClusterIconGenerator.setContentView(multiProfile); + mClusterImageView = multiProfile.findViewById(R.id.image); + + mImageView = new ImageView(getApplicationContext()); + mDimension = (int) getResources().getDimension(R.dimen.custom_profile_image); + mImageView.setLayoutParams(new ViewGroup.LayoutParams(mDimension, mDimension)); + int padding = (int) getResources().getDimension(R.dimen.custom_profile_padding); + mImageView.setPadding(padding, padding, padding, padding); + mIconGenerator.setContentView(mImageView); + } + + @Override + protected void onBeforeClusterItemRendered(@NonNull Person person, @NonNull MarkerOptions markerOptions) { + // Draw a single person - show their profile photo and set the info window to show their name + markerOptions + .icon(getItemIcon(person)) + .title(person.name); + } + + @Override + protected void onClusterItemUpdated(@NonNull Person person, @NonNull Marker marker) { + // Same implementation as onBeforeClusterItemRendered() (to update cached markers) + marker.setIcon(getItemIcon(person)); + marker.setTitle(person.name); + } + + /** + * Get a descriptor for a single person (i.e., a marker outside a cluster) from their + * profile photo to be used for a marker icon + * + * @param person person to return an BitmapDescriptor for + * @return the person's profile photo as a BitmapDescriptor + */ + private BitmapDescriptor getItemIcon(Person person) { + mImageView.setImageResource(person.profilePhoto); + Bitmap icon = mIconGenerator.makeIcon(); + return BitmapDescriptorFactory.fromBitmap(icon); + } + + @Override + protected void onBeforeClusterRendered(@NonNull Cluster cluster, @NonNull MarkerOptions markerOptions) { + // Draw multiple people. + // Note: this method runs on the UI thread. Don't spend too much time in here (like in this example). + markerOptions.icon(getClusterIcon(cluster)); + } + + @Override + protected void onClusterUpdated(@NonNull Cluster cluster, @NonNull Marker marker) { + // Same implementation as onBeforeClusterRendered() (to update cached markers) + marker.setIcon(getClusterIcon(cluster)); + } + + /** + * Get a descriptor for multiple people (a cluster) to be used for a marker icon. Note: this + * method runs on the UI thread. Don't spend too much time in here (like in this example). + * + * @param cluster cluster to draw a BitmapDescriptor for + * @return a BitmapDescriptor representing a cluster + */ + private BitmapDescriptor getClusterIcon(Cluster cluster) { + List profilePhotos = new ArrayList<>(Math.min(4, cluster.getSize())); + int width = mDimension; + int height = mDimension; + + for (Person p : cluster.getItems()) { + // Draw 4 at most. + if (profilePhotos.size() == 4) break; + Drawable drawable = ResourcesCompat.getDrawable(getBaseContext().getResources(), p.profilePhoto, null); + if (drawable != null) { + drawable.setBounds(0, 0, width, height); + } + profilePhotos.add(drawable); + } + MultiDrawable multiDrawable = new MultiDrawable(profilePhotos); + multiDrawable.setBounds(0, 0, width, height); + + mClusterImageView.setImageDrawable(multiDrawable); + Bitmap icon = mClusterIconGenerator.makeIcon(String.valueOf(cluster.getSize())); + return BitmapDescriptorFactory.fromBitmap(icon); + } + + @Override + protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { + return cluster.getSize() >= 2; + } + } + + + @Override + public boolean onClusterClick(Cluster cluster) { + // Show a toast with some info when the cluster is clicked. + String firstName = cluster.getItems().iterator().next().name; + Toast.makeText(this, cluster.getSize() + " (including " + firstName + ")", 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()); + } + // Get the LatLngBounds + final LatLngBounds bounds = builder.build(); + + // Animate camera to the bounds + try { + getMap().animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, 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(Person item) { + // Does nothing, but you could go into the user's profile page, for example. + return false; + } + + @Override + public void onClusterItemInfoWindowClick(Person item) { + // Does nothing, but you could go into the user's profile page, for example. + } + + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 6)); + } + + mClusterManager = new ClusterManager<>(this, getMap()); + mClusterManager.setRenderer(new PersonRenderer()); + getMap().setOnCameraIdleListener(mClusterManager); + getMap().setOnMarkerClickListener(mClusterManager); + getMap().setOnInfoWindowClickListener(mClusterManager); + mClusterManager.setOnClusterClickListener(this); + mClusterManager.setOnClusterInfoWindowClickListener(this); + mClusterManager.setOnClusterItemClickListener(this); + mClusterManager.setOnClusterItemInfoWindowClickListener(this); + + addItems(); + mClusterManager.cluster(); + } + + private void addItems() { + // Marker in Enfield + mClusterManager.addItem(new Person(ENFIELD, "John", R.drawable.john)); + + // Marker in the center of London + itemtoUpdate = new Person(LONDON, "Teach", R.drawable.teacher); + mClusterManager.addItem(itemtoUpdate); + } + + private void rotateLocation() { + // Update the current index to cycle through locations (0 = Enfield, 1 = Olford, 2 = London) + currentLocationIndex = (currentLocationIndex + 1) % 3; + + + LatLng newLocation = switch (currentLocationIndex) { + case 0 -> ENFIELD; + case 1 -> ILFORD; + default -> LONDON; + }; + + String cityName = getCityName(newLocation); + + Log.d("ClusterTest", "Item rotated to: " + newLocation.toString() + ", City: " + cityName); + + if (itemtoUpdate != null) { + itemtoUpdate = new Person(newLocation, "Teach", R.drawable.teacher); + mClusterManager.updateItem(itemtoUpdate); // Update the marker + mClusterManager.cluster(); + } + } + + // Method to map LatLng to city name + private String getCityName(LatLng location) { + if (areLocationsEqual(location, ENFIELD)) { + return "Enfield"; + } else if (areLocationsEqual(location, ILFORD)) { + return "Ilford"; + } else if (areLocationsEqual(location, LONDON)) { + return "London"; + } else { + return "Unknown City"; // Default case if location is not recognized + } + } + + // Method to compare LatLng objects with a tolerance + private boolean areLocationsEqual(LatLng loc1, LatLng loc2) { + return Math.abs(loc1.latitude - loc2.latitude) < 1E-5 && + Math.abs(loc1.longitude - loc2.longitude) < 1E-5; + } +} \ No newline at end of file diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java index 8cf7561fb..10ff9d5f7 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java @@ -47,6 +47,7 @@ protected void onCreate(Bundle savedInstanceState) { addDemo("Clustering", ClusteringDemoActivity.class); addDemo("Advanced Markers Clustering Example", CustomAdvancedMarkerClusteringDemoActivity.class); addDemo("Clustering: Custom Look", CustomMarkerClusteringDemoActivity.class); + addDemo("Clustering: Diff", ClusteringDiffDemoActivity.class); addDemo("Clustering: 2K markers", BigClusteringDemoActivity.class); addDemo("Clustering: 20K only visible markers", VisibleClusteringDemoActivity.class); addDemo("Clustering: ViewModel", ClusteringViewModelDemoActivity.class); diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/model/Person.java b/demo/src/main/java/com/google/maps/android/utils/demo/model/Person.java index 0c42737da..6d1a7fe70 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/model/Person.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/model/Person.java @@ -22,6 +22,9 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.Objects; + + public class Person implements ClusterItem { public final String name; public final int profilePhoto; @@ -54,4 +57,18 @@ public String getSnippet() { public Float getZIndex() { return null; } + + @Override + public int hashCode() { + return Objects.hashCode(name); + } + + // If we use the diff() operation, we need to implement an equals operation, to determine what + // makes each ClusterItem unique (which is probably not the position) + @Override + public boolean equals(@Nullable Object obj) { + if (obj != null && getClass() != obj.getClass()) return false; + Person myObj = (Person) obj; + return this.name.equals(myObj.name); + } } diff --git a/demo/src/main/res/layout/map_with_floating_button.xml b/demo/src/main/res/layout/map_with_floating_button.xml new file mode 100644 index 000000000..b32abf915 --- /dev/null +++ b/demo/src/main/res/layout/map_with_floating_button.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a6f2a4478..b7c53ecee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,15 +8,17 @@ lifecycle-viewmodel-ktx = "2.8.7" kotlin = "2.0.21" kotlinx-coroutines = "1.9.0" junit = "4.13.2" +mockito-core = "5.14.2" secrets-gradle-plugin = "2.0.1" truth = "1.4.4" play-services-maps = "19.0.0" core-ktx = "1.15.0" robolectric = "4.12.2" kxml2 = "2.3.0" -mockk = "1.13.11" +mockk = "1.13.13" lint = "31.7.3" org-jacoco-core = "0.8.11" +material = "1.12.0" [libraries] appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -30,6 +32,7 @@ kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", versi kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } junit = { module = "junit:junit", version.ref = "junit" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito-core" } secrets-gradle-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "secrets-gradle-plugin" } truth = { module = "com.google.truth:truth", version.ref = "truth" } play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "play-services-maps" } @@ -42,4 +45,5 @@ lint-checks = { module = "com.android.tools.lint:lint-checks", version.ref = "li lint = { module = "com.android.tools.lint:lint", version.ref = "lint" } lint-tests = { module = "com.android.tools.lint:lint-tests", version.ref = "lint" } testutils = { module = "com.android.tools:testutils", version.ref = "lint" } -org-jacoco-core = { module = "org.jacoco:org.jacoco.core", version.ref = "org-jacoco-core" } \ No newline at end of file +org-jacoco-core = { module = "org.jacoco:org.jacoco.core", version.ref = "org-jacoco-core" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } \ No newline at end of file diff --git a/library/src/main/java/com/google/maps/android/clustering/ClusterManager.java b/library/src/main/java/com/google/maps/android/clustering/ClusterManager.java index dbe882445..52474f2f5 100644 --- a/library/src/main/java/com/google/maps/android/clustering/ClusterManager.java +++ b/library/src/main/java/com/google/maps/android/clustering/ClusterManager.java @@ -18,6 +18,7 @@ import android.content.Context; import android.os.AsyncTask; +import android.util.Log; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.CameraPosition; @@ -37,6 +38,7 @@ import java.util.concurrent.locks.ReentrantReadWriteLock; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; /** * Groups many items on a map based on zoom level. @@ -205,6 +207,33 @@ public boolean addItem(T myItem) { } } + public void diff(@Nullable Collection add, @Nullable Collection remove, @Nullable Collection modify) { + final Algorithm algorithm = getAlgorithm(); + algorithm.lock(); + try { + // Add items + if (add != null) { + for (T item : add) { + algorithm.addItem(item); + } + } + + // Remove items + if (remove != null) { + algorithm.removeItems(remove); + } + + // Modify items + if (modify != null) { + for (T item : modify) { + updateItem(item); + } + } + } finally { + algorithm.unlock(); + } + } + /** * Removes items from clusters. After calling this method you must invoke {@link #cluster()} for * the state of the clusters to be updated on the map. diff --git a/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java b/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java new file mode 100644 index 000000000..a2632d00f --- /dev/null +++ b/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java @@ -0,0 +1,1197 @@ +/* + * Copyright 2025 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. + */ + +package com.google.maps.android.clustering.view; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.TimeInterpolator; +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.OvalShape; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.MessageQueue; +import android.util.SparseArray; +import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; + +import androidx.annotation.NonNull; +import androidx.annotation.StyleRes; + +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.Projection; +import com.google.android.gms.maps.model.BitmapDescriptor; +import com.google.android.gms.maps.model.BitmapDescriptorFactory; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.LatLngBounds; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import com.google.maps.android.R; +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.collections.MarkerManager; +import com.google.maps.android.geometry.Point; +import com.google.maps.android.projection.SphericalMercatorProjection; +import com.google.maps.android.ui.IconGenerator; +import com.google.maps.android.ui.SquareTextView; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +/** + * The default view for a ClusterManager. Markers are animated in and out of clusters. + */ +public class ClusterRendererMultipleItems implements ClusterRenderer { + private final GoogleMap mMap; + private final IconGenerator mIconGenerator; + private final ClusterManager mClusterManager; + private final float mDensity; + private boolean mAnimate; + private long mAnimationDurationMs; + private final Executor mExecutor = Executors.newSingleThreadExecutor(); + private final Queue ongoingAnimations = new LinkedList<>(); + + private static final int[] BUCKETS = {10, 20, 50, 100, 200, 500, 1000}; + private ShapeDrawable mColoredCircleBackground; + + /** + * Markers that are currently on the map. + */ + private Set mMarkers = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + /** + * Icons for each bucket. + */ + private final SparseArray mIcons = new SparseArray<>(); + + /** + * Markers for single ClusterItems. + */ + private final MarkerCache mMarkerCache = new MarkerCache<>(); + + /** + * If cluster size is less than this size, display individual markers. + */ + private int mMinClusterSize = 2; + + /** + * The currently displayed set of clusters. + */ + private Set> mClusters; + + /** + * Markers for Clusters. + */ + private final MarkerCache> mClusterMarkerCache = new MarkerCache<>(); + + /** + * The target zoom level for the current set of clusters. + */ + private float mZoom; + + private final ViewModifier mViewModifier = new ViewModifier(Looper.getMainLooper()); + + private ClusterManager.OnClusterClickListener mClickListener; + private ClusterManager.OnClusterInfoWindowClickListener mInfoWindowClickListener; + private ClusterManager.OnClusterInfoWindowLongClickListener mInfoWindowLongClickListener; + private ClusterManager.OnClusterItemClickListener mItemClickListener; + private ClusterManager.OnClusterItemInfoWindowClickListener mItemInfoWindowClickListener; + private ClusterManager.OnClusterItemInfoWindowLongClickListener mItemInfoWindowLongClickListener; + + public ClusterRendererMultipleItems(Context context, GoogleMap map, ClusterManager clusterManager) { + mMap = map; + mAnimate = true; + mAnimationDurationMs = 300; + mDensity = context.getResources().getDisplayMetrics().density; + mIconGenerator = new IconGenerator(context); + mIconGenerator.setContentView(makeSquareTextView(context)); + mIconGenerator.setTextAppearance(R.style.amu_ClusterIcon_TextAppearance); + mIconGenerator.setBackground(makeClusterBackground()); + mClusterManager = clusterManager; + } + + @Override + public void onAdd() { + mClusterManager.getMarkerCollection().setOnMarkerClickListener(marker -> mItemClickListener != null && mItemClickListener.onClusterItemClick(mMarkerCache.get(marker))); + + mClusterManager.getMarkerCollection().setOnInfoWindowClickListener(marker -> { + if (mItemInfoWindowClickListener != null) { + mItemInfoWindowClickListener.onClusterItemInfoWindowClick(mMarkerCache.get(marker)); + } + }); + + mClusterManager.getMarkerCollection().setOnInfoWindowLongClickListener(marker -> { + if (mItemInfoWindowLongClickListener != null) { + mItemInfoWindowLongClickListener.onClusterItemInfoWindowLongClick(mMarkerCache.get(marker)); + } + }); + + mClusterManager.getClusterMarkerCollection().setOnMarkerClickListener(marker -> mClickListener != null && mClickListener.onClusterClick(mClusterMarkerCache.get(marker))); + + mClusterManager.getClusterMarkerCollection().setOnInfoWindowClickListener(marker -> { + if (mInfoWindowClickListener != null) { + mInfoWindowClickListener.onClusterInfoWindowClick(mClusterMarkerCache.get(marker)); + } + }); + + mClusterManager.getClusterMarkerCollection().setOnInfoWindowLongClickListener(marker -> { + if (mInfoWindowLongClickListener != null) { + mInfoWindowLongClickListener.onClusterInfoWindowLongClick(mClusterMarkerCache.get(marker)); + } + }); + } + + @Override + public void onRemove() { + mClusterManager.getMarkerCollection().setOnMarkerClickListener(null); + mClusterManager.getMarkerCollection().setOnInfoWindowClickListener(null); + mClusterManager.getMarkerCollection().setOnInfoWindowLongClickListener(null); + mClusterManager.getClusterMarkerCollection().setOnMarkerClickListener(null); + mClusterManager.getClusterMarkerCollection().setOnInfoWindowClickListener(null); + mClusterManager.getClusterMarkerCollection().setOnInfoWindowLongClickListener(null); + } + + private LayerDrawable makeClusterBackground() { + mColoredCircleBackground = new ShapeDrawable(new OvalShape()); + ShapeDrawable outline = new ShapeDrawable(new OvalShape()); + outline.getPaint().setColor(0x80ffffff); // Transparent white. + LayerDrawable background = new LayerDrawable(new Drawable[]{outline, mColoredCircleBackground}); + int strokeWidth = (int) (mDensity * 3); + background.setLayerInset(1, strokeWidth, strokeWidth, strokeWidth, strokeWidth); + return background; + } + + private SquareTextView makeSquareTextView(Context context) { + SquareTextView squareTextView = new SquareTextView(context); + ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + squareTextView.setLayoutParams(layoutParams); + squareTextView.setId(R.id.amu_text); + int twelveDpi = (int) (12 * mDensity); + squareTextView.setPadding(twelveDpi, twelveDpi, twelveDpi, twelveDpi); + return squareTextView; + } + + @Override + public int getColor(int clusterSize) { + final float hueRange = 220; + final float sizeRange = 300; + final float size = Math.min(clusterSize, sizeRange); + final float hue = (sizeRange - size) * (sizeRange - size) / (sizeRange * sizeRange) * hueRange; + return Color.HSVToColor(new float[]{hue, 1f, .6f}); + } + + @StyleRes + @Override + public int getClusterTextAppearance(int clusterSize) { + return R.style.amu_ClusterIcon_TextAppearance; // Default value + } + + @NonNull + protected String getClusterText(int bucket) { + if (bucket < BUCKETS[0]) { + return String.valueOf(bucket); + } + return bucket + "+"; + } + + /** + * Gets the "bucket" for a particular cluster. By default, uses the number of points within the + * cluster, bucketed to some set points. + */ + protected int getBucket(@NonNull Cluster cluster) { + int size = cluster.getSize(); + if (size <= BUCKETS[0]) { + return size; + } + for (int i = 0; i < BUCKETS.length - 1; i++) { + if (size < BUCKETS[i + 1]) { + return BUCKETS[i]; + } + } + return BUCKETS[BUCKETS.length - 1]; + } + + /** + * Gets the minimum cluster size used to render clusters. For example, if "4" is returned, + * then for any clusters of size 3 or less the items will be rendered as individual markers + * instead of as a single cluster marker. + * + * @return the minimum cluster size used to render clusters. For example, if "4" is returned, + * then for any clusters of size 3 or less the items will be rendered as individual markers + * instead of as a single cluster marker. + */ + public int getMinClusterSize() { + return mMinClusterSize; + } + + /** + * Sets the minimum cluster size used to render clusters. For example, if "4" is provided, + * then for any clusters of size 3 or less the items will be rendered as individual markers + * instead of as a single cluster marker. + * + * @param minClusterSize the minimum cluster size used to render clusters. For example, if "4" + * is provided, then for any clusters of size 3 or less the items will be + * rendered as individual markers instead of as a single cluster marker. + */ + public void setMinClusterSize(int minClusterSize) { + mMinClusterSize = minClusterSize; + } + + /** + * ViewModifier ensures only one re-rendering of the view occurs at a time, and schedules + * re-rendering, which is performed by the RenderTask. + */ + @SuppressLint("HandlerLeak") + private class ViewModifier extends Handler { + public ViewModifier(Looper looper) { + super(looper); + } + private static final int RUN_TASK = 0; + private static final int TASK_FINISHED = 1; + private boolean mViewModificationInProgress = false; + private RenderTask mNextClusters = null; + + @Override + public void handleMessage(Message msg) { + if (msg.what == TASK_FINISHED) { + mViewModificationInProgress = false; + if (mNextClusters != null) { + // Run the task that was queued up. + sendEmptyMessage(RUN_TASK); + } + return; + } + removeMessages(RUN_TASK); + + if (mViewModificationInProgress) { + // Busy - wait for the callback. + return; + } + + if (mNextClusters == null) { + // Nothing to do. + return; + } + Projection projection = mMap.getProjection(); + + RenderTask renderTask; + synchronized (this) { + renderTask = mNextClusters; + mNextClusters = null; + mViewModificationInProgress = true; + } + + renderTask.setCallback(() -> sendEmptyMessage(TASK_FINISHED)); + renderTask.setProjection(projection); + renderTask.setMapZoom(mMap.getCameraPosition().zoom); + mExecutor.execute(renderTask); + } + + public void queue(Set> clusters) { + synchronized (this) { + // Overwrite any pending cluster tasks - we don't care about intermediate states. + mNextClusters = new RenderTask(clusters); + } + sendEmptyMessage(RUN_TASK); + } + } + + /** + * Determine whether the cluster should be rendered as individual markers or a cluster. + * + * @param cluster cluster to examine for rendering + * @return true if the provided cluster should be rendered as a single marker on the map, false + * if the items within this cluster should be rendered as individual markers instead. + */ + protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { + return cluster.getSize() >= mMinClusterSize; + } + + /** + * Transforms the current view (represented by DefaultClusterRenderer.mClusters and DefaultClusterRenderer.mZoom) to a + * new zoom level and set of clusters. + *

+ * This must be run off the UI thread. Work is coordinated in the RenderTask, then queued up to + * be executed by a MarkerModifier. + *

+ * There are three stages for the render: + *

+ * 1. Markers are added to the map + *

+ * 2. Markers are animated to their final position + *

+ * 3. Any old markers are removed from the map + *

+ * When zooming in, markers are animated out from the nearest existing cluster. When zooming + * out, existing clusters are animated to the nearest new cluster. + */ + private class RenderTask implements Runnable { + + final Set> clusters; + + private Runnable mCallback; + + private Projection mProjection; + + private SphericalMercatorProjection mSphericalMercatorProjection; + private float mMapZoom; + + private RenderTask(Set> clusters) { + this.clusters = clusters; + } + + public void setCallback(Runnable callback) { + mCallback = callback; + } + + public void setProjection(Projection projection) { + this.mProjection = projection; + } + + public void setMapZoom(float zoom) { + this.mMapZoom = zoom; + this.mSphericalMercatorProjection = new SphericalMercatorProjection(256 * Math.pow(2, Math.min(zoom, mZoom))); + } + + @SuppressLint("NewApi") + public void run() { + final MarkerModifier markerModifier = new MarkerModifier(); + final float zoom = mMapZoom; + final Set markersToRemove = mMarkers; + LatLngBounds visibleBounds; + + try { + visibleBounds = mProjection.getVisibleRegion().latLngBounds; + } catch (Exception e) { + e.printStackTrace(); + visibleBounds = LatLngBounds.builder().include(new LatLng(0, 0)).build(); + } + + // Find all of the existing clusters that are on-screen. These are candidates for markers to animate from. + List existingClustersOnScreen = null; + if (ClusterRendererMultipleItems.this.mClusters != null && mAnimate) { + existingClustersOnScreen = new ArrayList<>(); + for (Cluster c : ClusterRendererMultipleItems.this.mClusters) { + if (shouldRenderAsCluster(c) && visibleBounds.contains(c.getPosition())) { + Point point = mSphericalMercatorProjection.toPoint(c.getPosition()); + existingClustersOnScreen.add(point); + } + } + } + + // Create the new markers and animate them to their new positions. + final Set newMarkers = Collections.newSetFromMap(new ConcurrentHashMap<>()); + for (Cluster c : clusters) { + boolean onScreen = visibleBounds.contains(c.getPosition()); + if (mAnimate) { + Point point = mSphericalMercatorProjection.toPoint(c.getPosition()); + Point closest = findClosestCluster(existingClustersOnScreen, point); + if (closest != null) { + LatLng animateFrom = mSphericalMercatorProjection.toLatLng(closest); + markerModifier.add(true, new CreateMarkerTask(c, newMarkers, animateFrom)); + } else { + markerModifier.add(true, new CreateMarkerTask(c, newMarkers, null)); + } + + } else { + markerModifier.add(onScreen, new CreateMarkerTask(c, newMarkers, null)); + } + } + + // Wait for all markers to be added. + markerModifier.waitUntilFree(); + + // Don't remove any markers that were just added. This is basically anything that had a hit in the MarkerCache. + markersToRemove.removeAll(newMarkers); + + // Find all of the new clusters that were added on-screen. These are candidates for markers to animate from. + List newClustersOnScreen = null; + if (mAnimate) { + newClustersOnScreen = new ArrayList<>(); + for (Cluster c : clusters) { + if (shouldRenderAsCluster(c) && visibleBounds.contains(c.getPosition())) { + Point p = mSphericalMercatorProjection.toPoint(c.getPosition()); + newClustersOnScreen.add(p); + } + } + } + + for (final MarkerWithPosition marker : markersToRemove) { + boolean onScreen = visibleBounds.contains(marker.position); + if (onScreen && mAnimate) { + final Point point = mSphericalMercatorProjection.toPoint(marker.position); + final Point closest = findClosestCluster(newClustersOnScreen, point); + if (closest != null) { + LatLng animateTo = mSphericalMercatorProjection.toLatLng(closest); + markerModifier.animateThenRemove(marker, marker.position, animateTo); + } else if (mClusterMarkerCache.mCache.keySet().iterator().hasNext() && mClusterMarkerCache.mCache.keySet().iterator().next().getItems().contains(marker.clusterItem)) { + T foundItem = null; + for (Cluster cluster : mClusterMarkerCache.mCache.keySet()) { + for (T clusterItem : cluster.getItems()) { + if (clusterItem.equals(marker.clusterItem)) { + foundItem = clusterItem; + break; + } + } + + } + // Remove it because it will join a cluster + markerModifier.animateThenRemove(marker, marker.position, foundItem.getPosition()); + } else { + markerModifier.remove(true, marker.marker); + } + } else { + markerModifier.remove(onScreen, marker.marker); + } + } + + // Wait until all marker removal operations are completed. + markerModifier.waitUntilFree(); + + mMarkers = newMarkers; + ClusterRendererMultipleItems.this.mClusters = clusters; + mZoom = zoom; + + // Run the callback once everything is done. + mCallback.run(); + } + } + + + @Override + public void onClustersChanged(Set> clusters) { + mViewModifier.queue(clusters); + } + + @Override + public void setOnClusterClickListener(ClusterManager.OnClusterClickListener listener) { + mClickListener = listener; + } + + @Override + public void setOnClusterInfoWindowClickListener(ClusterManager.OnClusterInfoWindowClickListener listener) { + mInfoWindowClickListener = listener; + } + + @Override + public void setOnClusterInfoWindowLongClickListener(ClusterManager.OnClusterInfoWindowLongClickListener listener) { + mInfoWindowLongClickListener = listener; + } + + @Override + public void setOnClusterItemClickListener(ClusterManager.OnClusterItemClickListener listener) { + mItemClickListener = listener; + } + + @Override + public void setOnClusterItemInfoWindowClickListener(ClusterManager.OnClusterItemInfoWindowClickListener listener) { + mItemInfoWindowClickListener = listener; + } + + @Override + public void setOnClusterItemInfoWindowLongClickListener(ClusterManager.OnClusterItemInfoWindowLongClickListener listener) { + mItemInfoWindowLongClickListener = listener; + } + + @Override + public void setAnimation(boolean animate) { + mAnimate = animate; + } + + /** + * {@inheritDoc} The default duration is 300 milliseconds. + * + * @param animationDurationMs long: The length of the animation, in milliseconds. This value cannot be negative. + */ + @Override + public void setAnimationDuration(long animationDurationMs) { + mAnimationDurationMs = animationDurationMs; + } + + 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); + } + + private Point findClosestCluster(List markers, Point point) { + if (markers == null || markers.isEmpty()) return null; + + int maxDistance = mClusterManager.getAlgorithm().getMaxDistanceBetweenClusteredItems(); + double minDistSquared = maxDistance * maxDistance; + Point closest = null; + for (Point candidate : markers) { + double dist = distanceSquared(candidate, point); + if (dist < minDistSquared) { + closest = candidate; + minDistSquared = dist; + } + } + return closest; + } + + /** + * Handles all markerWithPosition manipulations on the map. Work (such as adding, removing, or + * animating a markerWithPosition) is performed while trying not to block the rest of the app's + * UI. + */ + @SuppressLint("HandlerLeak") + private class MarkerModifier extends Handler implements MessageQueue.IdleHandler { + private static final int BLANK = 0; + + private final Lock lock = new ReentrantLock(); + private final Condition busyCondition = lock.newCondition(); + + private final Queue mCreateMarkerTasks = new LinkedList<>(); + private final Queue mOnScreenCreateMarkerTasks = new LinkedList<>(); + private final Queue mRemoveMarkerTasks = new LinkedList<>(); + private final Queue mOnScreenRemoveMarkerTasks = new LinkedList<>(); + private final Queue mAnimationTasks = new LinkedList<>(); + + + /** + * Whether the idle listener has been added to the UI thread's MessageQueue. + */ + private boolean mListenerAdded; + + private MarkerModifier() { + super(Looper.getMainLooper()); + } + + /** + * Creates markers for a cluster some time in the future. + * + * @param priority whether this operation should have priority. + */ + public void add(boolean priority, CreateMarkerTask c) { + lock.lock(); + sendEmptyMessage(BLANK); + if (priority) { + mOnScreenCreateMarkerTasks.add(c); + } else { + mCreateMarkerTasks.add(c); + } + lock.unlock(); + } + + /** + * Removes a markerWithPosition some time in the future. + * + * @param priority whether this operation should have priority. + * @param m the markerWithPosition to remove. + */ + public void remove(boolean priority, Marker m) { + lock.lock(); + sendEmptyMessage(BLANK); + if (priority) { + mOnScreenRemoveMarkerTasks.add(m); + } else { + mRemoveMarkerTasks.add(m); + } + lock.unlock(); + } + + /** + * Animates a markerWithPosition some time in the future. + * + * @param marker the markerWithPosition to animate. + * @param from the position to animate from. + * @param to the position to animate to. + */ + public void animate(MarkerWithPosition marker, LatLng from, LatLng to) { + lock.lock(); + AnimationTask task = new AnimationTask(marker, from, to); + + for (AnimationTask existingTask : ongoingAnimations) { + if (existingTask.marker.getId().equals(task.marker.getId())) { + existingTask.cancel(); + break; + } + } + + mAnimationTasks.add(task); + ongoingAnimations.add(task); + lock.unlock(); + } + + /** + * Animates a markerWithPosition some time in the future, and removes it when the animation + * is complete. + * + * @param marker the markerWithPosition to animate. + * @param from the position to animate from. + * @param to the position to animate to. + */ + public void animateThenRemove(MarkerWithPosition marker, LatLng from, LatLng to) { + lock.lock(); + AnimationTask animationTask = new AnimationTask(marker, from, to); + for (AnimationTask existingTask : ongoingAnimations) { + if (existingTask.marker.getId().equals(animationTask.marker.getId())) { + existingTask.cancel(); + break; + } + } + + ongoingAnimations.add(animationTask); + animationTask.removeOnAnimationComplete(mClusterManager.getMarkerManager()); + mAnimationTasks.add(animationTask); + lock.unlock(); + } + + @Override + public void handleMessage(@NonNull Message msg) { + if (!mListenerAdded) { + Looper.myQueue().addIdleHandler(this); + mListenerAdded = true; + } + removeMessages(BLANK); + + lock.lock(); + try { + + // Perform up to 10 tasks at once. + // Consider only performing 10 remove tasks, not adds and animations. + // Removes are relatively slow and are much better when batched. + for (int i = 0; i < 10; i++) { + performNextTask(); + } + + if (!isBusy()) { + mListenerAdded = false; + Looper.myQueue().removeIdleHandler(this); + // Signal any other threads that are waiting. + busyCondition.signalAll(); + } else { + // Sometimes the idle queue may not be called - schedule up some work regardless + // of whether the UI thread is busy or not. + // TODO: try to remove this. + sendEmptyMessageDelayed(BLANK, 10); + } + } finally { + lock.unlock(); + } + } + + /** + * Perform the next task. Prioritise any on-screen work. + */ + private void performNextTask() { + if (!mOnScreenRemoveMarkerTasks.isEmpty()) { + removeMarker(mOnScreenRemoveMarkerTasks.poll()); + } else if (!mAnimationTasks.isEmpty()) { + Objects.requireNonNull(mAnimationTasks.poll()).perform(); + } else if (!mOnScreenCreateMarkerTasks.isEmpty()) { + Objects.requireNonNull(mOnScreenCreateMarkerTasks.poll()).perform(this); + } else if (!mCreateMarkerTasks.isEmpty()) { + Objects.requireNonNull(mCreateMarkerTasks.poll()).perform(this); + } else if (!mRemoveMarkerTasks.isEmpty()) { + removeMarker(mRemoveMarkerTasks.poll()); + } + } + + private void removeMarker(Marker m) { + mMarkerCache.remove(m); + mClusterMarkerCache.remove(m); + mClusterManager.getMarkerManager().remove(m); + } + + /** + * @return true if there is still work to be processed. + */ + public boolean isBusy() { + try { + lock.lock(); + return !(mCreateMarkerTasks.isEmpty() && mOnScreenCreateMarkerTasks.isEmpty() && mOnScreenRemoveMarkerTasks.isEmpty() && mRemoveMarkerTasks.isEmpty() && mAnimationTasks.isEmpty()); + } finally { + lock.unlock(); + } + } + + /** + * Blocks the calling thread until all work has been processed. + */ + public void waitUntilFree() { + while (isBusy()) { + // Sometimes the idle queue may not be called - schedule up some work regardless + // of whether the UI thread is busy or not. + // TODO: try to remove this. + sendEmptyMessage(BLANK); + lock.lock(); + try { + if (isBusy()) { + busyCondition.await(); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + lock.unlock(); + } + } + } + + @Override + public boolean queueIdle() { + // When the UI is not busy, schedule some work. + sendEmptyMessage(BLANK); + return true; + } + } + + /** + * A cache of markers representing individual ClusterItems. + */ + private static class MarkerCache { + private final Map mCache = new HashMap<>(); + private final Map mCacheReverse = new HashMap<>(); + + public Marker get(T item) { + return mCache.get(item); + } + + public T get(Marker m) { + return mCacheReverse.get(m); + } + + public void put(T item, Marker m) { + mCache.put(item, m); + mCacheReverse.put(m, item); + } + + public void remove(Marker m) { + T item = mCacheReverse.get(m); + mCacheReverse.remove(m); + mCache.remove(item); + } + } + + /** + * Called before the marker for a ClusterItem is added to the map. The default implementation + * sets the marker and snippet text based on the respective item text if they are both + * available, otherwise it will set the title if available, and if not it will set the marker + * title to the item snippet text if that is available. + *

+ * The first time {@link ClusterManager#cluster()} is invoked on a set of items + * {@link #onBeforeClusterItemRendered(ClusterItem, MarkerOptions)} will be called and + * {@link #onClusterItemUpdated(ClusterItem, Marker)} will not be called. + * If an item is removed and re-added (or updated) and {@link ClusterManager#cluster()} is + * invoked again, then {@link #onClusterItemUpdated(ClusterItem, Marker)} will be called and + * {@link #onBeforeClusterItemRendered(ClusterItem, MarkerOptions)} will not be called. + * + * @param item item to be rendered + * @param markerOptions the markerOptions representing the provided item + */ + protected void onBeforeClusterItemRendered(@NonNull T item, @NonNull MarkerOptions markerOptions) { + if (item.getTitle() != null && item.getSnippet() != null) { + markerOptions.title(item.getTitle()); + markerOptions.snippet(item.getSnippet()); + } else if (item.getTitle() != null) { + markerOptions.title(item.getTitle()); + } else if (item.getSnippet() != null) { + markerOptions.title(item.getSnippet()); + } + } + + /** + * Called when a cached marker for a ClusterItem already exists on the map so the marker may + * be updated to the latest item values. Default implementation updates the title and snippet + * of the marker if they have changed and refreshes the info window of the marker if it is open. + * Note that the contents of the item may not have changed since the cached marker was created - + * implementations of this method are responsible for checking if something changed (if that + * matters to the implementation). + *

+ * The first time {@link ClusterManager#cluster()} is invoked on a set of items + * {@link #onBeforeClusterItemRendered(ClusterItem, MarkerOptions)} will be called and + * {@link #onClusterItemUpdated(ClusterItem, Marker)} will not be called. + * If an item is removed and re-added (or updated) and {@link ClusterManager#cluster()} is + * invoked again, then {@link #onClusterItemUpdated(ClusterItem, Marker)} will be called and + * {@link #onBeforeClusterItemRendered(ClusterItem, MarkerOptions)} will not be called. + * + * @param item item being updated + * @param marker cached marker that contains a potentially previous state of the item. + */ + protected void onClusterItemUpdated(@NonNull T item, @NonNull Marker marker) { + boolean changed = false; + // Update marker text if the item text changed - same logic as adding marker in CreateMarkerTask.perform() + if (item.getTitle() != null && item.getSnippet() != null) { + if (!item.getTitle().equals(marker.getTitle())) { + marker.setTitle(item.getTitle()); + changed = true; + } + if (!item.getSnippet().equals(marker.getSnippet())) { + marker.setSnippet(item.getSnippet()); + changed = true; + } + } else if (item.getSnippet() != null && !item.getSnippet().equals(marker.getTitle())) { + marker.setTitle(item.getSnippet()); + changed = true; + } else if (item.getTitle() != null && !item.getTitle().equals(marker.getTitle())) { + marker.setTitle(item.getTitle()); + changed = true; + } + // Update marker position if the item changed position + if (!marker.getPosition().equals(item.getPosition())) { + marker.setPosition(item.getPosition()); + if (item.getZIndex() != null) { + marker.setZIndex(item.getZIndex()); + } + changed = true; + } + if (changed && marker.isInfoWindowShown()) { + // Force a refresh of marker info window contents + marker.showInfoWindow(); + } + } + + /** + * Called before the marker for a Cluster is added to the map. + * The default implementation draws a circle with a rough count of the number of items. + *

+ * The first time {@link ClusterManager#cluster()} is invoked on a set of items + * {@link #onBeforeClusterRendered(Cluster, MarkerOptions)} will be called and + * {@link #onClusterUpdated(Cluster, Marker)} will not be called. If an item is removed and + * re-added (or updated) and {@link ClusterManager#cluster()} is invoked + * again, then {@link #onClusterUpdated(Cluster, Marker)} will be called and + * {@link #onBeforeClusterRendered(Cluster, MarkerOptions)} will not be called. + * + * @param cluster cluster to be rendered + * @param markerOptions markerOptions representing the provided cluster + */ + protected void onBeforeClusterRendered(@NonNull Cluster cluster, @NonNull MarkerOptions markerOptions) { + // TODO: consider adding anchor(.5, .5) (Individual markers will overlap more often) + markerOptions.icon(getDescriptorForCluster(cluster)); + } + + /** + * Gets a BitmapDescriptor for the given cluster that contains a rough count of the number of + * items. Used to set the cluster marker icon in the default implementations of + * {@link #onBeforeClusterRendered(Cluster, MarkerOptions)} and + * {@link #onClusterUpdated(Cluster, Marker)}. + * + * @param cluster cluster to get BitmapDescriptor for + * @return a BitmapDescriptor for the marker icon for the given cluster that contains a rough + * count of the number of items. + */ + @NonNull + protected BitmapDescriptor getDescriptorForCluster(@NonNull Cluster cluster) { + int bucket = getBucket(cluster); + BitmapDescriptor descriptor = mIcons.get(bucket); + if (descriptor == null) { + mColoredCircleBackground.getPaint().setColor(getColor(bucket)); + mIconGenerator.setTextAppearance(getClusterTextAppearance(bucket)); + descriptor = BitmapDescriptorFactory.fromBitmap(mIconGenerator.makeIcon(getClusterText(bucket))); + mIcons.put(bucket, descriptor); + } + return descriptor; + } + + /** + * Called after the marker for a Cluster has been added to the map. + * + * @param cluster the cluster that was just added to the map + * @param marker the marker representing the cluster that was just added to the map + */ + protected void onClusterRendered(@NonNull Cluster cluster, @NonNull Marker marker) { + } + + /** + * Called when a cached marker for a Cluster already exists on the map so the marker may + * be updated to the latest cluster values. Default implementation updated the icon with a + * circle with a rough count of the number of items. Note that the contents of the cluster may + * not have changed since the cached marker was created - implementations of this method are + * responsible for checking if something changed (if that matters to the implementation). + *

+ * The first time {@link ClusterManager#cluster()} is invoked on a set of items + * {@link #onBeforeClusterRendered(Cluster, MarkerOptions)} will be called and + * {@link #onClusterUpdated(Cluster, Marker)} will not be called. If an item is removed and + * re-added (or updated) and {@link ClusterManager#cluster()} is invoked + * again, then {@link #onClusterUpdated(Cluster, Marker)} will be called and + * {@link #onBeforeClusterRendered(Cluster, MarkerOptions)} will not be called. + * + * @param cluster cluster being updated + * @param marker cached marker that contains a potentially previous state of the cluster + */ + protected void onClusterUpdated(@NonNull Cluster cluster, @NonNull Marker marker) { + // TODO: consider adding anchor(.5, .5) (Individual markers will overlap more often) + marker.setIcon(getDescriptorForCluster(cluster)); + } + + /** + * Called after the marker for a ClusterItem has been added to the map. + * + * @param clusterItem the item that was just added to the map + * @param marker the marker representing the item that was just added to the map + */ + protected void onClusterItemRendered(@NonNull T clusterItem, @NonNull Marker marker) { + } + + /** + * Get the marker from a ClusterItem + * + * @param clusterItem ClusterItem which you will obtain its marker + * @return a marker from a ClusterItem or null if it does not exists + */ + public Marker getMarker(T clusterItem) { + return mMarkerCache.get(clusterItem); + } + + /** + * Get the ClusterItem from a marker + * + * @param marker which you will obtain its ClusterItem + * @return a ClusterItem from a marker or null if it does not exists + */ + public T getClusterItem(Marker marker) { + return mMarkerCache.get(marker); + } + + /** + * Get the marker from a Cluster + * + * @param cluster which you will obtain its marker + * @return a marker from a cluster or null if it does not exists + */ + public Marker getMarker(Cluster cluster) { + return mClusterMarkerCache.get(cluster); + } + + /** + * Get the Cluster from a marker + * + * @param marker which you will obtain its Cluster + * @return a Cluster from a marker or null if it does not exists + */ + public Cluster getCluster(Marker marker) { + return mClusterMarkerCache.get(marker); + } + + /** + * Creates markerWithPosition(s) for a particular cluster, animating it if necessary. + */ + private class CreateMarkerTask { + private final Cluster cluster; + private final Set newMarkers; + private final LatLng animateFrom; + + /** + * @param c the cluster to render. + * @param markersAdded a collection of markers to append any created markers. + * @param animateFrom the location to animate the markerWithPosition from, or null if no + * animation is required. + */ + public CreateMarkerTask(Cluster c, Set markersAdded, LatLng animateFrom) { + this.cluster = c; + this.newMarkers = markersAdded; + this.animateFrom = animateFrom; + } + + private void perform(MarkerModifier markerModifier) { + // Don't show small clusters. Render the markers inside, instead. + if (!shouldRenderAsCluster(cluster)) { + for (T item : cluster.getItems()) { + Marker marker = mMarkerCache.get(item); + MarkerWithPosition markerWithPosition; + LatLng currentLocation = item.getPosition(); + if (marker == null) { + MarkerOptions markerOptions = new MarkerOptions(); + if (animateFrom != null) { + markerOptions.position(animateFrom); + } else if (mClusterMarkerCache.mCache.keySet().iterator().hasNext() && mClusterMarkerCache.mCache.keySet().iterator().next().getItems().contains(item)) { + T foundItem = null; + for (Cluster cluster : mClusterMarkerCache.mCache.keySet()) { + for (T clusterItem : cluster.getItems()) { + if (clusterItem.equals(item)) { + foundItem = clusterItem; + break; + } + } + } + currentLocation = foundItem.getPosition(); + markerOptions.position(currentLocation); + } else { + markerOptions.position(item.getPosition()); + if (item.getZIndex() != null) { + markerOptions.zIndex(item.getZIndex()); + } + } + onBeforeClusterItemRendered(item, markerOptions); + marker = mClusterManager.getMarkerCollection().addMarker(markerOptions); + markerWithPosition = new MarkerWithPosition<>(marker, item); + mMarkerCache.put(item, marker); + if (animateFrom != null) { + markerModifier.animate(markerWithPosition, animateFrom, item.getPosition()); + } else if (currentLocation != null) { + markerModifier.animate(markerWithPosition, currentLocation, item.getPosition()); + } + } else { + markerWithPosition = new MarkerWithPosition<>(marker, item); + markerModifier.animate(markerWithPosition, marker.getPosition(), item.getPosition()); + } + onClusterItemRendered(item, marker); + newMarkers.add(markerWithPosition); + } + return; + } + + // Handle cluster markers + Marker marker = mClusterMarkerCache.get(cluster); + MarkerWithPosition markerWithPosition; + if (marker == null) { + MarkerOptions markerOptions = new MarkerOptions().position(animateFrom == null ? cluster.getPosition() : animateFrom); + onBeforeClusterRendered(cluster, markerOptions); + marker = mClusterManager.getClusterMarkerCollection().addMarker(markerOptions); + mClusterMarkerCache.put(cluster, marker); + markerWithPosition = new MarkerWithPosition(marker, null); + if (animateFrom != null) { + markerModifier.animate(markerWithPosition, animateFrom, cluster.getPosition()); + } + } else { + markerWithPosition = new MarkerWithPosition(marker, null); + onClusterUpdated(cluster, marker); + } + onClusterRendered(cluster, marker); + newMarkers.add(markerWithPosition); + } + } + + /** + * A Marker and its position. {@link Marker#getPosition()} must be called from the UI thread, so this + * object allows lookup from other threads. + */ + private static class MarkerWithPosition { + private final Marker marker; + private final T clusterItem; + private LatLng position; + + private MarkerWithPosition(Marker marker, T clusterItem) { + this.marker = marker; + this.clusterItem = clusterItem; + position = marker.getPosition(); + } + + @Override + public boolean equals(Object other) { + if (other instanceof MarkerWithPosition) { + return marker.equals(((MarkerWithPosition) other).marker); + } + return false; + } + + @Override + public int hashCode() { + return marker.hashCode(); + } + } + + private static final TimeInterpolator ANIMATION_INTERP = new DecelerateInterpolator(); + + /** + * Animates a markerWithPosition from one position to another. TODO: improve performance for + * slow devices (e.g. Nexus S). + */ + private class AnimationTask extends AnimatorListenerAdapter implements ValueAnimator.AnimatorUpdateListener { + private final MarkerWithPosition markerWithPosition; + private final Marker marker; + private final LatLng from; + private final LatLng to; + private boolean mRemoveOnComplete; + private MarkerManager mMarkerManager; + private ValueAnimator valueAnimator; + + private AnimationTask(MarkerWithPosition markerWithPosition, LatLng from, LatLng to) { + this.markerWithPosition = markerWithPosition; + this.marker = markerWithPosition.marker; + this.from = from; + this.to = to; + } + + public void perform() { + valueAnimator = ValueAnimator.ofFloat(0.0f, 1.0f); + valueAnimator.setInterpolator(ANIMATION_INTERP); + valueAnimator.setDuration(mAnimationDurationMs); + valueAnimator.addUpdateListener(this); + valueAnimator.addListener(this); + valueAnimator.start(); + } + + public void cancel() { + if (Looper.myLooper() != Looper.getMainLooper()) { + new Handler(Looper.getMainLooper()).post(this::cancel); + return; + } + markerWithPosition.position = to; + mRemoveOnComplete = false; + valueAnimator.cancel(); + ongoingAnimations.remove(this); + } + + @Override + public void onAnimationEnd(Animator animation) { + if (mRemoveOnComplete) { + mMarkerCache.remove(marker); + mClusterMarkerCache.remove(marker); + mMarkerManager.remove(marker); + } + markerWithPosition.position = to; + + // Remove the task from the queue + ongoingAnimations.remove(this); + } + + public void removeOnAnimationComplete(MarkerManager markerManager) { + mMarkerManager = markerManager; + mRemoveOnComplete = true; + } + + @Override + public void onAnimationUpdate(@NonNull ValueAnimator valueAnimator) { + if (to == null || from == null || marker == null) { + return; + } + float fraction = valueAnimator.getAnimatedFraction(); + double lat = (to.latitude - from.latitude) * fraction + from.latitude; + double lngDelta = to.longitude - from.longitude; + + // Take the shortest path across the 180th meridian. + if (Math.abs(lngDelta) > 180) { + lngDelta -= Math.signum(lngDelta) * 360; + } + double lng = lngDelta * fraction + from.longitude; + LatLng position = new LatLng(lat, lng); + marker.setPosition(position); + markerWithPosition.position = position; + } + } +} 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 4d09c720d..f289c2796 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 @@ -145,19 +145,11 @@ public DefaultClusterRenderer(Context context, GoogleMap map, ClusterManager @Override public void onAdd() { - mClusterManager.getMarkerCollection().setOnMarkerClickListener(new GoogleMap.OnMarkerClickListener() { - @Override - public boolean onMarkerClick(@NonNull Marker marker) { - return mItemClickListener != null && mItemClickListener.onClusterItemClick(mMarkerCache.get(marker)); - } - }); + mClusterManager.getMarkerCollection().setOnMarkerClickListener(marker -> mItemClickListener != null && mItemClickListener.onClusterItemClick(mMarkerCache.get(marker))); - mClusterManager.getMarkerCollection().setOnInfoWindowClickListener(new GoogleMap.OnInfoWindowClickListener() { - @Override - public void onInfoWindowClick(@NonNull Marker marker) { - if (mItemInfoWindowClickListener != null) { - mItemInfoWindowClickListener.onClusterItemInfoWindowClick(mMarkerCache.get(marker)); - } + mClusterManager.getMarkerCollection().setOnInfoWindowClickListener(marker -> { + if (mItemInfoWindowClickListener != null) { + mItemInfoWindowClickListener.onClusterItemInfoWindowClick(mMarkerCache.get(marker)); } }); @@ -1168,7 +1160,7 @@ public void removeOnAnimationComplete(MarkerManager markerManager) { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { if (to == null || from == null || marker == null) { - return; + return; } float fraction = valueAnimator.getAnimatedFraction(); From 1ba553d86ce60c5e3ec598cb2e9114e8ddd7517c Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 16 Jan 2025 20:14:55 +0000 Subject: [PATCH 6/7] chore(release): 3.10.0 [skip ci] # [3.10.0](https://github.com/googlemaps/android-maps-utils/compare/v3.9.0...v3.10.0) (2025-01-16) ### Bug Fixes * update README to template ([#1434](https://github.com/googlemaps/android-maps-utils/issues/1434)) ([cf0b32e](https://github.com/googlemaps/android-maps-utils/commit/cf0b32e3eb9d02df434152a1184cecde270a10e3)) ### Features * implementation of diff ([#1438](https://github.com/googlemaps/android-maps-utils/issues/1438)) ([c1edbaf](https://github.com/googlemaps/android-maps-utils/commit/c1edbafb8c0f023ec29f58d97838a1f988b29c11)) * removed dependabot automerge flow ([#1430](https://github.com/googlemaps/android-maps-utils/issues/1430)) ([f7f4801](https://github.com/googlemaps/android-maps-utils/commit/f7f48013f53bb092d780583cedf0a711fd32d4bb)) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bd95332af..b2d79d554 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ dependencies { // Utilities for Maps SDK for Android (requires Google Play Services) // You do not need to add a separate dependency for the Maps SDK for Android // since this library builds in the compatible version of the Maps SDK. - implementation 'com.google.maps.android:android-maps-utils:3.10.1' + implementation 'com.google.maps.android:android-maps-utils:3.10.0' // Optionally add the Kotlin Extensions (KTX) for full Kotlin language support // See latest version at https://github.com/googlemaps/android-maps-ktx From fc66e48892beebe2b435a7d785ce9d5998071a59 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Thu, 16 Jan 2025 13:56:09 -0700 Subject: [PATCH 7/7] chore: Cleaned up cluster diff demo (#1446) --- .../demo/ClusteringDiffDemoActivity.java | 253 +++++++++--------- 1 file changed, 124 insertions(+), 129 deletions(-) diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDiffDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDiffDemoActivity.java index 08f156071..294f1484a 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDiffDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDiffDemoActivity.java @@ -46,18 +46,32 @@ import java.util.ArrayList; import java.util.List; +enum City { + ENFIELD(new LatLng(51.6524, -0.0838), "Enfield"), + ILFORD(new LatLng(51.5590, -0.0815), "Ilford"), + LONDON(new LatLng(51.5074, -0.1278), "London"); + + public final LatLng latLng; + public final String label; + + City(LatLng latLng, String label) { + this.latLng = latLng; + this.label = label; + } +} + /** * Demonstrates how to apply a diff to the current Cluster */ -public class ClusteringDiffDemoActivity extends BaseDemoActivity implements ClusterManager.OnClusterClickListener, ClusterManager.OnClusterInfoWindowClickListener, ClusterManager.OnClusterItemClickListener, ClusterManager.OnClusterItemInfoWindowClickListener { - private ClusterManager mClusterManager; - private Person itemtoUpdate = new Person(ENFIELD, "Teach", R.drawable.teacher); - - private static final LatLng ENFIELD = new LatLng(51.6524, -0.0838); - private static final LatLng ILFORD = new LatLng(51.5590, -0.0815); +public class ClusteringDiffDemoActivity extends BaseDemoActivity + implements ClusterManager.OnClusterClickListener, + ClusterManager.OnClusterInfoWindowClickListener, + ClusterManager.OnClusterItemClickListener, + ClusterManager.OnClusterItemInfoWindowClickListener { - private static final LatLng LONDON = new LatLng(51.5074, -0.1278); - LatLng midpoint = getMidpoint(); + private final LatLng midpoint = getMidpoint(); + private ClusterManager mClusterManager; + private Person itemToUpdate = new Person(City.ENFIELD.latLng, "Teach", R.drawable.teacher); private int currentLocationIndex = 0; protected int getLayoutId() { @@ -71,11 +85,108 @@ public void onMapReady(@NonNull GoogleMap map) { getMap().animateCamera(CameraUpdateFactory.newLatLngZoom(midpoint, 12)); } - private LatLng getMidpoint() { - double latitude = (ClusteringDiffDemoActivity.ENFIELD.latitude + ClusteringDiffDemoActivity.ILFORD.latitude + ClusteringDiffDemoActivity.LONDON.latitude) / 3; - double longitude = (ClusteringDiffDemoActivity.ENFIELD.longitude + ClusteringDiffDemoActivity.ILFORD.longitude + ClusteringDiffDemoActivity.LONDON.longitude) / 3; - return new LatLng(latitude, longitude); + double latitude = 0.0; + double longitude = 0.0; + + for (City city: City.values()) { + latitude += city.latLng.latitude; + longitude += city.latLng.longitude; + } + + int numCities = City.values().length; + + return new LatLng(latitude / numCities, longitude / numCities); + } + + @Override + public boolean onClusterClick(Cluster cluster) { + // Show a toast with some info when the cluster is clicked. + String firstName = cluster.getItems().iterator().next().name; + Toast.makeText(this, cluster.getSize() + " (including " + firstName + ")", 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()); + } + // Get the LatLngBounds + final LatLngBounds bounds = builder.build(); + + // Animate camera to the bounds + try { + getMap().animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, 200)); + } 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(Person item) { + // Does nothing, but you could go into the user's profile page, for example. + return false; + } + + @Override + public void onClusterItemInfoWindowClick(Person item) { + // Does nothing, but you could go into the user's profile page, for example. + } + + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 6)); + } + + mClusterManager = new ClusterManager<>(this, getMap()); + mClusterManager.setRenderer(new PersonRenderer()); + getMap().setOnCameraIdleListener(mClusterManager); + getMap().setOnMarkerClickListener(mClusterManager); + getMap().setOnInfoWindowClickListener(mClusterManager); + mClusterManager.setOnClusterClickListener(this); + mClusterManager.setOnClusterInfoWindowClickListener(this); + mClusterManager.setOnClusterItemClickListener(this); + mClusterManager.setOnClusterItemInfoWindowClickListener(this); + + addItems(); + mClusterManager.cluster(); + } + + private void addItems() { + // Marker in Enfield + mClusterManager.addItem(new Person(City.ENFIELD.latLng, "John", R.drawable.john)); + + // Marker in the center of London + itemToUpdate = new Person(City.LONDON.latLng, "Teach", R.drawable.teacher); + mClusterManager.addItem(itemToUpdate); + } + + private void rotateLocation() { + // Update the current index to cycle through locations (0 = Enfield, 1 = Olford, 2 = London) + currentLocationIndex = (currentLocationIndex + 1) % City.values().length; + + City nextCity = City.values()[currentLocationIndex]; + + LatLng newLocation = nextCity.latLng; + String cityName = nextCity.label; + + Log.d("ClusterTest", "Item rotated to: " + newLocation.toString() + ", City: " + cityName); + + if (itemToUpdate != null) { + itemToUpdate = new Person(newLocation, "Teach", R.drawable.teacher); + mClusterManager.updateItem(itemToUpdate); // Update the marker + mClusterManager.cluster(); + } } /** @@ -108,9 +219,7 @@ public PersonRenderer() { @Override protected void onBeforeClusterItemRendered(@NonNull Person person, @NonNull MarkerOptions markerOptions) { // Draw a single person - show their profile photo and set the info window to show their name - markerOptions - .icon(getItemIcon(person)) - .title(person.name); + markerOptions.icon(getItemIcon(person)).title(person.name); } @Override @@ -180,118 +289,4 @@ protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { return cluster.getSize() >= 2; } } - - - @Override - public boolean onClusterClick(Cluster cluster) { - // Show a toast with some info when the cluster is clicked. - String firstName = cluster.getItems().iterator().next().name; - Toast.makeText(this, cluster.getSize() + " (including " + firstName + ")", 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()); - } - // Get the LatLngBounds - final LatLngBounds bounds = builder.build(); - - // Animate camera to the bounds - try { - getMap().animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, 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(Person item) { - // Does nothing, but you could go into the user's profile page, for example. - return false; - } - - @Override - public void onClusterItemInfoWindowClick(Person item) { - // Does nothing, but you could go into the user's profile page, for example. - } - - @Override - protected void startDemo(boolean isRestore) { - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 6)); - } - - mClusterManager = new ClusterManager<>(this, getMap()); - mClusterManager.setRenderer(new PersonRenderer()); - getMap().setOnCameraIdleListener(mClusterManager); - getMap().setOnMarkerClickListener(mClusterManager); - getMap().setOnInfoWindowClickListener(mClusterManager); - mClusterManager.setOnClusterClickListener(this); - mClusterManager.setOnClusterInfoWindowClickListener(this); - mClusterManager.setOnClusterItemClickListener(this); - mClusterManager.setOnClusterItemInfoWindowClickListener(this); - - addItems(); - mClusterManager.cluster(); - } - - private void addItems() { - // Marker in Enfield - mClusterManager.addItem(new Person(ENFIELD, "John", R.drawable.john)); - - // Marker in the center of London - itemtoUpdate = new Person(LONDON, "Teach", R.drawable.teacher); - mClusterManager.addItem(itemtoUpdate); - } - - private void rotateLocation() { - // Update the current index to cycle through locations (0 = Enfield, 1 = Olford, 2 = London) - currentLocationIndex = (currentLocationIndex + 1) % 3; - - - LatLng newLocation = switch (currentLocationIndex) { - case 0 -> ENFIELD; - case 1 -> ILFORD; - default -> LONDON; - }; - - String cityName = getCityName(newLocation); - - Log.d("ClusterTest", "Item rotated to: " + newLocation.toString() + ", City: " + cityName); - - if (itemtoUpdate != null) { - itemtoUpdate = new Person(newLocation, "Teach", R.drawable.teacher); - mClusterManager.updateItem(itemtoUpdate); // Update the marker - mClusterManager.cluster(); - } - } - - // Method to map LatLng to city name - private String getCityName(LatLng location) { - if (areLocationsEqual(location, ENFIELD)) { - return "Enfield"; - } else if (areLocationsEqual(location, ILFORD)) { - return "Ilford"; - } else if (areLocationsEqual(location, LONDON)) { - return "London"; - } else { - return "Unknown City"; // Default case if location is not recognized - } - } - - // Method to compare LatLng objects with a tolerance - private boolean areLocationsEqual(LatLng loc1, LatLng loc2) { - return Math.abs(loc1.latitude - loc2.latitude) < 1E-5 && - Math.abs(loc1.longitude - loc2.longitude) < 1E-5; - } } \ No newline at end of file