Skip to content

Commit eaad9a2

Browse files
authored
Add directional edge glow when panning hits camera bounds (#10)
1 parent a09b137 commit eaad9a2

File tree

5 files changed

+271
-24
lines changed

5 files changed

+271
-24
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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+
/**
11+
* Represents an edge of the map view for edge effect triggering.
12+
*
13+
* Used with [OpenMapView.triggerEdgeEffect] to specify which edges should display
14+
* a visual glow effect when the camera reaches its bounds.
15+
*/
16+
enum class Edge {
17+
/** Top edge of the map view (north direction) */
18+
TOP,
19+
20+
/** Bottom edge of the map view (south direction) */
21+
BOTTOM,
22+
23+
/** Left edge of the map view (west direction) */
24+
LEFT,
25+
26+
/** Right edge of the map view (east direction) */
27+
RIGHT,
28+
}

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

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,49 @@ class MapController(
9999
* @param latLng The coordinate to clamp
100100
* @return The clamped coordinate
101101
*/
102-
private fun clampToTargetBounds(latLng: LatLng): LatLng {
103-
val bounds = cameraTargetBounds ?: return latLng
102+
private fun clampToTargetBounds(latLng: LatLng): LatLng = clampToTargetBoundsWithEdges(latLng).first
104103

105-
val clampedLat = latLng.latitude.coerceIn(bounds.southwest.latitude, bounds.northeast.latitude)
106-
val clampedLng = latLng.longitude.coerceIn(bounds.southwest.longitude, bounds.northeast.longitude)
104+
/**
105+
* Clamps a LatLng coordinate to remain within the camera target bounds,
106+
* and returns which edges were hit during clamping.
107+
*
108+
* If no bounds are set, returns the input unchanged with an empty edge set.
109+
*
110+
* @param latLng The coordinate to clamp
111+
* @return A pair of the clamped coordinate and the set of edges that were hit
112+
*/
113+
private fun clampToTargetBoundsWithEdges(latLng: LatLng): Pair<LatLng, Set<Edge>> {
114+
val bounds = cameraTargetBounds ?: return Pair(latLng, emptySet())
115+
116+
val hitEdges = mutableSetOf<Edge>()
117+
118+
val clampedLat =
119+
when {
120+
latLng.latitude < bounds.southwest.latitude -> {
121+
hitEdges.add(Edge.BOTTOM)
122+
bounds.southwest.latitude
123+
}
124+
latLng.latitude > bounds.northeast.latitude -> {
125+
hitEdges.add(Edge.TOP)
126+
bounds.northeast.latitude
127+
}
128+
else -> latLng.latitude
129+
}
130+
131+
val clampedLng =
132+
when {
133+
latLng.longitude < bounds.southwest.longitude -> {
134+
hitEdges.add(Edge.LEFT)
135+
bounds.southwest.longitude
136+
}
137+
latLng.longitude > bounds.northeast.longitude -> {
138+
hitEdges.add(Edge.RIGHT)
139+
bounds.northeast.longitude
140+
}
141+
else -> latLng.longitude
142+
}
107143

108-
return LatLng(clampedLat, clampedLng)
144+
return Pair(LatLng(clampedLat, clampedLng), hitEdges)
109145
}
110146

111147
private var viewWidth = 0
@@ -301,17 +337,38 @@ class MapController(
301337
* @param overzoomAmount The amount of overzoom attempted (positive for zoom in, negative for zoom out)
302338
*/
303339
private fun triggerOverzoomEffect(overzoomAmount: Float) {
304-
if (uiSettings?.isZoomEdgeEffectEnabled != true) return
340+
if (uiSettings?.isEdgeEffectEnabled != true) return
305341
if (viewWidth == 0 || viewHeight == 0) return
306342

307343
// Calculate glow strength based on overzoom magnitude
308344
val glowStrength = (kotlin.math.abs(overzoomAmount) * 2.0f).coerceIn(0.3f, 1.0f)
309345

310-
// Set glow on all edges
311-
edgeGlowTop = glowStrength
312-
edgeGlowBottom = glowStrength
313-
edgeGlowLeft = glowStrength
314-
edgeGlowRight = glowStrength
346+
// Trigger edge effect on all edges
347+
triggerEdgeEffect(setOf(Edge.TOP, Edge.BOTTOM, Edge.LEFT, Edge.RIGHT), glowStrength)
348+
}
349+
350+
/**
351+
* Triggers the edge glow effect on specified edges.
352+
*
353+
* This method can be called by app developers from button handlers or other UI controls
354+
* to provide visual feedback when the camera cannot move further in a direction.
355+
*
356+
* @param edges The set of edges to trigger the effect on
357+
* @param intensity The glow intensity from 0.0 (none) to 1.0 (full), default 0.8
358+
*/
359+
fun triggerEdgeEffect(
360+
edges: Set<Edge>,
361+
intensity: Float = 0.8f,
362+
) {
363+
if (uiSettings?.isEdgeEffectEnabled != true) return
364+
if (viewWidth == 0 || viewHeight == 0) return
365+
366+
val clampedIntensity = intensity.coerceIn(0.0f, 1.0f)
367+
368+
if (Edge.TOP in edges) edgeGlowTop = clampedIntensity
369+
if (Edge.BOTTOM in edges) edgeGlowBottom = clampedIntensity
370+
if (Edge.LEFT in edges) edgeGlowLeft = clampedIntensity
371+
if (Edge.RIGHT in edges) edgeGlowRight = clampedIntensity
315372
}
316373

317374
/**
@@ -1052,6 +1109,8 @@ class MapController(
10521109
* Updates the temporary pan offset during a drag gesture.
10531110
*
10541111
* The offset accumulates until committed via [commitPan].
1112+
* If camera target bounds are set and the pan would exceed them,
1113+
* triggers the edge effect on the appropriate edges.
10551114
*
10561115
* @param dx The horizontal movement in pixels
10571116
* @param dy The vertical movement in pixels
@@ -1060,8 +1119,24 @@ class MapController(
10601119
dx: Float,
10611120
dy: Float,
10621121
) {
1063-
panOffsetX -= dx
1064-
panOffsetY -= dy
1122+
val newPanOffsetX = panOffsetX - dx
1123+
val newPanOffsetY = panOffsetY - dy
1124+
1125+
// Check if bounds are set and detect edge hits during panning
1126+
if (cameraTargetBounds != null) {
1127+
val (centerPixelX, centerPixelY) = ProjectionUtils.latLngToPixel(center, zoom.toInt())
1128+
val newCenterPixelX = (centerPixelX + newPanOffsetX).toInt()
1129+
val newCenterPixelY = (centerPixelY + newPanOffsetY).toInt()
1130+
val newCenter = ProjectionUtils.pixelToLatLng(newCenterPixelX, newCenterPixelY, zoom.toInt())
1131+
1132+
val (_, hitEdges) = clampToTargetBoundsWithEdges(newCenter)
1133+
if (hitEdges.isNotEmpty()) {
1134+
triggerEdgeEffect(hitEdges, 0.6f)
1135+
}
1136+
}
1137+
1138+
panOffsetX = newPanOffsetX
1139+
panOffsetY = newPanOffsetY
10651140
}
10661141

10671142
/**

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,34 @@ class OpenMapView
625625
*/
626626
fun getUiSettings(): UiSettings = uiSettings
627627

628+
/**
629+
* Triggers the edge glow effect on specified edges.
630+
*
631+
* Use this method to provide visual feedback when the camera cannot move further
632+
* in a direction, such as when a button press would exceed the camera bounds.
633+
*
634+
* The edge effect respects the [UiSettings.isEdgeEffectEnabled] setting.
635+
*
636+
* Example:
637+
* ```kotlin
638+
* // Trigger effect on the right edge when a "move right" button is pressed at bounds
639+
* mapView.triggerEdgeEffect(setOf(Edge.RIGHT))
640+
*
641+
* // Trigger effect on multiple edges with custom intensity
642+
* mapView.triggerEdgeEffect(setOf(Edge.TOP, Edge.RIGHT), 0.5f)
643+
* ```
644+
*
645+
* @param edges The set of edges to trigger the effect on
646+
* @param intensity The glow intensity from 0.0 (none) to 1.0 (full), default 0.8
647+
*/
648+
fun triggerEdgeEffect(
649+
edges: Set<Edge>,
650+
intensity: Float = 0.8f,
651+
) {
652+
controller.triggerEdgeEffect(edges, intensity)
653+
invalidate()
654+
}
655+
628656
/**
629657
* Sets padding on the map.
630658
*

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,14 @@ class UiSettings {
3232
var isZoomControlsEnabled: Boolean = false
3333

3434
/**
35-
* Whether the zoom edge effect (visual glow when attempting to zoom beyond limits) is enabled.
36-
* When enabled, a visual EdgeEffect glow appears on all four edges when pinch-zoom gestures
37-
* attempt to exceed min/max zoom limits, similar to overscroll behavior.
35+
* Whether the edge effect (visual glow at map boundaries) is enabled.
36+
* When enabled, a visual EdgeEffect glow appears when:
37+
* - Pinch-zoom gestures attempt to exceed min/max zoom limits (all edges glow)
38+
* - Pan gestures attempt to exceed camera target bounds (directional glow on hit edges)
39+
* Similar to Android's overscroll behavior.
3840
* Default is true.
3941
*/
40-
var isZoomEdgeEffectEnabled: Boolean = true
42+
var isEdgeEffectEnabled: Boolean = true
4143

4244
/**
4345
* Whether scroll gestures are enabled during rotate or zoom gestures.

0 commit comments

Comments
 (0)