Skip to content

Commit e2e9b45

Browse files
committed
Implement setLatLngBoundsForCameraTarget
1 parent e8aeb01 commit e2e9b45

4 files changed

Lines changed: 280 additions & 9 deletions

File tree

docs/PUBLIC_API.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ Methods available on the UiSettings object returned by `getUiSettings()`:
167167

168168
| Method | Return Type | Status | Notes |
169169
| ---------------------------------------------- | ----------- | --------------- | -------------------------- |
170-
| `setLatLngBoundsForCameraTarget(LatLngBounds)` | `void` | NOT IMPLEMENTED | Planned for future release |
170+
| `setLatLngBoundsForCameraTarget(LatLngBounds)` | `void` | IMPLEMENTED | Constrains camera target to bounds |
171171

172172
---
173173

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

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class MapController(
5656

5757
private var minZoomPreference = DEFAULT_MIN_ZOOM
5858
private var maxZoomPreference = DEFAULT_MAX_ZOOM
59+
private var cameraTargetBounds: LatLngBounds? = null
5960

6061
private val markers = mutableListOf<Marker>()
6162
private val defaultMarkerIcon by lazy { MarkerIconFactory.getDefaultIcon() }
@@ -205,6 +206,44 @@ class MapController(
205206
zoom = zoom.coerceIn(minZoomPreference, maxZoomPreference)
206207
}
207208

209+
/**
210+
* Sets a LatLngBounds to constrain the camera target.
211+
*
212+
* When bounds are set, the camera target (center) is constrained to remain within
213+
* these bounds for both user gestures (pan/scroll) and programmatic camera movements.
214+
*
215+
* @param bounds The bounds to constrain the camera target, or null to remove constraints
216+
*/
217+
fun setLatLngBoundsForCameraTarget(bounds: LatLngBounds?) {
218+
cameraTargetBounds = bounds
219+
// Apply constraint immediately if camera is currently outside bounds
220+
bounds?.let { center = clampToTargetBounds(center) }
221+
}
222+
223+
/**
224+
* Returns the current camera target bounds constraint.
225+
*
226+
* @return The bounds constraining the camera target, or null if no constraint is set
227+
*/
228+
fun getLatLngBoundsForCameraTarget(): LatLngBounds? = cameraTargetBounds
229+
230+
/**
231+
* Clamps a LatLng coordinate to remain within the camera target bounds.
232+
*
233+
* If no bounds are set, returns the input unchanged.
234+
*
235+
* @param latLng The coordinate to clamp
236+
* @return The clamped coordinate
237+
*/
238+
private fun clampToTargetBounds(latLng: LatLng): LatLng {
239+
val bounds = cameraTargetBounds ?: return latLng
240+
241+
val clampedLat = latLng.latitude.coerceIn(bounds.southwest.latitude, bounds.northeast.latitude)
242+
val clampedLng = latLng.longitude.coerceIn(bounds.southwest.longitude, bounds.northeast.longitude)
243+
244+
return LatLng(clampedLat, clampedLng)
245+
}
246+
208247
/**
209248
* Sets the tile source for rendering the base map.
210249
*
@@ -264,10 +303,12 @@ class MapController(
264303
/**
265304
* Sets the map center to the specified location.
266305
*
306+
* If camera target bounds are set, the center will be clamped to stay within those bounds.
307+
*
267308
* @param latLng The new center location
268309
*/
269310
fun setCenter(latLng: LatLng) {
270-
center = latLng
311+
center = clampToTargetBounds(latLng)
271312
}
272313

273314
/**
@@ -423,8 +464,8 @@ class MapController(
423464
progress,
424465
)
425466

426-
center = LatLng(interpolatedLat, interpolatedLng)
427-
zoom = interpolatedZoom
467+
setCenter(LatLng(interpolatedLat, interpolatedLng))
468+
setZoom(interpolatedZoom)
428469

429470
// Fire camera move event during animation
430471
onCameraMoveListener?.onCameraMove()
@@ -433,8 +474,8 @@ class MapController(
433474
kotlinx.coroutines.delay(16)
434475
}
435476

436-
center = targetPosition.target
437-
zoom = targetPosition.zoom
477+
setCenter(targetPosition.target)
478+
setZoom(targetPosition.zoom)
438479
onTileLoadedCallback?.invoke()
439480

440481
animationListener?.onFinish()
@@ -476,8 +517,8 @@ class MapController(
476517

477518
private fun applyCameraUpdate(cameraUpdate: CameraUpdate) {
478519
val targetPosition = calculateTargetPosition(cameraUpdate, getCameraPosition())
479-
center = applyPaddingOffset(targetPosition.target, targetPosition.zoom)
480-
zoom = targetPosition.zoom
520+
setCenter(applyPaddingOffset(targetPosition.target, targetPosition.zoom))
521+
setZoom(targetPosition.zoom)
481522
}
482523

483524
/**
@@ -643,7 +684,7 @@ class MapController(
643684
val newCenterPixelX = (centerPixelX + panOffsetX).toInt()
644685
val newCenterPixelY = (centerPixelY + panOffsetY).toInt()
645686

646-
center = ProjectionUtils.pixelToLatLng(newCenterPixelX, newCenterPixelY, zoom.toInt())
687+
setCenter(ProjectionUtils.pixelToLatLng(newCenterPixelX, newCenterPixelY, zoom.toInt()))
647688

648689
// Reset pan offset
649690
panOffsetX = 0f

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,33 @@ class OpenMapView
406406
invalidate()
407407
}
408408

409+
/**
410+
* Sets a LatLngBounds to constrain the camera target.
411+
*
412+
* When bounds are set, the camera target (center point) is constrained to remain within
413+
* these bounds for both user gestures (pan/scroll) and programmatic camera movements
414+
* (via [moveCamera] or [animateCamera]).
415+
*
416+
* The map viewport may show areas outside the bounds (since the camera target is just the
417+
* center point), but the center itself cannot move outside the specified region.
418+
*
419+
* If the camera is currently positioned outside the bounds when this method is called,
420+
* it will be immediately adjusted to the nearest point within the bounds.
421+
*
422+
* @param bounds The bounds to constrain the camera target, or null to remove constraints
423+
*/
424+
fun setLatLngBoundsForCameraTarget(bounds: LatLngBounds?) {
425+
controller.setLatLngBoundsForCameraTarget(bounds)
426+
invalidate()
427+
}
428+
429+
/**
430+
* Returns the current camera target bounds constraint.
431+
*
432+
* @return The bounds constraining the camera target, or null if no constraint is set
433+
*/
434+
fun getLatLngBoundsForCameraTarget(): LatLngBounds? = controller.getLatLngBoundsForCameraTarget()
435+
409436
/**
410437
* Sets the type of map tiles that should be displayed.
411438
*

openmapview/src/test/kotlin/de/afarber/openmapview/MapControllerTest.kt

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,4 +842,207 @@ class MapControllerTest {
842842
// Second position should match target exactly (no padding)
843843
assertEquals(targetLatLng.latitude, position2.target.latitude, 0.0001)
844844
}
845+
846+
@Test
847+
fun testSetLatLngBoundsForCameraTarget_GetterReturnsSetValue() {
848+
val bounds = LatLngBounds(LatLng(-35.0, 138.58), LatLng(-34.9, 138.61))
849+
controller.setLatLngBoundsForCameraTarget(bounds)
850+
assertEquals(bounds, controller.getLatLngBoundsForCameraTarget())
851+
}
852+
853+
@Test
854+
fun testSetLatLngBoundsForCameraTarget_NullRemovesConstraint() {
855+
val bounds = LatLngBounds(LatLng(-35.0, 138.58), LatLng(-34.9, 138.61))
856+
controller.setLatLngBoundsForCameraTarget(bounds)
857+
controller.setLatLngBoundsForCameraTarget(null)
858+
assertNull(controller.getLatLngBoundsForCameraTarget())
859+
}
860+
861+
@Test
862+
fun testSetLatLngBoundsForCameraTarget_ClampsCameraIfOutsideBounds() {
863+
// Set camera far from bounds
864+
controller.setCenter(LatLng(0.0, 0.0))
865+
866+
// Set bounds
867+
val bounds = LatLngBounds(LatLng(-35.0, 138.58), LatLng(-34.9, 138.61))
868+
controller.setLatLngBoundsForCameraTarget(bounds)
869+
870+
// Camera should be clamped to nearest point in bounds
871+
val center = controller.getCenter()
872+
assertTrue(center.latitude >= bounds.southwest.latitude)
873+
assertTrue(center.latitude <= bounds.northeast.latitude)
874+
assertTrue(center.longitude >= bounds.southwest.longitude)
875+
assertTrue(center.longitude <= bounds.northeast.longitude)
876+
}
877+
878+
@Test
879+
fun testSetLatLngBoundsForCameraTarget_ConstrainsSetCenter() {
880+
val bounds = LatLngBounds(LatLng(-35.0, 138.58), LatLng(-34.9, 138.61))
881+
controller.setLatLngBoundsForCameraTarget(bounds)
882+
883+
// Try to set center outside bounds
884+
controller.setCenter(LatLng(0.0, 0.0))
885+
886+
// Center should be clamped
887+
val center = controller.getCenter()
888+
assertTrue(center.latitude >= bounds.southwest.latitude)
889+
assertTrue(center.latitude <= bounds.northeast.latitude)
890+
assertTrue(center.longitude >= bounds.southwest.longitude)
891+
assertTrue(center.longitude <= bounds.northeast.longitude)
892+
}
893+
894+
@Test
895+
fun testSetLatLngBoundsForCameraTarget_ConstrainsMoveCamera() {
896+
val bounds = LatLngBounds(LatLng(-35.0, 138.58), LatLng(-34.9, 138.61))
897+
controller.setLatLngBoundsForCameraTarget(bounds)
898+
899+
// Try to move camera outside bounds
900+
val outsideLocation = LatLng(0.0, 0.0)
901+
controller.moveCamera(CameraUpdateFactory.newLatLng(outsideLocation))
902+
903+
// Center should be clamped
904+
val center = controller.getCenter()
905+
assertTrue(center.latitude >= bounds.southwest.latitude)
906+
assertTrue(center.latitude <= bounds.northeast.latitude)
907+
assertTrue(center.longitude >= bounds.southwest.longitude)
908+
assertTrue(center.longitude <= bounds.northeast.longitude)
909+
}
910+
911+
@Test
912+
fun testSetLatLngBoundsForCameraTarget_AllowsMovementWithinBounds() {
913+
val bounds = LatLngBounds(LatLng(-35.0, 138.58), LatLng(-34.9, 138.61))
914+
controller.setLatLngBoundsForCameraTarget(bounds)
915+
916+
// Move to center of bounds
917+
val centerOfBounds = bounds.getCenter()
918+
controller.setCenter(centerOfBounds)
919+
920+
assertEquals(centerOfBounds.latitude, controller.getCenter().latitude, 0.0001)
921+
assertEquals(centerOfBounds.longitude, controller.getCenter().longitude, 0.0001)
922+
}
923+
924+
@Test
925+
fun testSetLatLngBoundsForCameraTarget_ClampsToSouthwestCorner() {
926+
val bounds = LatLngBounds(LatLng(10.0, 20.0), LatLng(30.0, 40.0))
927+
controller.setLatLngBoundsForCameraTarget(bounds)
928+
929+
// Try to move far southwest of bounds
930+
controller.setCenter(LatLng(-50.0, -50.0))
931+
932+
// Should clamp to southwest corner
933+
val center = controller.getCenter()
934+
assertEquals(10.0, center.latitude, 0.0001)
935+
assertEquals(20.0, center.longitude, 0.0001)
936+
}
937+
938+
@Test
939+
fun testSetLatLngBoundsForCameraTarget_ClampsToNortheastCorner() {
940+
val bounds = LatLngBounds(LatLng(10.0, 20.0), LatLng(30.0, 40.0))
941+
controller.setLatLngBoundsForCameraTarget(bounds)
942+
943+
// Try to move far northeast of bounds
944+
controller.setCenter(LatLng(80.0, 90.0))
945+
946+
// Should clamp to northeast corner
947+
val center = controller.getCenter()
948+
assertEquals(30.0, center.latitude, 0.0001)
949+
assertEquals(40.0, center.longitude, 0.0001)
950+
}
951+
952+
@Test
953+
fun testSetLatLngBoundsForCameraTarget_ClampsLatitudeOnly() {
954+
val bounds = LatLngBounds(LatLng(10.0, 20.0), LatLng(30.0, 40.0))
955+
controller.setLatLngBoundsForCameraTarget(bounds)
956+
957+
// Set position with valid longitude but invalid latitude
958+
controller.setCenter(LatLng(50.0, 30.0))
959+
960+
val center = controller.getCenter()
961+
assertEquals(30.0, center.latitude, 0.0001) // Clamped
962+
assertEquals(30.0, center.longitude, 0.0001) // Unchanged
963+
}
964+
965+
@Test
966+
fun testSetLatLngBoundsForCameraTarget_ClampsLongitudeOnly() {
967+
val bounds = LatLngBounds(LatLng(10.0, 20.0), LatLng(30.0, 40.0))
968+
controller.setLatLngBoundsForCameraTarget(bounds)
969+
970+
// Set position with valid latitude but invalid longitude
971+
controller.setCenter(LatLng(20.0, 50.0))
972+
973+
val center = controller.getCenter()
974+
assertEquals(20.0, center.latitude, 0.0001) // Unchanged
975+
assertEquals(40.0, center.longitude, 0.0001) // Clamped
976+
}
977+
978+
@Test
979+
fun testSetLatLngBoundsForCameraTarget_WorksWithVerySmallBounds() {
980+
// Create very small bounds (0.01 x 0.01 degrees)
981+
val bounds = LatLngBounds(LatLng(51.5, -0.1), LatLng(51.51, -0.09))
982+
controller.setLatLngBoundsForCameraTarget(bounds)
983+
984+
// Try to move outside
985+
controller.setCenter(LatLng(40.0, 10.0))
986+
987+
// Should still clamp
988+
val center = controller.getCenter()
989+
assertTrue(center.latitude >= bounds.southwest.latitude)
990+
assertTrue(center.latitude <= bounds.northeast.latitude)
991+
assertTrue(center.longitude >= bounds.southwest.longitude)
992+
assertTrue(center.longitude <= bounds.northeast.longitude)
993+
}
994+
995+
@Test
996+
fun testSetLatLngBoundsForCameraTarget_NullAllowsFreeMovement() {
997+
// Set bounds, then remove them
998+
val bounds = LatLngBounds(LatLng(10.0, 20.0), LatLng(30.0, 40.0))
999+
controller.setLatLngBoundsForCameraTarget(bounds)
1000+
controller.setLatLngBoundsForCameraTarget(null)
1001+
1002+
// Should be able to move anywhere
1003+
val targetLocation = LatLng(0.0, 0.0)
1004+
controller.setCenter(targetLocation)
1005+
1006+
assertEquals(targetLocation.latitude, controller.getCenter().latitude, 0.0001)
1007+
assertEquals(targetLocation.longitude, controller.getCenter().longitude, 0.0001)
1008+
}
1009+
1010+
@Test
1011+
fun testSetLatLngBoundsForCameraTarget_WorksAtEquator() {
1012+
val bounds = LatLngBounds(LatLng(-1.0, -1.0), LatLng(1.0, 1.0))
1013+
controller.setLatLngBoundsForCameraTarget(bounds)
1014+
1015+
controller.setCenter(LatLng(0.0, 0.0))
1016+
assertEquals(0.0, controller.getCenter().latitude, 0.0001)
1017+
assertEquals(0.0, controller.getCenter().longitude, 0.0001)
1018+
}
1019+
1020+
@Test
1021+
fun testSetLatLngBoundsForCameraTarget_WorksAtPrimeMeridian() {
1022+
val bounds = LatLngBounds(LatLng(50.0, -1.0), LatLng(52.0, 1.0))
1023+
controller.setLatLngBoundsForCameraTarget(bounds)
1024+
1025+
controller.setCenter(LatLng(51.0, 0.0))
1026+
assertEquals(51.0, controller.getCenter().latitude, 0.0001)
1027+
assertEquals(0.0, controller.getCenter().longitude, 0.0001)
1028+
}
1029+
1030+
@Test
1031+
fun testSetLatLngBoundsForCameraTarget_HandlesBoundsUpdate() {
1032+
// Set initial bounds
1033+
val bounds1 = LatLngBounds(LatLng(10.0, 20.0), LatLng(30.0, 40.0))
1034+
controller.setLatLngBoundsForCameraTarget(bounds1)
1035+
controller.setCenter(LatLng(20.0, 30.0))
1036+
1037+
// Update to different bounds
1038+
val bounds2 = LatLngBounds(LatLng(50.0, 60.0), LatLng(70.0, 80.0))
1039+
controller.setLatLngBoundsForCameraTarget(bounds2)
1040+
1041+
// Camera should be clamped to new bounds
1042+
val center = controller.getCenter()
1043+
assertTrue(center.latitude >= bounds2.southwest.latitude)
1044+
assertTrue(center.latitude <= bounds2.northeast.latitude)
1045+
assertTrue(center.longitude >= bounds2.southwest.longitude)
1046+
assertTrue(center.longitude <= bounds2.northeast.longitude)
1047+
}
8451048
}

0 commit comments

Comments
 (0)