Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions docs/PUBLIC_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
43 changes: 29 additions & 14 deletions openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -185,6 +187,9 @@ class MapController(

private val markers = mutableListOf<Marker>()
private val defaultMarkerIcon by lazy { MarkerIconFactory.getDefaultIcon() }

// Decoded resource and asset icons, keyed by descriptor (failed loads are not cached)
private val descriptorBitmapCache = HashMap<BitmapDescriptor, Bitmap>()
var onMarkerClickListener: OnMarkerClickListener? = null
var onInfoWindowClickListener: OnInfoWindowClickListener? = null
var onInfoWindowCloseListener: OnInfoWindowCloseListener? = null
Expand Down Expand Up @@ -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
}
Expand All @@ -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.
*
Expand Down
15 changes: 12 additions & 3 deletions openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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!!)
Expand Down Expand Up @@ -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) {
Expand Down
101 changes: 101 additions & 0 deletions openmapview/src/test/kotlin/de/afarber/openmapview/MarkerDragTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading