From e890576538343572991b8c4823d1e879fe157fca Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Wed, 14 Oct 2026 17:09:04 +0200 Subject: [PATCH] Fix map panning during marker drag and support vector marker icons - Measure the marker drag threshold from the touch-down point and do not pan the map while a draggable marker is pressed below the threshold - Load fromResource() icons through Drawable so vector drawables render instead of falling back to the default pin - Cache decoded resource and asset icons instead of decoding them on every draw --- docs/PUBLIC_API.md | 18 ++-- .../afarber/openmapview/BitmapDescriptor.kt | 4 +- .../openmapview/BitmapDescriptorFactory.kt | 3 + .../de/afarber/openmapview/MapController.kt | 43 +++++--- .../de/afarber/openmapview/OpenMapView.kt | 15 ++- .../de/afarber/openmapview/MarkerDragTest.kt | 101 ++++++++++++++++++ .../openmapview/MarkerIconLoadingTest.kt | 68 ++++++++++++ 7 files changed, 224 insertions(+), 28 deletions(-) create mode 100644 openmapview/src/test/kotlin/de/afarber/openmapview/MarkerDragTest.kt create mode 100644 openmapview/src/test/kotlin/de/afarber/openmapview/MarkerIconLoadingTest.kt diff --git a/docs/PUBLIC_API.md b/docs/PUBLIC_API.md index b330b37..85a4f03 100644 --- a/docs/PUBLIC_API.md +++ b/docs/PUBLIC_API.md @@ -380,15 +380,15 @@ mapView.setOnMarkerDragListener(object : OnMarkerDragListener { ## BitmapDescriptorFactory -| Method | Return Type | Notes | -| ---------------------- | ------------------ | ----- | -| `defaultMarker()` | `BitmapDescriptor` | | -| `defaultMarker(float)` | `BitmapDescriptor` | | -| `fromAsset(String)` | `BitmapDescriptor` | | -| `fromBitmap(Bitmap)` | `BitmapDescriptor` | | -| `fromFile(String)` | `BitmapDescriptor` | | -| `fromPath(String)` | `BitmapDescriptor` | | -| `fromResource(int)` | `BitmapDescriptor` | | +| Method | Return Type | Notes | +| ---------------------- | ------------------ | ----------------------------- | +| `defaultMarker()` | `BitmapDescriptor` | | +| `defaultMarker(float)` | `BitmapDescriptor` | | +| `fromAsset(String)` | `BitmapDescriptor` | | +| `fromBitmap(Bitmap)` | `BitmapDescriptor` | | +| `fromFile(String)` | `BitmapDescriptor` | | +| `fromPath(String)` | `BitmapDescriptor` | | +| `fromResource(int)` | `BitmapDescriptor` | Also accepts vector drawables | --- diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/BitmapDescriptor.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/BitmapDescriptor.kt index 7d4e2c0..bc8c02f 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/BitmapDescriptor.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/BitmapDescriptor.kt @@ -15,7 +15,7 @@ import android.graphics.Bitmap * This sealed class provides different ways to specify marker icons: * - [DefaultMarker]: Default colored teardrop marker * - [BitmapMarker]: Custom bitmap object - * - [ResourceMarker]: Bitmap from drawable resource + * - [ResourceMarker]: Bitmap or vector drawable resource * - [AssetMarker]: Bitmap from assets folder */ sealed class BitmapDescriptor { @@ -38,7 +38,7 @@ sealed class BitmapDescriptor { ) : BitmapDescriptor() /** - * Marker from a drawable resource ID. + * Marker from a drawable resource ID, either a bitmap or a vector drawable. * * @property resourceId The drawable resource ID (e.g., R.drawable.marker_icon) */ diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/BitmapDescriptorFactory.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/BitmapDescriptorFactory.kt index 3cc6237..af82987 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/BitmapDescriptorFactory.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/BitmapDescriptorFactory.kt @@ -44,6 +44,9 @@ object BitmapDescriptorFactory { /** * Creates a marker icon descriptor from a drawable resource. * + * Bitmap and vector drawables are both supported; the drawable is rendered at its + * intrinsic size. + * * @param resourceId The drawable resource ID (e.g., R.drawable.marker_icon) * @return A BitmapDescriptor for the resource marker */ diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt index 448219e..eafdb07 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt @@ -21,6 +21,8 @@ import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.toArgb +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.withSave import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -185,6 +187,9 @@ class MapController( private val markers = mutableListOf() private val defaultMarkerIcon by lazy { MarkerIconFactory.getDefaultIcon() } + + // Decoded resource and asset icons, keyed by descriptor (failed loads are not cached) + private val descriptorBitmapCache = HashMap() var onMarkerClickListener: OnMarkerClickListener? = null var onInfoWindowClickListener: OnInfoWindowClickListener? = null var onInfoWindowCloseListener: OnInfoWindowCloseListener? = null @@ -246,10 +251,12 @@ class MapController( /** * Loads a bitmap from a BitmapDescriptor. * + * Resource and asset bitmaps are decoded once and cached, because this runs on every draw. + * * @param descriptor The descriptor specifying the bitmap source * @return The loaded bitmap, or the default marker icon if loading fails */ - private fun loadBitmap(descriptor: BitmapDescriptor?): Bitmap { + internal fun loadBitmap(descriptor: BitmapDescriptor?): Bitmap { if (descriptor == null) { return defaultMarkerIcon } @@ -264,26 +271,34 @@ class MapController( } is BitmapDescriptor.ResourceMarker -> { - try { - BitmapFactory.decodeResource(context.resources, descriptor.resourceId) - ?: defaultMarkerIcon - } catch (e: Exception) { - defaultMarkerIcon - } + descriptorBitmapCache[descriptor] + ?: decodeResource(descriptor.resourceId)?.also { descriptorBitmapCache[descriptor] = it } + ?: defaultMarkerIcon } is BitmapDescriptor.AssetMarker -> { - try { - context.assets.open(descriptor.assetName).use { stream -> - BitmapFactory.decodeStream(stream) ?: defaultMarkerIcon - } - } catch (e: IOException) { - defaultMarkerIcon - } + descriptorBitmapCache[descriptor] + ?: decodeAsset(descriptor.assetName)?.also { descriptorBitmapCache[descriptor] = it } + ?: defaultMarkerIcon } } } + // Goes through Drawable rather than BitmapFactory so vector and other XML drawables work too + private fun decodeResource(resourceId: Int): Bitmap? = + try { + ContextCompat.getDrawable(context, resourceId)?.toBitmap() + } catch (e: Exception) { + null + } + + private fun decodeAsset(assetName: String): Bitmap? = + try { + context.assets.open(assetName).use { stream -> BitmapFactory.decodeStream(stream) } + } catch (e: IOException) { + null + } + /** * Sets the zoom level, clamping to valid range. * diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt index 5043de4..4a2d302 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt @@ -22,6 +22,9 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import kotlin.time.Duration +// Finger movement in pixels below which a touch counts as a tap and a marker drag does not start +private const val TAP_SLOP_PX = 10f + /** * A MapView powered by OpenStreetMap tiles. * @@ -181,8 +184,14 @@ class OpenMapView val movementDistance = kotlin.math.sqrt((dx * dx + dy * dy).toDouble()) if (draggedMarker != null) { - // Start dragging if moved more than threshold - if (!isDragging && movementDistance > 10) { + // Start dragging once the finger has moved past the slop from the + // down point; until then the map must not pan under the marker + if (!isDragging) { + val fromDownX = event.x - initialTouchX + val fromDownY = event.y - initialTouchY + if (fromDownX * fromDownX + fromDownY * fromDownY <= TAP_SLOP_PX * TAP_SLOP_PX) { + return true + } isDragging = true controller.commitPan() onMarkerDragListener?.onMarkerDragStart(draggedMarker!!) @@ -253,7 +262,7 @@ class OpenMapView val dy = event.y - initialTouchY val movementDistance = kotlin.math.sqrt((dx * dx + dy * dy).toDouble()) - if (movementDistance < 10) { + if (movementDistance < TAP_SLOP_PX) { // Report the tap as a click, so accessibility services and any // OnClickListener are notified like for other clickable views if (event.action == MotionEvent.ACTION_UP) { diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/MarkerDragTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/MarkerDragTest.kt new file mode 100644 index 0000000..2d619bd --- /dev/null +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/MarkerDragTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +import android.content.Context +import android.view.MotionEvent +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MarkerDragTest { + private lateinit var mapView: OpenMapView + private lateinit var marker: Marker + private val center = LatLng(51.5074, -0.1278) + private var dragStarts = 0 + private var cameraMoveStarts = 0 + + @Before + fun setUp() { + val context: Context = ApplicationProvider.getApplicationContext() + mapView = OpenMapView(context) + mapView.layout(0, 0, 400, 400) + mapView.setCenter(center) + mapView.setZoom(12f) + marker = mapView.addMarker(Marker(position = center, draggable = true)) + mapView.setOnMarkerDragListener( + object : OnMarkerDragListener { + override fun onMarkerDragStart(marker: Marker) { + dragStarts++ + } + + override fun onMarkerDrag(marker: Marker) {} + + override fun onMarkerDragEnd(marker: Marker) {} + }, + ) + mapView.setOnCameraMoveStartedListener { cameraMoveStarts++ } + } + + // Sends DOWN at (x, startY), MOVE events of stepY pixels each, then UP + private fun swipe( + x: Float, + startY: Float, + stepY: Float, + steps: Int, + ) { + var time = 0L + val down = MotionEvent.obtain(0, time, MotionEvent.ACTION_DOWN, x, startY, 0) + mapView.onTouchEvent(down) + down.recycle() + for (i in 1..steps) { + time += 16 + val move = MotionEvent.obtain(0, time, MotionEvent.ACTION_MOVE, x, startY + i * stepY, 0) + mapView.onTouchEvent(move) + move.recycle() + } + time += 16 + val up = MotionEvent.obtain(0, time, MotionEvent.ACTION_UP, x, startY + steps * stepY, 0) + mapView.onTouchEvent(up) + up.recycle() + } + + @Test + fun testSmallMoves_OnDraggableMarker_DragMarkerWithoutPanning() { + // Each step is below the 10 px slop, but together they exceed it + swipe(x = 200f, startY = 190f, stepY = 5f, steps = 20) + + assertEquals(1, dragStarts) + assertEquals(0, cameraMoveStarts) + assertNotEquals(center, marker.position) + assertEquals(center, mapView.getCameraPosition().target) + } + + @Test + fun testMoveWithinSlop_OnDraggableMarker_NeitherDragsNorPans() { + swipe(x = 200f, startY = 190f, stepY = 4f, steps = 2) + + assertEquals(0, dragStarts) + assertEquals(0, cameraMoveStarts) + assertEquals(center, marker.position) + } + + @Test + fun testMove_OnEmptyMap_Pans() { + swipe(x = 50f, startY = 50f, stepY = 5f, steps = 20) + + assertEquals(0, dragStarts) + assertEquals(1, cameraMoveStarts) + assertEquals(center, marker.position) + } +} diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/MarkerIconLoadingTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/MarkerIconLoadingTest.kt new file mode 100644 index 0000000..f1f12ea --- /dev/null +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/MarkerIconLoadingTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MarkerIconLoadingTest { + private lateinit var context: Context + private lateinit var controller: MapController + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + controller = MapController(context) + } + + @Test + fun testXmlDrawableResource_IsRenderedAtIntrinsicSize() { + // btn_default is an XML selector, which BitmapFactory cannot decode + val resourceId = android.R.drawable.btn_default + val drawable = ContextCompat.getDrawable(context, resourceId)!! + + val bitmap = controller.loadBitmap(BitmapDescriptorFactory.fromResource(resourceId)) + + assertNotSame(controller.loadBitmap(null), bitmap) + assertEquals(drawable.intrinsicWidth, bitmap.width) + assertEquals(drawable.intrinsicHeight, bitmap.height) + } + + @Test + fun testResourceBitmap_IsDecodedOnce() { + val descriptor = BitmapDescriptorFactory.fromResource(android.R.drawable.ic_menu_compass) + + val first = controller.loadBitmap(descriptor) + val second = controller.loadBitmap(BitmapDescriptorFactory.fromResource(android.R.drawable.ic_menu_compass)) + + assertSame(first, second) + } + + @Test + fun testMissingResource_FallsBackToDefaultIcon() { + val bitmap = controller.loadBitmap(BitmapDescriptorFactory.fromResource(0)) + + assertSame(controller.loadBitmap(null), bitmap) + } + + @Test + fun testMissingAsset_FallsBackToDefaultIcon() { + val bitmap = controller.loadBitmap(BitmapDescriptorFactory.fromAsset("missing.png")) + + assertSame(controller.loadBitmap(null), bitmap) + } +}