diff --git a/demo/build.gradle b/demo/build.gradle index b0b5559f6..c321b54a8 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -48,7 +48,6 @@ android { dependencies { // [START_EXCLUDE silent] - implementation 'com.google.android.gms:play-services-maps:18.1.0' implementation project(':library') implementation 'androidx.appcompat:appcompat:1.7.0-alpha01' diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index fd890c5e6..2f75c0c49 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -59,6 +59,7 @@ + diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/CustomAdvancedMarkerClusteringDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/CustomAdvancedMarkerClusteringDemoActivity.java new file mode 100644 index 000000000..3f08398ef --- /dev/null +++ b/demo/src/main/java/com/google/maps/android/utils/demo/CustomAdvancedMarkerClusteringDemoActivity.java @@ -0,0 +1,227 @@ +/* + * Copyright 2023 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.utils.demo; + +import java.util.Random; + +import android.graphics.Color; +import android.util.Log; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.MapsInitializer; +import com.google.android.gms.maps.OnMapsSdkInitializedCallback; +import com.google.android.gms.maps.model.AdvancedMarker; +import com.google.android.gms.maps.model.AdvancedMarkerOptions; +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.PinConfig; +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.DefaultAdvancedMarkersClusterRenderer; +import com.google.maps.android.utils.demo.model.Person; + +import androidx.annotation.NonNull; + +/** + * This sample demonstrates how to make use of the new Advanced Markers. + */ +public class CustomAdvancedMarkerClusteringDemoActivity extends BaseDemoActivity implements + ClusterManager.OnClusterClickListener, ClusterManager.OnClusterInfoWindowClickListener, + ClusterManager.OnClusterItemClickListener, ClusterManager.OnClusterItemInfoWindowClickListener, + OnMapsSdkInitializedCallback { + private ClusterManager mClusterManager; + private final Random mRandom = new Random(1984); + + @Override + public void onMapsSdkInitialized(@NonNull MapsInitializer.Renderer renderer) { + switch (renderer) { + case LATEST: + Log.d("MapsDemo", "The latest version of the renderer is used."); + break; + case LEGACY: + Log.d("MapsDemo", "The legacy version of the renderer is used."); + break; + default: + break; + } + } + + private class AdvancedMarkerRenderer extends DefaultAdvancedMarkersClusterRenderer { + + public AdvancedMarkerRenderer() { + super(getApplicationContext(), getMap(), mClusterManager); + } + + @Override + protected void onBeforeClusterItemRendered(@NonNull Person person, + @NonNull AdvancedMarkerOptions markerOptions) { + markerOptions + .icon(BitmapDescriptorFactory.fromPinConfig(getPinConfig().build())) + .title(person.name); + } + + @Override + protected void onClusterItemUpdated(@NonNull Person person, @NonNull Marker marker) { + // Same implementation as onBeforeClusterItemRendered() (to update cached markers) + marker.setIcon(BitmapDescriptorFactory.fromPinConfig(getPinConfig().build())); + marker.setTitle(person.name); + } + + private PinConfig.Builder getPinConfig() { + PinConfig.Builder pinConfigBuilder = PinConfig.builder(); + pinConfigBuilder.setBackgroundColor(Color.MAGENTA); + pinConfigBuilder.setBorderColor(getResources().getColor(R.color.colorPrimaryDark)); + return pinConfigBuilder; + } + + private View addTextAsMarker(int size) { + TextView textView = new TextView(getApplicationContext()); + textView.setText("I am a cluster of size " + size); + textView.setBackgroundColor(Color.BLACK); + textView.setTextColor(Color.YELLOW); + return textView; + } + + @Override + protected void onBeforeClusterRendered(@NonNull Cluster cluster, + @NonNull AdvancedMarkerOptions markerOptions) { + markerOptions.iconView(addTextAsMarker(cluster.getSize())); + } + + @Override + protected void onClusterUpdated(@NonNull Cluster cluster, AdvancedMarker marker) { + marker.setIconView(addTextAsMarker(cluster.getSize())); + } + + + @Override + protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { + // Always render clusters. + return cluster.getSize() > 1; + } + } + + + @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), 9.5f)); + } + + // This line is extremely important to initialise the advanced markers. Without it, advanced markers will not work. + MapsInitializer.initialize(getApplicationContext(), MapsInitializer.Renderer.LATEST, this); + + mClusterManager = new ClusterManager<>(this, getMap()); + mClusterManager.setRenderer(new AdvancedMarkerRenderer()); + 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() { + // http://www.flickr.com/photos/sdasmarchives/5036248203/ + mClusterManager.addItem(new Person(position(), "Walter", R.drawable.walter)); + + // http://www.flickr.com/photos/usnationalarchives/4726917149/ + mClusterManager.addItem(new Person(position(), "Gran", R.drawable.gran)); + + // http://www.flickr.com/photos/nypl/3111525394/ + mClusterManager.addItem(new Person(position(), "Ruth", R.drawable.ruth)); + + // http://www.flickr.com/photos/smithsonian/2887433330/ + mClusterManager.addItem(new Person(position(), "Stefan", R.drawable.stefan)); + + // http://www.flickr.com/photos/library_of_congress/2179915182/ + mClusterManager.addItem(new Person(position(), "Mechanic", R.drawable.mechanic)); + + // http://www.flickr.com/photos/nationalmediamuseum/7893552556/ + mClusterManager.addItem(new Person(position(), "Yeats", R.drawable.yeats)); + + // http://www.flickr.com/photos/sdasmarchives/5036231225/ + mClusterManager.addItem(new Person(position(), "John", R.drawable.john)); + + // http://www.flickr.com/photos/anmm_thecommons/7694202096/ + mClusterManager.addItem(new Person(position(), "Trevor the Turtle", R.drawable.turtle)); + + // http://www.flickr.com/photos/usnationalarchives/4726892651/ + mClusterManager.addItem(new Person(position(), "Teach", R.drawable.teacher)); + } + + private LatLng position() { + return new LatLng(random(51.6723432, 51.38494009999999), random(0.148271, -0.3514683)); + } + + private double random(double min, double max) { + return mRandom.nextDouble() * (max - min) + min; + } +} \ 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 26a7a728e..fcd22813d 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 @@ -41,6 +41,7 @@ protected void onCreate(Bundle savedInstanceState) { mListView = findViewById(R.id.list); addDemo("Clustering", ClusteringDemoActivity.class); + addDemo("Advanced Markers Clustering Example", CustomAdvancedMarkerClusteringDemoActivity.class); addDemo("Clustering: Custom Look", CustomMarkerClusteringDemoActivity.class); addDemo("Clustering: 2K markers", BigClusteringDemoActivity.class); addDemo("Clustering: 20K only visible markers", VisibleClusteringDemoActivity.class); diff --git a/demo/src/main/res/layout/map.xml b/demo/src/main/res/layout/map.xml index 8a3047660..763773e73 100644 --- a/demo/src/main/res/layout/map.xml +++ b/demo/src/main/res/layout/map.xml @@ -15,8 +15,11 @@ ~ limitations under the License. --> + diff --git a/library/build.gradle b/library/build.gradle index bb2fc7d71..5d8cceed2 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -60,7 +60,8 @@ android { } dependencies { - implementation 'com.google.android.gms:play-services-maps:18.1.0' + // We are adding api() for the Maps SDK for Android, so it propagates to the app-level modules. + api 'com.google.android.gms:play-services-maps:18.2.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.core:core-ktx:1.12.0' diff --git a/library/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.java b/library/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.java new file mode 100644 index 000000000..f79207cde --- /dev/null +++ b/library/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.java @@ -0,0 +1,1185 @@ +/* + * Copyright 2023 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.clustering.view; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +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; + +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 com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.Projection; +import com.google.android.gms.maps.model.AdvancedMarker; +import com.google.android.gms.maps.model.AdvancedMarkerOptions; +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.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 androidx.annotation.NonNull; +import androidx.annotation.StyleRes; + +/** + * The default view for a ClusterManager. Markers are animated in and out of clusters. + */ +public class DefaultAdvancedMarkersClusterRenderer 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 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 SparseArray mIcons = new SparseArray<>(); + + /** + * Markers for single ClusterItems. + */ + private MarkerCache mMarkerCache = new MarkerCache<>(); + + /** + * If cluster size is less than this size, display individual markers. + */ + private int mMinClusterSize = 4; + + /** + * The currently displayed set of clusters. + */ + private Set> mClusters; + + /** + * Markers for Clusters. + */ + private MarkerCache> mClusterMarkerCache = new MarkerCache<>(); + + /** + * The target zoom level for the current set of clusters. + */ + private float mZoom; + + private final ViewModifier mViewModifier = new ViewModifier(); + + 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 DefaultAdvancedMarkersClusterRenderer(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(new GoogleMap.OnMarkerClickListener() { + @Override + public boolean onMarkerClick(@NonNull Marker marker) { + return 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().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 { + 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; + } + + /** + * Determines if the new clusters should be rendered on the map, given the old clusters. This + * method is primarily for optimization of performance, and the default implementation simply + * checks if the new clusters are equal to the old clusters, and if so, it returns false. + *

+ * However, there are cases where you may want to re-render the clusters even if they didn't + * change. For example, if you want a cluster with one item to render as a cluster above + * a certain zoom level and as a marker below a certain zoom level (even if the contents of the + * clusters themselves did not change). In this case, you could check the zoom level in an + * implementation of this method and if that zoom level threshold is crossed return true, else + * {@code return super.shouldRender(oldClusters, newClusters)}. + *

+ * Note that always returning true from this method could potentially have negative performance + * implications as clusters will be re-rendered on each pass even if they don't change. + * + * @param oldClusters The clusters from the previous iteration of the clustering algorithm + * @param newClusters The clusters from the current iteration of the clustering algorithm + * @return true if the new clusters should be rendered on the map, and false if they should not. This + * method is primarily for optimization of performance, and the default implementation simply + * checks if the new clusters are equal to the old clusters, and if so, it returns false. + */ + protected boolean shouldRender(@NonNull Set> oldClusters, @NonNull Set> newClusters) { + return !newClusters.equals(oldClusters); + } + + /** + * Transforms the current view (represented by DefaultClusterRenderer.mClusters and DefaultClusterRenderer.mZoom) to a + * new zoom level and set of clusters. + *

+ * 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; + } + + /** + * A callback to be run when all work has been completed. + * + * @param callback + */ + 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() { + if (!shouldRender(immutableOf(DefaultAdvancedMarkersClusterRenderer.this.mClusters), immutableOf(clusters))) { + mCallback.run(); + return; + } + + final MarkerModifier markerModifier = new MarkerModifier(); + + final float zoom = mMapZoom; + final boolean zoomingIn = zoom > mZoom; + final float zoomDelta = zoom - mZoom; + + final Set markersToRemove = mMarkers; + // Prevent crashes: https://issuetracker.google.com/issues/35827242 + LatLngBounds visibleBounds; + try { + visibleBounds = mProjection.getVisibleRegion().latLngBounds; + } catch (Exception e) { + e.printStackTrace(); + visibleBounds = LatLngBounds.builder() + .include(new LatLng(0, 0)) + .build(); + } + // TODO: Add some padding, so that markers can animate in from off-screen. + + // Find all of the existing clusters that are on-screen. These are candidates for + // markers to animate from. + List existingClustersOnScreen = null; + if (DefaultAdvancedMarkersClusterRenderer.this.mClusters != null && mAnimate) { + existingClustersOnScreen = new ArrayList<>(); + for (Cluster c : DefaultAdvancedMarkersClusterRenderer.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 (zoomingIn && onScreen && mAnimate) { + Point point = mSphericalMercatorProjection.toPoint(c.getPosition()); + Point closest = findClosestCluster(existingClustersOnScreen, point); + if (closest != null) { + LatLng animateTo = mSphericalMercatorProjection.toLatLng(closest); + markerModifier.add(true, new CreateMarkerTask(c, newMarkers, animateTo)); + } 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); + } + } + } + + // Remove the old markers, animating them into clusters if zooming out. + for (final MarkerWithPosition marker : markersToRemove) { + boolean onScreen = visibleBounds.contains(marker.position); + // Don't animate when zooming out more than 3 zoom levels. + // TODO: drop animation based on speed of device & number of markers to animate. + if (!zoomingIn && zoomDelta > -3 && 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 { + markerModifier.remove(true, marker.marker); + } + } else { + markerModifier.remove(onScreen, marker.marker); + } + } + + markerModifier.waitUntilFree(); + + mMarkers = newMarkers; + DefaultAdvancedMarkersClusterRenderer.this.mClusters = clusters; + mZoom = zoom; + + 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 Set> immutableOf(Set> clusters) { + return clusters != null ? Collections.unmodifiableSet(clusters) : Collections.emptySet(); + } + + private static double distanceSquared(Point a, Point b) { + return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y); + } + + 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 Queue mCreateMarkerTasks = new LinkedList<>(); + private Queue mOnScreenCreateMarkerTasks = new LinkedList<>(); + private Queue mRemoveMarkerTasks = new LinkedList<>(); + private Queue mOnScreenRemoveMarkerTasks = new LinkedList<>(); + private 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(); + mAnimationTasks.add(new AnimationTask(marker, from, to)); + 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); + animationTask.removeOnAnimationComplete(mClusterManager.getMarkerManager()); + mAnimationTasks.add(animationTask); + lock.unlock(); + } + + @Override + public void handleMessage(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()) { + mAnimationTasks.poll().perform(); + } else if (!mOnScreenCreateMarkerTasks.isEmpty()) { + mOnScreenCreateMarkerTasks.poll().perform(this); + } else if (!mCreateMarkerTasks.isEmpty()) { + 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 Map mCache = new HashMap<>(); + private 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, AdvancedMarkerOptions)} 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, AdvancedMarkerOptions)} will not be called. + * + * @param item item to be rendered + * @param advancedMarkerOptions the AdvancedMarkerOptions representing the provided item + */ + protected void onBeforeClusterItemRendered(@NonNull T item, @NonNull AdvancedMarkerOptions advancedMarkerOptions) { + if (item.getTitle() != null && item.getSnippet() != null) { + advancedMarkerOptions.title(item.getTitle()); + advancedMarkerOptions.snippet(item.getSnippet()); + } else if (item.getTitle() != null) { + advancedMarkerOptions.title(item.getTitle()); + } else if (item.getSnippet() != null) { + advancedMarkerOptions.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, AdvancedMarkerOptions)} 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, AdvancedMarkerOptions)} 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, AdvancedMarkerOptions)} will be called and + * {@link #onClusterUpdated(Cluster, AdvancedMarker)} 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, AdvancedMarker)} will be called and + * {@link #onBeforeClusterRendered(Cluster, AdvancedMarkerOptions)} will not be called. + * + * @param cluster cluster to be rendered + * @param advancedMarkerOptions markerOptions representing the provided cluster + */ + protected void onBeforeClusterRendered(@NonNull Cluster cluster, @NonNull AdvancedMarkerOptions advancedMarkerOptions) { + // TODO: consider adding anchor(.5, .5) (Individual markers will overlap more often) + advancedMarkerOptions.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, AdvancedMarkerOptions)} and + * {@link #onClusterUpdated(Cluster, AdvancedMarker)}. + * + * @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, AdvancedMarkerOptions)} will be called and + * {@link #onClusterUpdated(Cluster, AdvancedMarker)} 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, AdvancedMarker)} will be called and + * {@link #onBeforeClusterRendered(Cluster, AdvancedMarkerOptions)} 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 AdvancedMarker 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()) { + AdvancedMarker marker = (AdvancedMarker)mMarkerCache.get(item); + MarkerWithPosition markerWithPosition; + if (marker == null) { + AdvancedMarkerOptions advancedMarkerOptions = new AdvancedMarkerOptions(); + if (animateFrom != null) { + advancedMarkerOptions.position(animateFrom); + } else { + advancedMarkerOptions.position(item.getPosition()); + if (item.getZIndex() != null) { + advancedMarkerOptions.zIndex(item.getZIndex()); + } + } + onBeforeClusterItemRendered(item, advancedMarkerOptions); + marker = (AdvancedMarker)mClusterManager.getMarkerCollection().addMarker(advancedMarkerOptions); + markerWithPosition = new MarkerWithPosition(marker); + mMarkerCache.put(item, marker); + if (animateFrom != null) { + markerModifier.animate(markerWithPosition, animateFrom, item.getPosition()); + } + } else { + markerWithPosition = new MarkerWithPosition(marker); + onClusterItemUpdated(item, marker); + } + onClusterItemRendered(item, marker); + newMarkers.add(markerWithPosition); + } + return; + } + + AdvancedMarker marker = (AdvancedMarker)mClusterMarkerCache.get(cluster); + MarkerWithPosition markerWithPosition; + if (marker == null) { + AdvancedMarkerOptions advancedMarkerOptions = new AdvancedMarkerOptions(). + position(animateFrom == null ? cluster.getPosition() : animateFrom); + onBeforeClusterRendered(cluster, advancedMarkerOptions); + Object object = mClusterManager.getClusterMarkerCollection().addMarker(advancedMarkerOptions); + marker = (AdvancedMarker) object; + mClusterMarkerCache.put(cluster, marker); + markerWithPosition = new MarkerWithPosition(marker); + if (animateFrom != null) { + markerModifier.animate(markerWithPosition, animateFrom, cluster.getPosition()); + } + } else { + markerWithPosition = new MarkerWithPosition(marker); + 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 LatLng position; + + private MarkerWithPosition(Marker marker) { + this.marker = marker; + 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 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 = ValueAnimator.ofFloat(0.0f, 1.0f); + valueAnimator.setInterpolator(ANIMATION_INTERP); + valueAnimator.setDuration(mAnimationDurationMs); + valueAnimator.addUpdateListener(this); + valueAnimator.addListener(this); + valueAnimator.start(); + } + + @Override + public void onAnimationEnd(Animator animation) { + if (mRemoveOnComplete) { + mMarkerCache.remove(marker); + mClusterMarkerCache.remove(marker); + mMarkerManager.remove(marker); + } + markerWithPosition.position = to; + } + + public void removeOnAnimationComplete(MarkerManager markerManager) { + mMarkerManager = markerManager; + mRemoveOnComplete = true; + } + + @Override + public void onAnimationUpdate(ValueAnimator valueAnimator) { + 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); + } + } +} \ No newline at end of file diff --git a/library/src/main/java/com/google/maps/android/collections/MarkerManager.java b/library/src/main/java/com/google/maps/android/collections/MarkerManager.java index 48d785648..cd8ca5215 100644 --- a/library/src/main/java/com/google/maps/android/collections/MarkerManager.java +++ b/library/src/main/java/com/google/maps/android/collections/MarkerManager.java @@ -19,6 +19,8 @@ import android.view.View; import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.model.AdvancedMarker; +import com.google.android.gms.maps.model.AdvancedMarkerOptions; import com.google.android.gms.maps.model.Marker; import com.google.android.gms.maps.model.MarkerOptions; @@ -145,6 +147,11 @@ public Marker addMarker(MarkerOptions opts) { return marker; } + public Marker addMarker(AdvancedMarkerOptions opts) { + Marker marker = mMap.addMarker(opts); + super.add(marker); + return marker; + } public void addAll(java.util.Collection opts) { for (MarkerOptions opt : opts) { addMarker(opt);