On this article, I am going to cowl the fundamentals of working with MapView (and MapFragment) markers, learn to implement marker clustering, i.e. grouping markers which can be situated shut to one another, and show markers. dynamic icons in markers i.e. load by url and show distant photos. Lastly, we’ll create such a map with clusters and cargo the animation (within the background).
Create a brand new undertaking in Android Studio and add an Exercise with Map actions template.
Within the newly created undertaking, open AndroidManifest.xml. There you will note a remark asking to get the API_KEY by hyperlink and add it to the metadata.
android:title="com.google.android.geo.API_KEY"
android:worth="YOUR_API_KEY" />
If all the things is appropriate, it is best to have the ability to launch the app and see a map with a marker someplace in Australia. This can be a very primary utility and in case of any issues please seek advice from the documentation or StackOverflow.
Subsequent, add dynamic marker add. In our instance, we’ll use a JSON file with marker places and obtain them utilizing Retrofit.
[
{
"lat": 59.92140394439577,
"lon": 30.445576954709395,
"icon": "1619152.jpg"
},
{
"lat": 59.93547541514004,
"lon": 30.21481515274267,
"icon": "1712315710.jpg"
},
For now we only care about the location but we will use icon
field in the following sections of this article.
Add MapApplication
class for our application. It will be responsible for loading the data in the application. Of course in real application we will use a separate class and module for that business logic and follow Clean Architecture principles but this is not the focus of this article.
class MapApplication : Application() {
private val context = CoroutineScope(Dispatchers.Default)
private val _dataFlow = MutableSharedFlow>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val dataFlow: Flow> = _dataFlow
override fun onCreate() {
super.onCreate()
val retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
val service = retrofit.create(MarkerLocationsService::class.java)
context.launch {
_dataFlow.tryEmit(service.getLocations())
}
}
}
MarkerLocationsService
is a Retrofit service that downloads our markers.
Update onMapReady
code in MapActivity
so it gets this list:
override fun onMapReady(googleMap: GoogleMap) {
mMap = googleMap
scope.launch {
(application as MapApplication).dataFlow.collect { data ->
mMap.clear()
data.forEach { marker ->
mMap.addMarker(MarkerOptions().position(LatLng(marker.lat, marker.lon)))
}
}
}
}
Great! Now we know how to display the markers received from the server. But there can be multiple markers, so add the option to add markers to the map taking into account the currently displayed area. For this we need OnCameraIdleListener
it shows that the user has interacted with the map (changed the scale or moved to a different location) which means we need to recalculate the map boundaries and show only the points mark it.
internal class BoundariesListener(
private val map: GoogleMap,
) : GoogleMap.OnCameraIdleListener {
private val _boundariesFlow = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val boundariesFlow: Flow = _boundariesFlow
override fun onCameraIdle() {
val boundaries = map.projection.visibleRegion.latLngBounds
_boundariesFlow.tryEmit(boundaries)
}
}
Now, we can assign BoundariesListener
into the map object and when the map boundary is changed we filter the markers taking into account the currently displayed area:
override fun onMapReady(googleMap: GoogleMap) {
mMap = googleMap
val boundariesListener = BoundariesListener(googleMap)
mMap.setOnCameraMoveStartedListener(boundariesListener)
mMap.setOnCameraIdleListener(boundariesListener)
scope.launch {
(application as MapApplication).dataFlow.combine(boundariesListener.boundariesFlow) { data, boundaries ->
data to boundaries
}.collect { (data, boundaries) ->
mMap.clear()
data.filter { boundaries.bounds.includesLocation(it.lat, it.lon) }
.forEach { marker ->
mMap.addMarker(MarkerOptions().position(LatLng(marker.lat, marker.lon)))
}
}
}
}
fun LatLngBounds.includesLocation(lat: Double, lon: Double): Boolean {
return this.northeast.latitude > lat && this.southwest.latitude < lat &&
this.northeast.longitude > lon && this.southwest.longitude < lon
}
Excellent! After this step, we can display the markers received from the server, on the map; however, if multiple items are placed close together, the markers will overlap, so let’s see how we can group them.
The screenshot above shows a large number of markers that can overlap and, on a larger scale, merge into a single point. That’s why Google created a library it is possible to group markers if they are close to each other. In this case, instead of several markers, we show a marker with general information. Let’s try to implement this solution in our application.
To add clustering, add a dependency on Maps SDK for Android Gadgets to the project.
dependencies {
...
implementation 'com.google.maps.android:android-maps-utils:'
}
First, we need Cluster Management that will get a big part of the job done. It processes a list of markers, deciding which of them are close to each other and, therefore, should be replaced with a group marker (cluster). The second important ingredient is ClusterRenderer
that, as the name implies, draws markers and cluster items. Also, we have a base class DefaultClusterRenderer
implements pretty much the basic logic, so we can inherit from it and only care about the important stuff. Both of these classes work with ClusterItem
store marker information such as location. It is an interface, so first let’s create its implementation:
data class MapMarker(
val titleText: String,
val location: LatLng,
) : ClusterItem {
override fun getPosition(): LatLng = location
override fun getTitle(): String? = null
override fun getSnippet(): String? = null
}
It’s very simple: we store information about the location and some marker labels. It can actually contain more information about the marker, but we’ll look at it later. Then create an instance of ClusterManager
IN onMapReady
:
override fun onMapReady(googleMap: GoogleMap) {
mMap = googleMap
val boundariesListener = BoundariesListener(googleMap)
val clusterManager = ClusterManager(this, mMap)
val mapRenderer = MapMarkersRenderer(
context = this,
callback = this,
map = mMap,
clusterManager = clusterManager
)
clusterManager.renderer = mapRenderer
Here you can see MapMarkersRenderer
is used, so add it to the project:
class MapMarkersRenderer(
context: Context,
map: GoogleMap,
clusterManager: ClusterManager,
) : DefaultClusterRenderer(context, map, clusterManager) {
private val mapMarkerView: MapMarkerView = MapMarkerView(context)
private val markerIconGenerator = IconGenerator(context)
init {
markerIconGenerator.setBackground(null)
markerIconGenerator.setContentView(mapMarkerView)
}
override fun onBeforeClusterItemRendered(clusterItem: MapMarker, markerOptions: MarkerOptions) {
val data = getItemIcon(clusterItem)
markerOptions
.icon(data.bitmapDescriptor)
.anchor(data.anchorU, data.anchorV)
}
override fun onClusterItemUpdated(clusterItem: MapMarker, marker: Marker) {
val data = getItemIcon(clusterItem)
marker.setIcon(data.bitmapDescriptor)
marker.setAnchor(data.anchorU, data.anchorV)
}
override fun onBeforeClusterRendered(
cluster: Cluster,
markerOptions: MarkerOptions
) {
val data = getClusterIcon(cluster)
markerOptions
.icon(data.bitmapDescriptor)
.anchor(data.anchorU, data.anchorV)
}
override fun onClusterUpdated(cluster: Cluster, marker: Marker) {
val data = getClusterIcon(cluster)
marker.setIcon(data.bitmapDescriptor)
marker.setAnchor(data.anchorU, data.anchorV)
}
override fun shouldRenderAsCluster(cluster: Cluster): Boolean = cluster.size > 1
}
In this code we have two pairs of methods onBefore…Rendered
And on...Updated
. They are called when a cluster or cluster item (separate marker) is created or updated (when the marker already exists on the map and we just want to update its presentation e.g. color , text or other content). Also, we have a method shouldRenderAsCluster
, where we define whether we need to display a group of markers as a cluster or as separate markers. In the above code, we want to display clusters instead of separate markers if the group contains many elements.
ClusterRenderer
allows drawing very custom clusters and markers so we will use this option and create a custom view MapMarkerView
and draw it in methods getClusterIcon()
And getItemIcon()
. I will not give the code to MapMarkerView
since it’s a pretty simple android View
display only images and text. But you can check it out in my project on Github. What we need is to send some content to this View and then draw its state, get the bitmap with markerIconGenerator.makeIcon
and send it to marker.setIcon
so the getClusterIcon and getItemIcon methods should look like this:
private fun getItemIcon(marker: MapMarker): IconData {
mapMarkerView.setContent(
circle = MapMarkerView.CircleContent.Marker,
title = marker.titleText
)
val icon: Bitmap = markerIconGenerator.makeIcon()
val middleBalloon = dpToPx(mapMarkerView.context, 24)
return IconData(
bitmapDescriptor = BitmapDescriptorFactory.fromBitmap(icon),
anchorU = middleBalloon / 2 / icon.width,
anchorV = 1f
)
}
private fun getClusterIcon(cluster: Cluster): IconData {
mapMarkerView.setContent(
circle = MapMarkerView.CircleContent.Cluster(
count = cluster.size
),
title = null
)
val icon: Bitmap = markerIconGenerator.makeIcon()
val middleBalloon = dpToPx(context, 40)
return IconData(
bitmapDescriptor = BitmapDescriptorFactory.fromBitmap(icon),
anchorU = middleBalloon / 2 / icon.width,
anchorV = 1f
)
}
Now we can render clusters and markers. The last thing to do is to send the list of markers directly to ClusterManager
so that it determines which of them and how to display. Please note that it is important to call clusterManager.cluster()
after you modify the collection of items.
override fun onMapReady(googleMap: GoogleMap) {
...
scope.launch {
(application as MapApplication).dataFlow.combine(boundariesListener.boundariesFlow) { data, boundaries ->
data to boundaries
}.collect { (data, boundaries) ->
val markers = data.filter { boundaries.bounds.includesLocation(it.lat, it.lon) }
.map { marker ->
MapMarker(
titleText = "Item ${marker.hashCode()}",
location = LatLng(marker.lat, marker.lon)
)
}
clusterManager.clearItems()
clusterManager.addItems(markers)
clusterManager.cluster()
}
}
}
If we launch our application now, we will see a map with clusters and markers. They are displayed according to the logic we have added MapMarkerView
And MapMarkersRenderer
i.e. it can only display static icons (little purple human) and text.
Next, we will learn how to upload animations that are remotely hosted on the server and how to display them in markers.
If you open the Google Maps app, you’ll see 99% of the icons are static resources that match the location category: restaurants, hotels, museums, etc. Google Maps can also show icons of locations selected, but their number relative to the position other than the icon is very low.
I believe this restriction serves two purposes. The first is to ensure optimal performance. Obviously, downloading images from the Internet requires additional resources to download and draw, compared to static icons. The second reason is to avoid the visual clutter of the markers in the map because many different symbols create visual clutter and make the objects under the marker layer less distinct.
Unfortunately, Maps SDK for Android Utilities Library does not support loading and displaying animations immediately. Normally we load the image in the background thread and the loaded result is passed into a callback and placed into the ImageView in the main thread (i.e. in picaso).
Picasso.get()
.load(imageUrl)
.resize(size, size)
.centerCrop()
.into(object : Target {
})
Theoretically, we can load images by methods onBefore…Rendered
And on...Updated
but they are synchronous, meaning the drawing must take place directly in them and block the UI thread.
However, we can fix this by showing the placeholder first and starting the image download simultaneously. And once it’s loaded, update the markers with the loaded and cached image. Here is a diagram for this:
We will use the Picasso library to upload images. For this, add it to the project:
dependencies {
implementation 'com.squareup.picasso:picasso:'
Now, update MapMarkersRenderer
, plus the ability to load icons. We only want to show images in separate markers, so just update the modal getItemIcon
Used in onClusterItemUpdated
And onBeforeClusterItemRendered
private fun getItemIcon(marker: MapMarker): IconData {
val iconToShow: MapMarker.Icon = when (marker.icon) {
is MapMarker.Icon.UrlIcon -> {
val cachedIcon = loadedImages.get(marker.icon.url)
if (cachedIcon == null) {
loadBitmapImage(marker.icon.url)
}
cachedIcon?.let { MapMarker.Icon.BitmapIcon(marker.icon.url, it) } ?: marker.icon
}
else -> marker.icon
}
This loadedImages
is the cache of previously loaded icons. LruCache would be a great choice for this.
private val loadedImages = LruCache(30)
Next, add a container for our icons so they can be stored in MapMarker
and draw in one MapMarkerView
.
data class MapMarker(
val icon: Icon,
val titleText: String,
@ColorInt val pinColor: Int,
val location: LatLng,
) : ClusterItem {
...
sealed interface Icon {
val url: String
data class Placeholder(override val url: String) : Icon
data class BitmapIcon(override val url: String, val image: Bitmap) : Icon
}
}
Now we also update MapMarkerView
code so it can draw our icons:
fun setContent(
circle: CircleContent,
title: String?,
@ColorInt pinColor: Int,
) {
when (circle) {
is CircleContent.Cluster -> {
...
}
is CircleContent.Marker -> {
binding.mapMarkerViewClusterText.isVisible = false
val icon = circle.mapMarkerIcon
val drawable = getIconDrawable(markerIcon = icon)
binding.mapMarkerViewIcon.setImageDrawable(drawable)
...
private fun getIconDrawable(
markerIcon: MapMarker.Icon,
): Drawable? {
val drawable = when (markerIcon) {
is MapMarker.Icon.BitmapIcon -> {
RoundedBitmapDrawableFactory.create(resources, markerIcon.image).apply {
isCircular = true
cornerRadius = max(markerIcon.image.width, markerIcon.image.height) / 2.0f
}
}
is MapMarker.Icon.Placeholder -> {
// Here we are just waiting for image to be loaded
null
}
}
return drawable
}
Come back MapMarkersRenderer
we need to define the method loadBitmapImage
. We pass the image ID to it and download the image using Picasso. Results will be received in ImageTarget
. Here, we store the image and return in Callback
for later update ClusterManager
with our uploaded images.
private fun loadBitmapImage(imageUrl: String) {
val size = dpToPx(context, 40).toInt()
val target = IconTarget(imageUrl)
Picasso.get()
.load(BuildConfig.IMAGES_URL + imageUrl)
.resize(size, size)
.centerCrop()
.into(target)
}
inner class IconTarget(private val imageUrl: String) : Target {
override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) {
loadedImages.put(imageUrl, bitmap)
callback.onImageLoaded(icon = MapMarker.Icon.BitmapIcon(imageUrl, bitmap))
}
override fun onBitmapFailed(e: Exception?, errorDrawable: Drawable?) {}
override fun onPrepareLoad(placeHolderDrawable: Drawable?) {}
}
interface Callback {
fun onImageLoaded(icon: MapMarker.Icon.BitmapIcon)
}
In this code cutter, for the sake of simplicity, we do not handle any errors in onBitmapFailed
and doesn’t check if an image with such a URL has been downloaded (we have it in our cache), but I recommend adding such a check. You can find an example of such code This. Next, when we call callback.onImageLoaded
we assume our activity will handle this event, find the marker to be updated and notify ClusterManager
to update it on the map. Afterward, ClusterManager
will see that the uploaded image already exists in the cache and can therefore be sent to MapMarkerView
and the view can be rendered with the image inside. If everything is correct, we will have a map with clusters and markers that can display the uploaded image asynchronously when we launch the application.
You can find the full code of this app on my page Github. I have added certain optimizations that can be used when you implement such a feature in a real world project. Also, I recommend using a Custom style for your map. This will remove some elements that are visible on the map by default, such as businesses (restaurants, hotels, etc.), to focus the user’s attention on markers and clusters. .