Map Master or how to combine Google and Yandex maps in Android

Integrating different geoservices into a project can be challenging, especially when you need to support multiple providers at the same time. The most popular map providers, such as Google Maps and Yandex.Maps, offer different APIs and functionality, which can lead to a number of problems when creating an abstraction to work with them.

Why Google Maps?

Google Maps is the most popular mapping system in the world due to its wide functionality and data accuracy. However, despite the obvious advantages, the detail of the maps in some regions may be insufficient.

Why Yandex.Maps?

Yandex.Maps offers more detailed maps for Russia and the CIS countries. However, they also have their limitations, such as DAU limits, crashes and bugs.

Advantages:

  • Flexibility: Ability to switch between providers at will.

  • Increased reliability: Possibility of changing providers if errors occur.

  • Variety of functionality: Using the unique functions of each provider.

Flaws:

  • Miscellaneous API: Each provider has its own API, which requires studying the documentation when adding new functionality.

  • Implementation complexity: Creating a wrapper to work with different providers can be a labor-intensive process.

In this article, I'll talk about creating a wrapper for the most popular map providers and the problems you might encounter. We will look at the differences between integrations and creating an interface for working with different providers.

Initialization

To start working with maps, you need to initialize them in the project.

Google Maps

The API key is written in the manifest.

<meta-data
  android:name="com.google.android.geo.API_KEY"
  android:value="${GOOGLE_MAPS_API_KEY}" />

More details in get started in the documentation

Yandex maps

The API key is set in the Application class.

 class App: Application() {
    override fun onCreate() {
        super.onCreate()
        MapKitFactory.setApiKey(BuildConfig.MAPKIT_API_KEY)
    }
}

Also get started in the documentation

You need to set the key before initializing the card. If we set a key in an activity, it is important not to forget to handle the death of the process (saveInstanceState).

Lite version

Separately, I would like to mention a common situation when minimal interactivity is needed, for example, in a list with many cards.

Yandex maps

For mapkit from Yandex, it is possible to connect only the light version of the dependency 4.6.*-lite

com.yandex.android:maps.mobile:4.6.1-lite.

Google Maps

The liteMode flag provides a bitmap representation with a specific location and zoom instead of an interactive map.

app:liteMode=”true” in xml or GoogleMapOptions (an example will be in the provider implementation).

Life cycle processing for a lightweight card instance becomes optional.

I also recommend looking at screenshots of maps when working with lists. By initializing just one object, we can get a list of bitmaps.

Abstract Provider

Now we can create an abstract provider

interface MapProvider {
    fun provide(
        holder: FrameLayout,
        lifecycleOwner: LifecycleOwner? = null,
        interactive: Boolean = false,
        movable: Boolean = false,
        onMapLoaded: (AwesomeMap) -> Unit
    )
}

As a container, we will use FrameLayout

Implementation of providers

Something had to be cut, link to sources at the end of the article

Google Maps

class GoogleMapsProvider(private val context: Context) : MapProvider {

    override fun provide(
        holder: FrameLayout,
        lifecycleOwner: LifecycleOwner?,
        interactive: Boolean,
        movable: Boolean,
        onMapLoaded: (AwesomeMap) -> Unit
    ) {
        holder.removeAllViews()
        val options = GoogleMapOptions().apply {
            liteMode(!interactive)
        }
        val mapView = MapView(context, options)
        holder.addView(mapView)

        lifecycleOwner?.lifecycleScope?.launch {
            lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
                mapView.onCreate(null)
                mapView.onResume()
                val map = mapView.awaitMap()
                val awesomeMap = AwesomeMapGoogle(map, mapView)
                map.awaitMapLoad()
                onMapLoaded(awesomeMap)
                map.setOnMarkerClickListener(awesomeMap)
            }
        }
    }

To work with Google maps, we need an instance of the GoogleMap class, which can be obtained asynchronously by implementing OnMapReadyCallback, or in this case we will use coroutines and the MapView.awaitMap() extension.

maps-ktx

Yandex maps

class YandexMapsProvider(private val context: Context) : MapProvider {

    private val yaMapLoadedListeners: MutableList<MapLoadedListener> = mutableListOf()

    override fun provide(
        holder: FrameLayout,
        lifecycleOwner: LifecycleOwner?,
        interactive: Boolean,
        movable: Boolean,
        onMapLoaded: (AwesomeMap) -> Unit
    ) {
        holder.removeAllViews()
        val mapView = MapView(context)
        mapView.onStart()
        MapKitFactory.getInstance().onStart()

        val map = AwesomeMapYandex(mapView)
        val innerLoadListener = MapLoadedListener { onMapLoaded(map) }
        yaMapLoadedListeners.add(innerLoadListener) // храним ссылку на listener
        mapView.mapWindow.map.setMapLoadedListener(innerLoadListener)
        mapView.setNoninteractive(!interactive)
    }
}

If we need to show the map as a static object on the screen, i.e. without the possibility of interaction.

Yandex provider:

MapView.isClickable = interactive

liteMode(!interactive)

Google provider:

mapView.setNoninteractive(!interactive)

MapKit stores weak references to the Listener objects passed to it, so they need to be stored on the application side.

Main functionality

Which functionality to implement depends on the needs of the project.

In my example, the standard set is: you need to show the location by coordinates, zoom and labels.

Additionally: Polylines, polygons, zoom with specified boundaries and radius.

interface AwesomeMap {

    val defaultZoom: Float
    val zoom: Float
    val target: Location
  
    fun addMarker(location: Location, id: Long? = null): MapMarker?
    fun addCircle(...): MapCircle
    fun addPolyline(...)
    fun moveCamera(...)
    fun onMarkerClick(callback: (Long) -> Unit)
    fun setCameraListener(listener: CameraEventListener)
    fun zoomIn()
    fun zoomOut()
    
    fun onStart()
    fun onStop()
}

And now the specific implementations of the Map interface.

class AwesomeMapYandex(
    private val mapView: MapView
) : AwesomeMap {

    private val map get() = mapView.mapWindow.map
    private val context get() = mapView.context
    override val defaultZoom: Float = 16f
    override val target get() = map.cameraPosition.target.toLocation()
    override val zoom get() = map.cameraPosition.zoom

    private var markerClickListener: (Long) -> Unit = {}
    private val mapObjectTapListener = MapObjectTapListener { mapObject, _ ->
        val id = mapObject.userData as? Long
        id?.let(markerClickListener)
        true
    }

    private var cameraEventListener: CameraEventListener? = null
    private val cameraListener: CameraListener =
        CameraListener { map, cameraPosition, cameraUpdateReason, finished ->
            if (finished) {
                cameraEventListener?.onCameraIdleListener()
                return@CameraListener
            } else {
                cameraEventListener?.onMoveListener()
            }
            if (cameraUpdateReason == CameraUpdateReason.GESTURES) cameraEventListener?.onGestureListener()
        }

    init {
        map.mapObjects.addTapListener(mapObjectTapListener)
        map.addCameraListener(cameraListener)
    }

    override fun zoomIn() {
        ...
    }

    override fun zoomOut() {
        ...
    }

    override fun addMarker(
        location: Location,
        id: Long?
    ): MapMarker = map.let { yaMap ->
        val placemark = yaMap.mapObjects.addPlacemark().apply {
            geometry = location.toPoint()
        }
        return object : MapMarker {
            override var zIndex: Float
                get() = placemark.zIndex
                set(value) {
                    placemark.zIndex = value
                }
            override var location: Location
                get() = placemark.geometry.toLocation()
                set(value) {
                    placemark.geometry = value.toPoint()
                }
            override var id: Long
                set(value) {
                    placemark.userData = value
                }
                get() = placemark.userData as Long


            override fun setImage(bitmap: Bitmap, anchor: Pair<Float, Float>?) {
                val imageProvider = ImageProvider.fromBitmap(bitmap)
                placemark.apply {
                    setIcon(imageProvider)
                    setIconStyle(IconStyle().apply {
                        anchor?.let {
                            this.anchor = PointF(anchor.first, anchor.second)
                        }
                    })
                }
            }

            override fun remove() {
                yaMap.mapObjects.remove(placemark)
            }
        }
    }

    override fun addCircle(
        context: Context,
        position: Location,
        currentRange: Double,
        @ColorRes circleColor: Int,
        stroke: Boolean
    ): MapCircle {
      ...
    }

    override fun addPolyline(locations: List<Location>, colorRes: Int, width: Float) {
        val polyline = Polyline(locations.map { it.toPoint() })
        map.mapObjects.addPolyline(polyline).apply {
            strokeWidth = width
            setStrokeColor(ContextCompat.getColor(context, colorRes))
        }
    }

    override fun moveCamera(
        location: Location,
        zoomLevel: Float?,
        zoomRange: Float?,
        isAnimated: Boolean
    ) {
        val rangePosition = zoomRange?.let {
            val circle = Circle(location.toPoint(), zoomRange)
            map.cameraPosition(Geometry.fromCircle(circle))
        }
        val pointPosition = CameraPosition(
            location.toPoint(),
            zoomLevel ?: map.cameraPosition.zoom,
            map.cameraPosition.azimuth,
            map.cameraPosition.tilt
        )
        if (isAnimated) {
            map.move(
                rangePosition ?: pointPosition,
                Animation(Animation.Type.SMOOTH, defaultAnimateDuration),
                null
            )
        } else {
            map.move(rangePosition ?: pointPosition)
        }
    }

    override fun onMarkerClick(callback: (id: Long) -> Unit) {
        markerClickListener = callback
    }

    override fun setCameraListener(listener: CameraEventListener) {
        cameraEventListener = listener
    }

    override fun onStart() {
        MapKitFactory.getInstance().onStart()
        mapView.onStart()
    }

    override fun onStop() {
        MapKitFactory.getInstance().onStop()
        mapView.onStop()
    }

    private companion object {
        const val defaultAnimateDuration = 0.5f
        const val defaultZoomDuration = 0.3f
    }
}

As I wrote above, we have to store all listeners on the application side, otherwise the card will simply stop responding to taps on tags over time.

class AwesomeMapGoogle(
    private var map: GoogleMap,
    private var mapView: com.google.android.gms.maps.MapView
) : AwesomeMap, OnMarkerClickListener {

    private val context get() = mapView.context
    override val defaultZoom: Float = 16f
    override val target: Location get() = map.cameraPosition.target.toLocation()
    override val zoom: Float get() = map.cameraPosition.zoom
    private var markerClickListener: (Long) -> Unit = {}

    var mapType: Int
        get() = map.mapType
        set(value) {
            map.mapType = value
        }

    override fun addMarker(location: Location, id: Long?): MapMarker? {
        val marker = map.addMarker {
            position(location.toLatLng())
        } ?: return null
        return object : MapMarker {
            override var zIndex: Float
                get() = marker.zIndex
                set(value) {
                    marker.zIndex = value
                }
            override var location: Location
                get() = marker.position.toLocation()
                set(value) {
                    marker.position = value.toLatLng()
                }
            override var id: Long
                get() = marker.tag as Long
                set(value) {
                    marker.tag = value
                }

            override fun setImage(bitmap: Bitmap, anchor: Pair<Float, Float>?) {
                marker.setIcon(BitmapDescriptorFactory.fromBitmap(bitmap))
                anchor?.let {
                    marker.setAnchor(anchor.first, anchor.second)
                } ?: run {
                    marker.setAnchor(0.5f, 0.5f)
                }
            }

            override fun remove() {
                marker.remove()
            }
        }
    }

    override fun addCircle(
        context: Context,
        position: Location,
        currentRange: Double,
        circleColor: Int,
        stroke: Boolean
    ): MapCircle {
      ...
    }

    override fun addPolyline(locations: List<Location>, colorRes: Int, width: Float) {
        val polyline = PolylineOptions()
            .width(width)
            .color(ContextCompat.getColor(context, colorRes))

        locations.forEach {
            polyline.add(it.toLatLng())
        }
        map.addPolyline(polyline)
    }

    override fun moveCamera(location: Location, zoomLevel: Float?, zoomRange: Float?, isAnimated: Boolean) {
        val rangePosition = zoomRange?.let {
            CameraUpdateFactory.newLatLngZoom(
                location.toLatLng(),
                zoomRange
            )
        }

        val defaultZoom = if (zoom < defaultZoom) 14f else zoom
        map.animateCamera(
            rangePosition ?: CameraUpdateFactory.newLatLngZoom(
                location.toLatLng(),
                zoomLevel ?: defaultZoom
            )
        )
    }

    override fun onMarkerClick(callback: (Long) -> Unit) {
        markerClickListener = callback
    }

    override fun setCameraListener(listener: CameraEventListener) {
        map.setOnCameraIdleListener { listener.onCameraIdleListener() }
        map.setOnCameraMoveListener { listener.onMoveListener() }
        map.setOnCameraMoveStartedListener {
            if (it == GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) {
                listener.onGestureListener()
            }
        }
    }

    override fun onStart() {
        mapView.onStart()
    }

    override fun onStop() {
        mapView.onStop()
    }

    override fun zoomIn() {
        map.animateCamera(CameraUpdateFactory.zoomIn(), 300, null)
    }

    override fun zoomOut() {
        map.animateCamera(CameraUpdateFactory.zoomOut(), 300, null)
    }

    override fun onMarkerClick(marker: Marker): Boolean {
        val id = marker.tag as Long
        markerClickListener(id)
        return true
    }
}

The main difference here is in the implementation of the OnMarkerClickListener interface. Also, tap events on a marker can be processed through the map.mapClickEvents() extension, which returns flow.

The general scheme turned out to be quite simple. Depending on the provider, in the provide method we return the corresponding Map instance.

Additionally

As soon as it was necessary to show a list of elements, a problem appeared in the implementation of the Yandex provider: the map was drawn even outside the list.

The fact is that to draw the map by default, SurfaceView is used, which uses a separate thread for rendering; accordingly, it is not suitable for displaying a list of elements.

The solution to this problem is the xml attribute yandex:movable=”true”

If set to true, a TextureView will be used under the hood, which works in the UI thread and will not create problems in the list, the only caveat is that this attribute is only in xml, so initializing the mapview in the provider will take the following form:

class YandexMapsProvider(private val context: Context) : MapProvider {
    override fun provide(...) {
        val mapView = if (movable) {
            val mapkitViewBinding = MapkitViewBinding.inflate(LayoutInflater.from(holder.context), holder, true)
            mapkitViewBinding.root
        } else {
            MapView(context)
                .apply {
                    layoutParams = FrameLayout.LayoutParams(
                        FrameLayout.LayoutParams.MATCH_PARENT,
                        FrameLayout.LayoutParams.MATCH_PARENT
                    )
                    holder.addView(this)
                }
        }
        ...
  }
}

What about compose?

There are no cards for both providers yet official implementations on compose (Yandex promised in 24, we’ll wait), but you can manually add support for the necessary parameters and life cycle processing using AndroidView.

Commercial use

When working with libraries intended for commercial use, even with free frameworks, you should not forget that each provider has its own conditions. Speaking about free versions, I would like to highlight:

Yandex maps

To use Yandex.Maps for free, you must comply with the following conditions:

– No more than 1000 daily active users (DAU).

– The Yandex logo should not be hidden on maps.

– Other conditions can be found in the documentation: Yandex Commercial Usage.

Google Maps

Google provides $200 in free credit every month to use the Google Maps Platform, including the Maps SDK for Android. This is equivalent to approximately 28,000 requests per month. Each map download counts as a request, as well as some interactions with the map. You can find out more about this here: Google Maps Usage and Billing.

I would also like to touch on a few more topics:

  1. Custom tiles

  2. Clustering

  3. Routes

  4. Example screenshots

If you are interested, I will write a second part.

Conclusion

Good luck in learning and improving your skills!

If you found the article interesting, you can go to my telegram channelwhere I will post my further findings and thoughts.

Link to sources

Similar Posts

Leave a Reply

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