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();