Blog Infos
Author
Published
Topics
Published

Dynamic images in Google Maps markers

 

Basic markers loading and displaying

In the newly created project, open AndroidManifest.xml. There, you will see a comment requesting to get an API_KEY by the link and add it to the metadata.

<meta-data
    android:name="com.google.android.geo.API_KEY"
    android:value="YOUR_API_KEY" />

Next, add dynamic marker upload. In our example, we will use a JSON file with the marker locations and download them using Retrofit.

[
  {
    "lat": 59.92140394439577,
    "lon": 30.445576954709395,
    "icon": "1619152.jpg"
  },
  {
    "lat": 59.93547541514004,
    "lon": 30.21481515274267,
    "icon": "1712315710.jpg"
  },
class MapApplication : Application() {
        
    private val context = CoroutineScope(Dispatchers.Default)
  
    private val _dataFlow = MutableSharedFlow<List<MarkerData>>(
        replay = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )
  
    val dataFlow: Flow<List<MarkerData>> = _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())
        }
    }
}
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)))
            }
        }
    }
}
internal class BoundariesListener(
    private val map: GoogleMap,
) : GoogleMap.OnCameraIdleListener {

    private val _boundariesFlow = MutableSharedFlow<LatLngBounds>(
        replay = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
    )
    val boundariesFlow: Flow<LatLngBounds> = _boundariesFlow

    override fun onCameraIdle() {
        val boundaries = map.projection.visibleRegion.latLngBounds
        _boundariesFlow.tryEmit(boundaries)
    }
}

Now, we can assign BoundariesListener to the map object, and, when the map boundaries gets changed, we will filter the markers taking into account currently shown 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

}

Marker clustering
dependencies {

  ...

    implementation 'com.google.maps.android:android-maps-utils:<version>'
}

First, we need ClusterManager that will get big part of the job done. It processes a list of the markers, decides which of them are close to each other and, therefore, should be replaced with one group marker (cluster). The second important component is ClusterRenderer that, as it goes from its name, draws markers and cluster items. Also, we have a base class DefaultClusterRenderer that implements quite a lot of the basic logic, so we can inherit from it and care only about important things. Both these classes work with ClusterItem which stores the marker information such as location. It is an interface, so let’s, first of all, 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 plain and simple: we store information about the location and some marker label. It actually can contain more information about the marker, but we will 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<MapMarker>(this, mMap)
    val mapRenderer = MapMarkersRenderer(
        context = this,
        callback = this,
        map = mMap,
        clusterManager = clusterManager
    )
    clusterManager.renderer = mapRenderer

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Find your way with GoogleMap() {}

Maps are a crucial piece of many mobile apps today and there is no shortage of mapping libraries one can use. If you prefer to stick with platform components, you could use the OG MapView,…
Watch Video

Find your way with GoogleMap() {}

Brian Gardner
Android Developer
Cash App

Find your way with GoogleMap() {}

Brian Gardner
Android Developer
Cash App

Find your way with GoogleMap() {}

Brian Gardner
Android Developer
Cash App

Jobs

Here, you can see MapMarkersRenderer is used, so add it to the project, too:

class MapMarkersRenderer(
    context: Context,
    map: GoogleMap,
    clusterManager: ClusterManager<MapMarker>,
) : DefaultClusterRenderer<MapMarker>(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<MapMarker>,
        markerOptions: MarkerOptions
    ) {
        val data = getClusterIcon(cluster)
        markerOptions
            .icon(data.bitmapDescriptor)
            .anchor(data.anchorU, data.anchorV)
    }

    override fun onClusterUpdated(cluster: Cluster<MapMarker>, marker: Marker) {
        val data = getClusterIcon(cluster)
        marker.setIcon(data.bitmapDescriptor)
        marker.setAnchor(data.anchorU, data.anchorV)
    }

    override fun shouldRenderAsCluster(cluster: Cluster<MapMarker>): Boolean = cluster.size > 1
}
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<MapMarker>): 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 the ClusterManager so that it determined which of them and in which way to display. Please note that it is crucial to call clusterManager.cluster() after you modified 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()
        }
    }
}

Dynamic images in the markers
Picasso.get()
    .load(imageUrl)
    .resize(size, size)
    .centerCrop()
    .into(object : Target {

    })

dependencies {

    implementation 'com.squareup.picasso:picasso:<VERSION>'

Now, update MapMarkersRenderer, adding it the ability of loading the icon. We want to display images in separate markers only, so update only method 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
    }

Here loadedImages is a cache of previously loaded icons. LruCache will be an excellent choice for this.

private val loadedImages = LruCache<String, Bitmap>(30)

Next, add a container for our icons so that they could be stored in MapMarker and drawn in a 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
    }
}
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
}

Back to MapMarkersRenderer we need to define method loadBitmapImage. We pass the image ID to it and download the image using Picasso. The result will be received in ImageTarget . Here, we cache the image and return in the Callback to later update ClusterManager with our uploaded image.

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 snipper, for simplicity we don’t handle any errors in onBitmapFailed and don’t check if an image with such URL is already downloaded (we have it in our cache), but I recommend adding such a check. You can find an example of such code here. Next, when we call callback.onImageLoaded, we assume that our activity will handle this event, find the marker to be updated, and tell ClusterManager to update it on the map. After that, ClusterManager will see that the image just uploaded already exists in the cache and, therefore, can be sent to MapMarkerView, and the view can be rendered with the image inside. If everything is correct, we will have the map with clusters and markers that can display the images uploaded asynchronously, when we launch the app.

You can find the full code of this app on my Github. I’ve added certain optimizations that can be of use when you implement such feature in a real-world project. In addition, I would recommend to use a custom style for your map. This will help to remove some elements displayed on the map by default, such as businesses (restaurants, hotels, etc.), to focus user’s attention on the markers and clusters.

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Jetpack Compose is a modern toolkit for building native Android UI. It’s declarative, meaning…
READ MORE
blog
Today we will learn how select area in google maps using free hand drawn…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu