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.
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:
Custom tiles
Clustering
Routes
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.