В предыдущих статьях мы создавали аккаунт разработчика для использования Huawei Mobile Services и подготавливали проект к их использованию. Потом использовали аналитику от Huawei вместо аналога от Google. Также поступили и с определением геолокации. В этой же статье мы будем использовать карты от Huawei вместо карт от Google.
Вот полный список статей из цикла:
- Создаём аккаунт разработчика, подключаем зависимости, подготавливаем код к внедрению. тык
- Встраиваем Huawei Analytics. тык
- Используем геолокацию от Huawei. тык
- Huawei maps. Используем вместо Google maps для AppGallery. < вы тут
В чём сложность
К сожалению, с картами не получится так просто, как было с аналитикой и геолокацией. Что и неудивительно, т.к. это гораздо более сложная система сама по себе. И очень часто в приложениях карты и взаимодействие с ними кастомизируется. Например, отображают маркеры, кластеризуют их. Поэтому кода будет много, т.к. надо всё это заабстрагировать, имея в виду некоторые отличия в API карт разных реализаций.
Создаём абстракцию над картой
Надо в разметке использовать разные классы для отображения карты. com.google.android.libraries.maps.MapView
для гугло-карт и com.huawei.hms.maps.MapView
для Huawei. Сделаем так: создадим собственную абстрактную вьюху, унаследовавшись от FrameLayout
и в неё будет загружать конкретную реализацию MapView
в разных flavors
. Также создадим в нашей абстрактной вьюхе все нужные методы, которые мы должны вызывать на конкретных реализациях. И ещё метод для получения объекта самой карты. И методы для непосредственного внедрения реализации MapView от гугла и Huawei и прокидывания атрибутов для карт из разметки. Вот такой класс получится:
abstract class MapView : FrameLayout {
enum class MapType(val value: Int) {
NONE(0), NORMAL(1), SATELLITE(2), TERRAIN(3), HYBRID(4)
}
protected var mapType = MapType.NORMAL
protected var liteModeEnabled = false
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
initView(context, attrs)
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
initView(context, attrs)
}
private fun initView(context: Context, attrs: AttributeSet) {
initAttributes(context, attrs)
inflateMapViewImpl()
}
private fun initAttributes(context: Context, attrs: AttributeSet) {
val attributeInfo = context.obtainStyledAttributes(
attrs,
R.styleable.MapView
)
mapType = MapType.values()[attributeInfo.getInt(
R.styleable.MapView_someMapType,
MapType.NORMAL.value
)]
liteModeEnabled = attributeInfo.getBoolean(R.styleable.MapView_liteModeEnabled, false)
attributeInfo.recycle()
}
abstract fun inflateMapViewImpl()
abstract fun onCreate(mapViewBundle: Bundle?)
abstract fun onStart()
abstract fun onResume()
abstract fun onPause()
abstract fun onStop()
abstract fun onLowMemory()
abstract fun onDestroy()
abstract fun onSaveInstanceState(mapViewBundle: Bundle?)
abstract fun getMapAsync(function: (SomeMap) -> Unit)
}
Чтобы работали атрибуты в разметке нам, конечно, надо их определить. Добавляем в res/values/attrs.xml
вот это:
<declare-styleable name="MapView">
<attr name="someMapType">
<enum name="none" value="0"/>
<enum name="normal" value="1"/>
<enum name="satellite" value="2"/>
<enum name="terrain" value="3"/>
<enum name="hybrid" value="4"/>
</attr>
<attr format="boolean" name="liteModeEnabled"/>
</declare-styleable>
Это нам позволит прямо в разметке, используя нашу абстрактную карту передавать тип карты и нужен ли нам облегчённый режим для неё. Выглядеть в разметке это будет как-то так (реализация MapViewImpl
будет показана далее):
<com.example.ui.base.widget.map.MapViewImpl
android:layout_width="match_parent"
android:layout_height="150dp"
app:liteModeEnabled="true"
app:someMapType="normal"/>
Как можно заметить в коде нашего абстрактного класса MapView
, там используется некий SomeMap
в методе getMapAsync
. Так что давайте сразу покажем какие ещё общие классы и интерфейсы нам понадобятся, прежде чем перейдём к использованию различных реализаций карт.
SomeMap
— основной класс для работы с картами. В его переопределениях мы будет прокидывать вызовы методов для показа маркеров, назначения слушателей событий и опций отображения и для перемещения камеры по карте:
abstract class SomeMap {
abstract fun setUiSettings(
isMapToolbarEnabled: Boolean? = null,
isCompassEnabled: Boolean? = null,
isRotateGesturesEnabled: Boolean? = null,
isMyLocationButtonEnabled: Boolean? = null,
isZoomControlsEnabled: Boolean? = null
)
abstract fun setPadding(left: Int, top: Int, right: Int, bottom: Int)
abstract fun animateCamera(someCameraUpdate: SomeCameraUpdate)
abstract fun moveCamera(someCameraUpdate: SomeCameraUpdate)
abstract fun setOnCameraIdleListener(function: () -> Unit)
abstract fun setOnCameraMoveStartedListener(onCameraMoveStartedListener: (Int) -> Unit)
abstract fun setOnCameraMoveListener(function: () -> Unit)
abstract fun setOnMarkerClickListener(function: (SomeMarker) -> Boolean)
abstract fun setOnMapClickListener(function: () -> Unit)
abstract fun addMarker(markerOptions: SomeMarkerOptions): SomeMarker
abstract fun <Item : SomeClusterItem> addMarkers(
context: Context,
markers: List<Item>,
clusterItemClickListener: (Item) -> Boolean,
clusterClickListener: (SomeCluster<Item>) -> Boolean,
generateClusterItemIconFun: ((Item, Boolean) -> Bitmap)? = null
): (Item?) -> Unit
companion object {
const val REASON_GESTURE = 1
const val REASON_API_ANIMATION = 2
const val REASON_DEVELOPER_ANIMATION = 3
}
}
А вот и остальные классы/интерфейсы:
SomeCameraUpdate
— нужен для перемещения камеры на карте к какой-то точке или области.
class SomeCameraUpdate private constructor(
val location: Location? = null,
val zoom: Float? = null,
val bounds: SomeLatLngBounds? = null,
val width: Int? = null,
val height: Int? = null,
val padding: Int? = null
) {
constructor(
location: Location? = null,
zoom: Float? = null
) : this(location, zoom, null, null, null, null)
constructor(
bounds: SomeLatLngBounds? = null,
width: Int? = null,
height: Int? = null,
padding: Int? = null
) : this(null, null, bounds, width, height, padding)
}
SomeLatLngBounds
— класс для описания области на карте, куда можно переместить камеру.
abstract class SomeLatLngBounds(val southwest: Location? = null, val northeast: Location? = null) {
abstract fun forLocations(locations: List<Location>): SomeLatLngBounds
}
И классы для маркеров.
SomeMarker
— собственно маркер:
abstract class SomeMarker {
abstract fun remove()
}
SomeMarkerOptions
— для указания иконки и местоположения маркера.
data class SomeMarkerOptions(
val icon: Bitmap,
val position: Location
)
SomeClusterItem
— для маркера при кластеризации.
interface SomeClusterItem {
fun getLocation(): Location
fun getTitle(): String?
fun getSnippet(): String?
fun getDrawableResourceId(): Int
}
SomeCluster
— для кластера маркеров.
data class SomeCluster<T : SomeClusterItem>(
val location: Location,
val items: List<T>
)
SelectableMarkerRenderer
нужен для возможности выделять маркеры при нажатии, меняя им иконку и сохраняя выбранный маркер.
interface SelectableMarkerRenderer<Item : SomeClusterItem> {
val pinBitmapDescriptorsCache: Map<Int, Bitmap>
var selectedItem: Item?
fun selectItem(item: Item?)
fun getVectorResourceAsBitmap(@DrawableRes vectorResourceId: Int): Bitmap
}
Также мы хотим иметь возможность сложной настройки внешнего вида маркера. Например генерируя иконку для него из разметки. Для этого скопируем класс из гугловой библиотеки — IconGenerator
:
/**
* Not full copy of com.google.maps.android.ui.IconGenerator
*/
class IconGenerator(private val context: Context) {
private val mContainer = LayoutInflater.from(context)
.inflate(R.layout.map_marker_view, null as ViewGroup?) as ViewGroup
private var mTextView: TextView?
private var mContentView: View?
init {
mTextView = mContainer.findViewById(R.id.amu_text) as TextView
mContentView = mTextView
}
fun makeIcon(text: CharSequence?): Bitmap {
if (mTextView != null) {
mTextView!!.text = text
}
return this.makeIcon()
}
fun makeIcon(): Bitmap {
val measureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
mContainer.measure(measureSpec, measureSpec)
val measuredWidth = mContainer.measuredWidth
val measuredHeight = mContainer.measuredHeight
mContainer.layout(0, 0, measuredWidth, measuredHeight)
val r = Bitmap.createBitmap(measuredWidth, measuredHeight, Bitmap.Config.ARGB_8888)
r.eraseColor(0)
val canvas = Canvas(r)
mContainer.draw(canvas)
return r
}
fun setContentView(contentView: View?) {
mContainer.removeAllViews()
mContainer.addView(contentView)
mContentView = contentView
val view = mContainer.findViewById<View>(R.id.amu_text)
mTextView = if (view is TextView) view else null
}
fun setBackground(background: Drawable?) {
mContainer.setBackgroundDrawable(background)
if (background != null) {
val rect = Rect()
background.getPadding(rect)
mContainer.setPadding(rect.left, rect.top, rect.right, rect.bottom)
} else {
mContainer.setPadding(0, 0, 0, 0)
}
}
fun setContentPadding(left: Int, top: Int, right: Int, bottom: Int) {
mContentView!!.setPadding(left, top, right, bottom)
}
}
Создаём реализации нашей абстрактной карты
Наконец приступаем к переопределению созданных нами абстрактных классов.
Подключим библиотеки:
//google maps
googleImplementation 'com.google.android.gms:play-services-location:17.0.0'
googleImplementation 'com.google.maps.android:android-maps-utils-sdk-v3-compat:0.1' //clasterization
//huawei maps
huaweiImplementation 'com.huawei.hms:maps:4.0.1.302'
Также добавляем необходимое для карт разрешение в манифест. Для этого создайте ещё один файл манифеста (AndroidManifest.xml
) в папке src/huawei/
с таким содержимым:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example">
<!-- used for MapKit -->
<uses-permission android:name="com.huawei.appmarket.service.commondata.permission.GET_COMMON_DATA"/>
</manifest>
Вот так будет выглядеть реализация карт для гугл. Добавляем в папку src/google/kotlin/com/example
класс MapViewImpl
:
class MapViewImpl : MapView {
private lateinit var mapView: com.google.android.libraries.maps.MapView
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
override fun inflateMapViewImpl() {
mapView = com.google.android.libraries.maps.MapView(
context,
GoogleMapOptions().liteMode(liteModeEnabled).mapType(mapType.value)
)
addView(mapView)
}
override fun getMapAsync(function: (SomeMap) -> Unit) {
mapView.getMapAsync { function(SomeMapImpl(it)) }
}
override fun onCreate(mapViewBundle: Bundle?) {
mapView.onCreate(mapViewBundle)
}
override fun onStart() {
mapView.onStart()
}
override fun onResume() {
mapView.onResume()
}
override fun onPause() {
mapView.onPause()
}
override fun onStop() {
mapView.onStop()
}
override fun onLowMemory() {
mapView.onLowMemory()
}
override fun onDestroy() {
mapView.onDestroy()
}
override fun onSaveInstanceState(mapViewBundle: Bundle?) {
mapView.onSaveInstanceState(mapViewBundle)
}
/**
* We need to manually pass touch events to MapView
*/
override fun onTouchEvent(event: MotionEvent?): Boolean {
mapView.onTouchEvent(event)
return true
}
/**
* We need to manually pass touch events to MapView
*/
override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
mapView.dispatchTouchEvent(event)
return true
}
}
А в папку src/huawei/kotlin/com/example
аналогичный класс MapViewImpl
но уже с использование карт от Huawei:
class MapViewImpl : MapView {
private lateinit var mapView: com.huawei.hms.maps.MapView
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
override fun inflateMapViewImpl() {
mapView = com.huawei.hms.maps.MapView(
context,
HuaweiMapOptions().liteMode(liteModeEnabled).mapType(mapType.value)
)
addView(mapView)
}
override fun getMapAsync(function: (SomeMap) -> Unit) {
mapView.getMapAsync { function(SomeMapImpl(it)) }
}
override fun onCreate(mapViewBundle: Bundle?) {
mapView.onCreate(mapViewBundle)
}
override fun onStart() {
mapView.onStart()
}
override fun onResume() {
mapView.onResume()
}
override fun onPause() {
try {
mapView.onPause()
} catch (e: Exception) {
// there can be ClassCastException: com.exmaple.App cannot be cast to android.app.Activity
// at com.huawei.hms.maps.MapView$MapViewLifecycleDelegate.onPause(MapView.java:348)
Log.wtf("MapView", "Error while pausing MapView", e)
}
}
override fun onStop() {
mapView.onStop()
}
override fun onLowMemory() {
mapView.onLowMemory()
}
override fun onDestroy() {
mapView.onDestroy()
}
override fun onSaveInstanceState(mapViewBundle: Bundle?) {
mapView.onSaveInstanceState(mapViewBundle)
}
/**
* We need to manually pass touch events to MapView
*/
override fun onTouchEvent(event: MotionEvent?): Boolean {
mapView.onTouchEvent(event)
return true
}
/**
* We need to manually pass touch events to MapView
*/
override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
mapView.dispatchTouchEvent(event)
return true
}
}
Тут надо обратить внимание на 3 момента:
- Вьюху карты мы создаём программно, а не загружаем из разметки, т.к. только так можно передать в неё опции (лёгкий режим, тип карты etc).
- Переопределены
onTouchEvent
иdispatchTouchEvent
, с прокидывание вызовов вmapView
— без этого карты не будут реагировать на касания. - В реализации для Huawei был обнаружен крэш при приостановке карты в методе
onPause
, пришлось в try-catch обернуть. Надеюсь это поправят в обновлениях библиотеки)
Реализуем дополнительные абстракции
А теперь самое сложное. У нас в приложении было достаточно много кода для отображения, кастомизации и обработки нажатия на маркеры и кластеры маркеров. Когда начали это всё пытаться заабстрагировать — возникли сложности. Почти сразу выяснилось, что хотя в картах от Huawei есть кластеризация, она не полностью аналогична по функционалу кластеризации от гугла. Например нельзя влиять на внешний вид кластера и обрабатывать нажатия на него. Также в Huawei картах внешний вид отдельных маркеров (и обработка их событий) работает также как и маркеры, которые должны кластеризироваться. А вот в гугло-картах для кластеризующихся маркеров всё иначе — отдельная обработка событий, отдельный способ настройки внешнего вида и вообще всё это сделано в рамках отдельной библиотеки. В итоге пришлось думать как переписать код так, чтобы и сохранить функционал для гугло-карт и чтобы карты от Huawei работали.
В общем, пришли в итоге к такому варианту: создаём метод для показа множества маркеров, которые должны кластеризоваться, в него передаём нужные нам слушатели событий и возвращаем лямбду, для функционала выбора маркера. Вот реализация SomeMap
для гугло-карт:
class SomeMapImpl(val map: GoogleMap) : SomeMap() {
override fun setUiSettings(
isMapToolbarEnabled: Boolean?,
isCompassEnabled: Boolean?,
isRotateGesturesEnabled: Boolean?,
isMyLocationButtonEnabled: Boolean?,
isZoomControlsEnabled: Boolean?
) {
map.uiSettings.apply {
isMapToolbarEnabled?.let {
this.isMapToolbarEnabled = isMapToolbarEnabled
}
isCompassEnabled?.let {
this.isCompassEnabled = isCompassEnabled
}
isRotateGesturesEnabled?.let {
this.isRotateGesturesEnabled = isRotateGesturesEnabled
}
isMyLocationButtonEnabled?.let {
this.isMyLocationButtonEnabled = isMyLocationButtonEnabled
}
isZoomControlsEnabled?.let {
this.isZoomControlsEnabled = isZoomControlsEnabled
}
setAllGesturesEnabled(true)
}
}
override fun animateCamera(someCameraUpdate: SomeCameraUpdate) {
someCameraUpdate.toCameraUpdate()?.let { map.animateCamera(it) }
}
override fun moveCamera(someCameraUpdate: SomeCameraUpdate) {
someCameraUpdate.toCameraUpdate()?.let { map.moveCamera(it) }
}
override fun setOnCameraIdleListener(function: () -> Unit) {
map.setOnCameraIdleListener { function() }
}
override fun setOnMarkerClickListener(function: (SomeMarker) -> Boolean) {
map.setOnMarkerClickListener { function(MarkerImpl(it)) }
}
override fun setOnMapClickListener(function: () -> Unit) {
map.setOnMapClickListener { function() }
}
override fun setOnCameraMoveStartedListener(onCameraMoveStartedListener: (Int) -> Unit) {
map.setOnCameraMoveStartedListener { onCameraMoveStartedListener(it) }
}
override fun addMarker(markerOptions: SomeMarkerOptions): SomeMarker {
return MarkerImpl(
map.addMarker(
MarkerOptions()
.position(markerOptions.position.toLatLng())
.icon(BitmapDescriptorFactory.fromBitmap(markerOptions.icon))
)
)
}
override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
map.setPadding(left, top, right, bottom)
}
override fun setOnCameraMoveListener(function: () -> Unit) {
map.setOnCameraMoveListener { function() }
}
override fun <Item : SomeClusterItem> addMarkers(
context: Context,
markers: List<Item>,
clusterItemClickListener: (Item) -> Boolean,
clusterClickListener: (SomeCluster<Item>) -> Boolean,
generateClusterItemIconFun: ((Item, Boolean) -> Bitmap)?
): (Item?) -> Unit {
val clusterManager = ClusterManager<SomeClusterItemImpl<Item>>(context, map)
.apply {
setOnClusterItemClickListener {
clusterItemClickListener(it.someClusterItem)
}
setOnClusterClickListener { cluster ->
val position = Location(cluster.position.latitude, cluster.position.longitude)
val items: List<Item> = cluster.items.map { it.someClusterItem }
val someCluster: SomeCluster<Item> = SomeCluster(position, items)
clusterClickListener(someCluster)
}
}
map.setOnCameraIdleListener(clusterManager)
map.setOnMarkerClickListener(clusterManager)
val renderer =
object :
DefaultClusterRenderer<SomeClusterItemImpl<Item>>(context, map, clusterManager),
SelectableMarkerRenderer<SomeClusterItemImpl<Item>> {
override val pinBitmapDescriptorsCache = mutableMapOf<Int, Bitmap>()
override var selectedItem: SomeClusterItemImpl<Item>? = null
override fun onBeforeClusterItemRendered(
item: SomeClusterItemImpl<Item>,
markerOptions: MarkerOptions
) {
val icon = generateClusterItemIconFun
?.invoke(item.someClusterItem, item == selectedItem)
?: getVectorResourceAsBitmap(
item.someClusterItem.getDrawableResourceId(item == selectedItem)
)
markerOptions
.icon(BitmapDescriptorFactory.fromBitmap(icon))
.zIndex(1.0f) // to hide cluster pin under the office pin
}
override fun getColor(clusterSize: Int): Int {
return context.resources.color(R.color.primary)
}
override fun selectItem(item: SomeClusterItemImpl<Item>?) {
selectedItem?.let {
val icon = generateClusterItemIconFun
?.invoke(it.someClusterItem, false)
?: getVectorResourceAsBitmap(
it.someClusterItem.getDrawableResourceId(false)
)
getMarker(it)?.setIcon(BitmapDescriptorFactory.fromBitmap(icon))
}
selectedItem = item
item?.let {
val icon = generateClusterItemIconFun
?.invoke(it.someClusterItem, true)
?: getVectorResourceAsBitmap(
it.someClusterItem.getDrawableResourceId(true)
)
getMarker(it)?.setIcon(BitmapDescriptorFactory.fromBitmap(icon))
}
}
override fun getVectorResourceAsBitmap(@DrawableRes vectorResourceId: Int): Bitmap {
return pinBitmapDescriptorsCache[vectorResourceId]
?: context.resources.generateBitmapFromVectorResource(vectorResourceId)
.also { pinBitmapDescriptorsCache[vectorResourceId] = it }
}
}
clusterManager.renderer = renderer
clusterManager.clearItems()
clusterManager.addItems(markers.map { SomeClusterItemImpl(it) })
clusterManager.cluster()
@Suppress("UnnecessaryVariable")
val pinItemSelectedCallback = fun(item: Item?) {
renderer.selectItem(item?.let { SomeClusterItemImpl(it) })
}
return pinItemSelectedCallback
}
}
fun Location.toLatLng() = LatLng(latitude, longitude)
fun SomeLatLngBounds.toLatLngBounds() = LatLngBounds(southwest?.toLatLng(), northeast?.toLatLng())
fun SomeCameraUpdate.toCameraUpdate(): CameraUpdate? {
return if (zoom != null) {
CameraUpdateFactory.newCameraPosition(
CameraPosition.fromLatLngZoom(
location?.toLatLng()
?: Location.DEFAULT_LOCATION.toLatLng(),
zoom
)
)
} else if (bounds != null && width != null && height != null && padding != null) {
CameraUpdateFactory.newLatLngBounds(
bounds.toLatLngBounds(),
width,
height,
padding
)
} else {
null
}
}
Самое сложное, как уже и говорилось — в addMarkers
методе. В нём используются ClusterManager
и ClusterRenderer
, аналогов которых нет в Huawei картах. К тому же, эти классы требуют, чтобы объекты, из которых будут создаваться маркеты для кластеризации реализовывали интерфейс ClusterItem
, аналога которому также нет у Huawei. В итоге пришлось изворачиваться и комбинировать наследование с инкапсуляцией. Data классы в проекте будут реализовывать наш интерфейс SomeClusterItem
, а гугловый интерфейс ClusterItem
будет реализовывать обёртка над классом с данными маркера. Вот такая:
data class SomeClusterItemImpl<T : SomeClusterItem>(
val someClusterItem: T
) : ClusterItem, SomeClusterItem {
override fun getSnippet(): String {
return someClusterItem.getSnippet() ?: ""
}
override fun getTitle(): String {
return someClusterItem.getTitle() ?: ""
}
override fun getPosition(): LatLng {
return someClusterItem.getLocation().toLatLng()
}
override fun getLocation(): Location {
return someClusterItem.getLocation()
}
}
В итоге, снаружи мы будем использовать библиотеко-независимый интерфейс, а внутри карт для гугла будем оборачивать его экземпляры в класс, реализующий ClusterItem
из гугловой библиотеки. Подробнее — смотрите реализацию addMarkers
выше.
Чтобы всё это работало, осталось только вот эти классы добавить:
class SomeLatLngBoundsImpl(bounds: LatLngBounds? = null) :
SomeLatLngBounds(bounds?.southwest?.toLocation(), bounds?.northeast?.toLocation()) {
override fun forLocations(locations: List<Location>): SomeLatLngBounds {
val bounds = LatLngBounds.builder()
.apply { locations.map { it.toLatLng() }.forEach { include(it) } }
.build()
return SomeLatLngBoundsImpl(bounds)
}
}
fun LatLng.toLocation(): Location {
return Location(latitude, longitude)
}
class MarkerImpl(private val marker: Marker?) : SomeMarker() {
override fun remove() {
marker?.remove()
}
}
С реализацией для Huawei будет проще — не надо возиться с оборачиванием SomeClusterItem
. Вот все классы, которые надо положить в src/huawei/kotlin/com/example
:
Реализация SomeMap
:
class SomeMapImpl(val map: HuaweiMap) : SomeMap() {
override fun setUiSettings(
isMapToolbarEnabled: Boolean?,
isCompassEnabled: Boolean?,
isRotateGesturesEnabled: Boolean?,
isMyLocationButtonEnabled: Boolean?,
isZoomControlsEnabled: Boolean?
) {
map.uiSettings.apply {
isMapToolbarEnabled?.let {
this.isMapToolbarEnabled = isMapToolbarEnabled
}
isCompassEnabled?.let {
this.isCompassEnabled = isCompassEnabled
}
isRotateGesturesEnabled?.let {
this.isRotateGesturesEnabled = isRotateGesturesEnabled
}
isMyLocationButtonEnabled?.let {
this.isMyLocationButtonEnabled = isMyLocationButtonEnabled
}
isZoomControlsEnabled?.let {
this.isZoomControlsEnabled = isZoomControlsEnabled
}
setAllGesturesEnabled(true)
}
}
override fun animateCamera(someCameraUpdate: SomeCameraUpdate) {
someCameraUpdate.toCameraUpdate()?.let { map.animateCamera(it) }
}
override fun moveCamera(someCameraUpdate: SomeCameraUpdate) {
someCameraUpdate.toCameraUpdate()?.let { map.moveCamera(it) }
}
override fun setOnCameraIdleListener(function: () -> Unit) {
map.setOnCameraIdleListener { function() }
}
override fun setOnMarkerClickListener(function: (SomeMarker) -> Boolean) {
map.setOnMarkerClickListener { function(MarkerImpl(it)) }
}
override fun setOnMapClickListener(function: () -> Unit) {
map.setOnMapClickListener { function() }
}
override fun setOnCameraMoveStartedListener(onCameraMoveStartedListener: (Int) -> Unit) {
map.setOnCameraMoveStartedListener { onCameraMoveStartedListener(it) }
}
override fun addMarker(markerOptions: SomeMarkerOptions): SomeMarker {
return MarkerImpl(
map.addMarker(
MarkerOptions()
.position(markerOptions.position.toLatLng())
.icon(BitmapDescriptorFactory.fromBitmap(markerOptions.icon))
)
)
}
override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
map.setPadding(left, top, right, bottom)
}
override fun setOnCameraMoveListener(function: () -> Unit) {
map.setOnCameraMoveListener { function() }
}
override fun <Item : SomeClusterItem> addMarkers(
context: Context,
markers: List<Item>,
clusterItemClickListener: (Item) -> Boolean,
clusterClickListener: (SomeCluster<Item>) -> Boolean,
generateClusterItemIconFun: ((Item, Boolean) -> Bitmap)?
): (Item?) -> Unit {
val addedMarkers = mutableListOf<Pair<Item, Marker>>()
val selectableMarkerRenderer = object : SelectableMarkerRenderer<Item> {
override val pinBitmapDescriptorsCache = mutableMapOf<Int, Bitmap>()
override var selectedItem: Item? = null
override fun selectItem(item: Item?) {
selectedItem?.let {
val icon = generateClusterItemIconFun
?.invoke(it, false)
?: getVectorResourceAsBitmap(it.getDrawableResourceId(false))
getMarker(it)?.setIcon(BitmapDescriptorFactory.fromBitmap(icon))
}
selectedItem = item
item?.let {
val icon = generateClusterItemIconFun
?.invoke(it, true)
?: getVectorResourceAsBitmap(
it.getDrawableResourceId(true)
)
getMarker(it)?.setIcon(BitmapDescriptorFactory.fromBitmap(icon))
}
}
private fun getMarker(item: Item): Marker? {
return addedMarkers.firstOrNull { it.first == item }?.second
}
override fun getVectorResourceAsBitmap(@DrawableRes vectorResourceId: Int): Bitmap {
return pinBitmapDescriptorsCache[vectorResourceId]
?: context.resources.generateBitmapFromVectorResource(vectorResourceId)
.also { pinBitmapDescriptorsCache[vectorResourceId] = it }
}
}
addedMarkers += markers.map {
val selected = selectableMarkerRenderer.selectedItem == it
val icon = generateClusterItemIconFun
?.invoke(it, selected)
?: selectableMarkerRenderer.getVectorResourceAsBitmap(it.getDrawableResourceId(selected))
val markerOptions = MarkerOptions()
.position(it.getLocation().toLatLng())
.icon(BitmapDescriptorFactory.fromBitmap(icon))
.clusterable(true)
val marker = map.addMarker(markerOptions)
it to marker
}
map.setMarkersClustering(true)
map.setOnMarkerClickListener { clickedMarker ->
val clickedItem = addedMarkers.firstOrNull { it.second == clickedMarker }?.first
clickedItem?.let { clusterItemClickListener(it) } ?: false
}
return selectableMarkerRenderer::selectItem
}
}
fun Location.toLatLng() = LatLng(latitude, longitude)
fun SomeLatLngBounds.toLatLngBounds() = LatLngBounds(southwest?.toLatLng(), northeast?.toLatLng())
fun SomeCameraUpdate.toCameraUpdate(): CameraUpdate? {
return if (zoom != null) {
CameraUpdateFactory.newCameraPosition(
CameraPosition.fromLatLngZoom(
location?.toLatLng()
?: Location.DEFAULT_LOCATION.toLatLng(),
zoom
)
)
} else if (bounds != null && width != null && height != null && padding != null) {
CameraUpdateFactory.newLatLngBounds(
bounds.toLatLngBounds(),
width,
height,
padding
)
} else {
null
}
}
class SomeLatLngBoundsImpl(bounds: LatLngBounds? = null) :
SomeLatLngBounds(bounds?.southwest?.toLocation(), bounds?.northeast?.toLocation()) {
override fun forLocations(locations: List<Location>): SomeLatLngBounds {
val bounds = LatLngBounds.builder()
.apply { locations.map { it.toLatLng() }.forEach { include(it) } }
.build()
return SomeLatLngBoundsImpl(bounds)
}
}
fun LatLng.toLocation(): Location {
return Location(latitude, longitude)
}
class MarkerImpl(private val marker: Marker?) : SomeMarker() {
override fun remove() {
marker?.remove()
}
}
На этом реализацию наших абстракций мы закончили. Осталось показать, как это в коде будет использоваться. Важно иметь в виду, что в отличии от аналитики и геолокации, которые работают на любом девайсе, на котором установлены Huawei Mobile Services, карты будут работать только на устройствах от Huawei.
Используем нашу абстрактную карту
Итак, в разметку мы добавляем MapViewImpl
, как было показано выше и переходим к коду. Для начала нам надо из нашей MapView
получить объект карты:
mapView.getMapAsync { onMapReady(it) }
Когда она будет получена — будем рисовать на ней маркеры с помощью нашей абстракции. А также, при нажатии, выделять их и отображать сообщение. И ещё обрабатывать нажатие на кластер. При этом мы, как и планировалось, не зависим от реализации карт:
private fun onMapReady(map: SomeMap) {
map.setUiSettings(isMapToolbarEnabled = false, isCompassEnabled = false)
var pinItemSelected: ((MarkerItem?) -> Unit)? = null
fun onMarkerSelected(selectedMarkerItem: MarkerItem?) {
pinItemSelected?.invoke(selectedMarkerItem)
selectedMarkerItem?.let {
map.animateCamera(SomeCameraUpdate(it.getLocation(), DEFAULT_ZOOM))
Snackbar.make(root, "Marker selected: ${it.markerTitle}", Snackbar.LENGTH_SHORT).show()
}
}
with(map) {
setOnMapClickListener {
onMarkerSelected(null)
}
setOnCameraMoveStartedListener { reason ->
if (reason == SomeMap.REASON_GESTURE) {
onMarkerSelected(null)
}
}
}
locationGateway.requestLastLocation()
.flatMap { mapMarkersGateway.getMapMarkers(it) }
.subscribeBy { itemList ->
pinItemSelected = map.addMarkers(
requireContext(),
itemList.map { it },
{
onMarkerSelected(it)
true
},
{ someCluster ->
mapView?.let { mapViewRef ->
val bounds = SomeLatLngBoundsImpl()
.forLocations(someCluster.items.map { it.getLocation() })
val someCameraUpdate = SomeCameraUpdate(
bounds = bounds,
width = mapViewRef.width,
height = mapViewRef.height,
padding = 32.dp()
)
map.animateCamera(someCameraUpdate)
}
onMarkerSelected(null)
true
}
)
}
}
Часть кода, понятно, опущена для краткости. Полный пример вы можете найти на GitHub.
А вот как выглядят карты разных реализаций (сначала Huawei, потом Google):
По итогу работы с картами можно сказать следующее — с картами гораздо сложнее, чем с местоположением и аналитикой. Особенно, если есть маркеры и кластеризация. Хотя могло быть и хуже, конечно, если бы API для работы с картами отличалось сильнее. Так что можно сказать спасибо команде Huawei за облегчение поддержки карт их реализации.
Заключение
Рынок мобильных приложений меняется. Ещё вчера казавшиеся незыблемыми монополии Google и Apple наконец-то замечены не только пострадавшими от них разработчиками (из самых известных последних — Telegram, Microsoft, Epic Games) но и государственными структурами уровня EC и США. Надеюсь, на этом фоне наконец-то появится здоровая конкурентная среда хотя бы на Android. Работа Huawei в этой области радует — видно, что люди стараются максимально упростить жизнь разработчикам. После опыта общения с тех. поддержкой GooglePlay, где тебе отвечают роботы в основном (и они же и банят) в Huawei тебе отвечают люди. Мало того — когда у меня возник вопрос по их магазину приложений — у меня была возможность просто взять и позвонить живому человеку из Москвы и быстро решить проблему.
В общем, если вы хотите расширить свою аудиторию и получить качественную тех.поддержку в процессе — идите в Huawei. Чем больше там будет разработчиков, тем выше шанс, что и в GooglePlay что-то поменяется в лучшую сторону. И выиграют все.
Весь код, который есть в этом цикле статей вы можете посмотреть в репозитории на GitHub. Вот ссылка.
Javian
off Насколько подробны их карты? Откуда они их наполняют?
kuchanov Автор
Согласно вот этому карты от TomTom. Вроде бы это и в доке видел, но с ходу не нашёл подтверждения
UPD: Вот где это в доке упомянуто (см. п. 2.23): HUAWEI Map Service Agreement