Skip to content

Commit 5dbb32a

Browse files
committed
Add markers
1 parent 1d4a79b commit 5dbb32a

6 files changed

Lines changed: 304 additions & 48 deletions

File tree

app/build.gradle.kts

Lines changed: 0 additions & 44 deletions
This file was deleted.

examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MainActivity.kt

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,18 @@
88
package de.afarber.openmapview.example03markers
99

1010
import android.os.Bundle
11+
import android.widget.Toast
1112
import androidx.activity.ComponentActivity
1213
import androidx.activity.compose.setContent
1314
import androidx.compose.foundation.layout.fillMaxSize
1415
import androidx.compose.material3.MaterialTheme
1516
import androidx.compose.material3.Surface
1617
import androidx.compose.runtime.Composable
1718
import androidx.compose.ui.Modifier
19+
import androidx.compose.ui.platform.LocalContext
1820
import androidx.compose.ui.viewinterop.AndroidView
1921
import de.afarber.openmapview.LatLng
22+
import de.afarber.openmapview.Marker
2023
import de.afarber.openmapview.OpenMapView
2124

2225
class MainActivity : ComponentActivity() {
@@ -37,12 +40,68 @@ class MainActivity : ComponentActivity() {
3740

3841
@Composable
3942
fun MapViewScreen() {
40-
// TODO: Add marker support
43+
val context = LocalContext.current
44+
4145
AndroidView(
42-
factory = { context ->
43-
OpenMapView(context).apply {
44-
setCenter(LatLng(51.4661, 7.2491)) // Bochum, Germany
46+
factory = { ctx ->
47+
OpenMapView(ctx).apply {
48+
// Center on Bochum, Germany
49+
setCenter(LatLng(51.4661, 7.2491))
4550
setZoom(14.0)
51+
52+
// Add several markers around Bochum
53+
addMarker(
54+
Marker(
55+
position = LatLng(51.4661, 7.2491),
56+
title = "Bochum City Center",
57+
snippet = "Welcome to Bochum!",
58+
),
59+
)
60+
61+
addMarker(
62+
Marker(
63+
position = LatLng(51.4700, 7.2550),
64+
title = "North Location",
65+
snippet = "A place north of center",
66+
),
67+
)
68+
69+
addMarker(
70+
Marker(
71+
position = LatLng(51.4620, 7.2430),
72+
title = "South Location",
73+
snippet = "A place south of center",
74+
),
75+
)
76+
77+
addMarker(
78+
Marker(
79+
position = LatLng(51.4680, 7.2380),
80+
title = "West Location",
81+
snippet = "A place west of center",
82+
),
83+
)
84+
85+
addMarker(
86+
Marker(
87+
position = LatLng(51.4640, 7.2600),
88+
title = "East Location",
89+
snippet = "A place east of center",
90+
),
91+
)
92+
93+
// Set marker click listener
94+
setOnMarkerClickListener { marker ->
95+
val message = buildString {
96+
append(marker.title ?: "Marker")
97+
if (marker.snippet != null) {
98+
append("\n")
99+
append(marker.snippet)
100+
}
101+
}
102+
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
103+
true // Consume the click event
104+
}
46105
}
47106
},
48107
modifier = Modifier.fillMaxSize(),

openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ class MapController(
3232
private const val MAX_ZOOM = 19.0
3333
}
3434

35+
private val markers = mutableListOf<Marker>()
36+
private val defaultMarkerIcon by lazy { MarkerIconFactory.getDefaultIcon() }
37+
var onMarkerClickListener: ((Marker) -> Boolean)? = null
38+
3539
private val scope = CoroutineScope(Dispatchers.Main + Job())
3640
private val tileDownloader = TileDownloader()
3741
private val tileCache = TileCache()
@@ -170,6 +174,34 @@ class MapController(
170174
}
171175
}
172176
}
177+
178+
// Draw markers on top of tiles
179+
drawMarkers(canvas, centerPixelX, centerPixelY)
180+
}
181+
182+
private fun drawMarkers(
183+
canvas: Canvas,
184+
centerPixelX: Double,
185+
centerPixelY: Double,
186+
) {
187+
for (marker in markers) {
188+
// Convert marker position to pixel coordinates
189+
val (markerPixelX, markerPixelY) = Projection.latLngToPixel(marker.position, zoom.toInt())
190+
191+
// Calculate screen position
192+
val screenX = (markerPixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat()
193+
val screenY = (markerPixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat()
194+
195+
// Get marker icon
196+
val icon = marker.icon ?: defaultMarkerIcon
197+
198+
// Apply anchor point
199+
val anchorX = icon.width * marker.anchor.first
200+
val anchorY = icon.height * marker.anchor.second
201+
202+
// Draw the marker
203+
canvas.drawBitmap(icon, screenX - anchorX, screenY - anchorY, null)
204+
}
173205
}
174206

175207
private fun downloadTile(tile: TileCoordinate) {
@@ -189,6 +221,48 @@ class MapController(
189221
}
190222
}
191223

224+
fun addMarker(marker: Marker): Marker {
225+
markers.add(marker)
226+
return marker
227+
}
228+
229+
fun removeMarker(marker: Marker): Boolean = markers.remove(marker)
230+
231+
fun clearMarkers() {
232+
markers.clear()
233+
}
234+
235+
fun getMarkers(): List<Marker> = markers.toList()
236+
237+
fun handleMarkerTouch(
238+
x: Float,
239+
y: Float,
240+
): Marker? {
241+
val (centerPixelX, centerPixelY) = Projection.latLngToPixel(center, zoom.toInt())
242+
243+
// Check markers in reverse order (top to bottom) for correct z-ordering
244+
for (marker in markers.reversed()) {
245+
val (markerPixelX, markerPixelY) = Projection.latLngToPixel(marker.position, zoom.toInt())
246+
247+
val screenX = (markerPixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat()
248+
val screenY = (markerPixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat()
249+
250+
val icon = marker.icon ?: defaultMarkerIcon
251+
val anchorX = icon.width * marker.anchor.first
252+
val anchorY = icon.height * marker.anchor.second
253+
254+
val markerLeft = screenX - anchorX
255+
val markerTop = screenY - anchorY
256+
val markerRight = markerLeft + icon.width
257+
val markerBottom = markerTop + icon.height
258+
259+
if (x >= markerLeft && x <= markerRight && y >= markerTop && y <= markerBottom) {
260+
return marker
261+
}
262+
}
263+
return null
264+
}
265+
192266
fun onResume() {}
193267

194268
fun onPause() {}
@@ -197,5 +271,6 @@ class MapController(
197271
scope.cancel()
198272
tileDownloader.close()
199273
tileCache.clear()
274+
MarkerIconFactory.clearCache()
200275
}
201276
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright (c) 2025 Alexander Farber
3+
* SPDX-License-Identifier: MIT
4+
*
5+
* This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView)
6+
*/
7+
8+
package de.afarber.openmapview
9+
10+
import android.graphics.Bitmap
11+
12+
/**
13+
* Represents a marker on the map at a specific geographic location.
14+
*
15+
* @property position The geographic location of the marker
16+
* @property title Optional title text displayed when marker is clicked
17+
* @property snippet Optional snippet text displayed below the title
18+
* @property icon Custom icon bitmap. If null, a default marker icon will be used
19+
* @property anchor Anchor point for the marker icon. Default (0.5f, 1.0f) means
20+
* the marker is centered horizontally and anchored at the bottom
21+
* @property tag Optional user data associated with the marker
22+
*/
23+
data class Marker(
24+
val position: LatLng,
25+
val title: String? = null,
26+
val snippet: String? = null,
27+
val icon: Bitmap? = null,
28+
val anchor: Pair<Float, Float> = Pair(0.5f, 1.0f),
29+
val tag: Any? = null,
30+
) {
31+
/**
32+
* Unique identifier for this marker instance.
33+
* Used internally for touch detection and callbacks.
34+
*/
35+
internal val id: String = "marker_${System.nanoTime()}_${hashCode()}"
36+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright (c) 2025 Alexander Farber
3+
* SPDX-License-Identifier: MIT
4+
*
5+
* This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView)
6+
*/
7+
8+
package de.afarber.openmapview
9+
10+
import android.graphics.Bitmap
11+
import android.graphics.Canvas
12+
import android.graphics.Color
13+
import android.graphics.Paint
14+
import android.graphics.Path
15+
16+
/**
17+
* Factory for creating default marker icons.
18+
*/
19+
internal object MarkerIconFactory {
20+
private const val DEFAULT_WIDTH = 48
21+
private const val DEFAULT_HEIGHT = 72
22+
private var cachedDefaultIcon: Bitmap? = null
23+
24+
/**
25+
* Creates or returns a cached default marker icon.
26+
* The icon is a red teardrop shape with a white circle in the center.
27+
*/
28+
fun getDefaultIcon(): Bitmap {
29+
cachedDefaultIcon?.let { return it }
30+
31+
val bitmap = Bitmap.createBitmap(DEFAULT_WIDTH, DEFAULT_HEIGHT, Bitmap.Config.ARGB_8888)
32+
val canvas = Canvas(bitmap)
33+
34+
val markerPaint =
35+
Paint().apply {
36+
style = Paint.Style.FILL
37+
color = Color.RED
38+
isAntiAlias = true
39+
}
40+
41+
val borderPaint =
42+
Paint().apply {
43+
style = Paint.Style.STROKE
44+
color = Color.parseColor("#8B0000") // Dark red
45+
strokeWidth = 3f
46+
isAntiAlias = true
47+
}
48+
49+
val centerPaint =
50+
Paint().apply {
51+
style = Paint.Style.FILL
52+
color = Color.WHITE
53+
isAntiAlias = true
54+
}
55+
56+
// Draw teardrop shape
57+
val path = Path()
58+
val centerX = DEFAULT_WIDTH / 2f
59+
val circleRadius = DEFAULT_WIDTH / 2f - 4f
60+
val circleBottom = circleRadius * 2 + 4f
61+
62+
// Create teardrop using circle + triangle
63+
path.addCircle(centerX, circleRadius + 2f, circleRadius, Path.Direction.CW)
64+
65+
// Add the pointy bottom
66+
path.moveTo(centerX - circleRadius * 0.5f, circleBottom - 2f)
67+
path.lineTo(centerX, DEFAULT_HEIGHT.toFloat() - 2f)
68+
path.lineTo(centerX + circleRadius * 0.5f, circleBottom - 2f)
69+
path.close()
70+
71+
// Draw the marker
72+
canvas.drawPath(path, markerPaint)
73+
canvas.drawPath(path, borderPaint)
74+
75+
// Draw white center circle
76+
canvas.drawCircle(centerX, circleRadius + 2f, circleRadius * 0.4f, centerPaint)
77+
78+
cachedDefaultIcon = bitmap
79+
return bitmap
80+
}
81+
82+
/**
83+
* Clears the cached default icon to free memory.
84+
*/
85+
fun clearCache() {
86+
cachedDefaultIcon?.recycle()
87+
cachedDefaultIcon = null
88+
}
89+
}

0 commit comments

Comments
 (0)