From 790672b39ec2a7b27db3634cc2dbd59aeaa8408e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20G=C4=99siarz?= <126520293+arturgesiarz@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:53:46 +0100 Subject: [PATCH 01/11] Add basic version of bounds --- .../com/swmansion/kmpmaps/core/Extensions.kt | 12 ++++++++++-- .../kotlin/com/swmansion/kmpmaps/core/Map.kt | 3 ++- .../kotlin/com/swmansion/kmpmaps/core/MapTypes.kt | 14 ++++++++++++++ .../com/swmansion/kmpmaps/core/Extensions.kt | 15 +++++++++++++-- .../core/src/jvmMain/resources/web/google_map.js | 10 +++++++++- .../kmpmaps/sample/MapSettingsControls.kt | 11 +++++++++++ 6 files changed, 59 insertions(+), 6 deletions(-) diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt index 41b4e4c..9ee7e5d 100644 --- a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt @@ -15,6 +15,7 @@ import com.google.android.gms.maps.model.Dash import com.google.android.gms.maps.model.Dot import com.google.android.gms.maps.model.Gap import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds import com.google.android.gms.maps.model.MapStyleOptions import com.google.android.gms.maps.model.PatternItem import com.google.maps.android.clustering.Cluster as GoogleMapCluster @@ -50,12 +51,19 @@ internal fun CameraPosition.toGoogleMapsCameraPosition() = /** * Converts Google Maps CameraPosition back to CameraPosition. * - * @return CameraPosition with coordinates, zoom, bearing, and tilt + * @param latLngBounds Optional visible region bounds to include in the result + * @return CameraPosition with coordinates, zoom, bearing, tilt, and optional bounds */ -internal fun GoogleCameraPosition.toCameraPosition() = +internal fun GoogleCameraPosition.toCameraPosition(latLngBounds: LatLngBounds? = null) = CameraPosition( coordinates = Coordinates(latitude = target.latitude, longitude = target.longitude), zoom = zoom, + bounds = latLngBounds?.let { + MapBounds( + northeast = Coordinates(it.northeast.latitude, it.northeast.longitude), + southwest = Coordinates(it.southwest.latitude, it.southwest.longitude), + ) + }, androidCameraPosition = AndroidCameraPosition(bearing = bearing, tilt = tilt), ) diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt index f8a233b..948c4fe 100644 --- a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt @@ -288,7 +288,8 @@ public actual fun Map( } LaunchedEffect(cameraPositionState.position) { - onCameraMove?.invoke(cameraPositionState.position.toCameraPosition()) + val bounds = cameraPositionState.projection?.visibleRegion?.latLngBounds + onCameraMove?.invoke(cameraPositionState.position.toCameraPosition(bounds)) } } } diff --git a/kmp-maps/core/src/commonMain/kotlin/com/swmansion/kmpmaps/core/MapTypes.kt b/kmp-maps/core/src/commonMain/kotlin/com/swmansion/kmpmaps/core/MapTypes.kt index e2b430e..a4daee0 100644 --- a/kmp-maps/core/src/commonMain/kotlin/com/swmansion/kmpmaps/core/MapTypes.kt +++ b/kmp-maps/core/src/commonMain/kotlin/com/swmansion/kmpmaps/core/MapTypes.kt @@ -193,11 +193,24 @@ public fun Polyline.getId(): String = "polyline_${hashCode()}" */ @Serializable public data class Coordinates(val latitude: Double, val longitude: Double) +/** + * Represents the visible geographic bounds of the map. + * + * @property northeast The northeast corner coordinates of the visible region + * @property southwest The southwest corner coordinates of the visible region + */ +@Serializable +public data class MapBounds( + val northeast: Coordinates, + val southwest: Coordinates, +) + /** * Represents the camera position and orientation of the map. * * @property coordinates The center coordinates of the camera view * @property zoom The zoom level of the map (typically 0-20) + * @property bounds The visible geographic bounds of the map, or null if not available * @property androidCameraPosition Android-specific options for the camera position and orientation * @property iosCameraPosition iOS-specific options for the camera position and orientation */ @@ -205,6 +218,7 @@ public fun Polyline.getId(): String = "polyline_${hashCode()}" public data class CameraPosition( val coordinates: Coordinates, val zoom: Float, + val bounds: MapBounds? = null, val androidCameraPosition: AndroidCameraPosition? = null, val iosCameraPosition: IosCameraPosition? = null, ) diff --git a/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt b/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt index 82d2365..8381888 100644 --- a/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt +++ b/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt @@ -142,7 +142,7 @@ internal fun CameraPosition.toMKCoordinateRegion(): CValue { /** * Converts Apple MapKit's MKCoordinateRegion back to CameraPosition. * - * @return CameraPosition with calculated zoom level and coordinates + * @return CameraPosition with calculated zoom level, coordinates, and visible bounds */ @OptIn(ExperimentalForeignApi::class) internal fun CValue.toCameraPosition() = useContents { @@ -150,7 +150,18 @@ internal fun CValue.toCameraPosition() = useContents { val lngZoom = ln(360.0 / span.longitudeDelta) / ln(2.0) val zoom = min(latZoom, lngZoom).toFloat() - CameraPosition(coordinates = Coordinates(center.latitude, center.longitude), zoom = zoom) + val halfLat = span.latitudeDelta / 2.0 + val halfLng = span.longitudeDelta / 2.0 + val bounds = MapBounds( + northeast = Coordinates(center.latitude + halfLat, center.longitude + halfLng), + southwest = Coordinates(center.latitude - halfLat, center.longitude - halfLng), + ) + + CameraPosition( + coordinates = Coordinates(center.latitude, center.longitude), + zoom = zoom, + bounds = bounds, + ) } /** diff --git a/kmp-maps/core/src/jvmMain/resources/web/google_map.js b/kmp-maps/core/src/jvmMain/resources/web/google_map.js index eef2208..211b32d 100644 --- a/kmp-maps/core/src/jvmMain/resources/web/google_map.js +++ b/kmp-maps/core/src/jvmMain/resources/web/google_map.js @@ -67,14 +67,22 @@ async function initMap() { map.addListener("idle", () => { const center = map.getCenter(); const zoom = map.getZoom(); + const mapBounds = map.getBounds(); if (center) { + const ne = mapBounds ? mapBounds.getNorthEast() : null; + const sw = mapBounds ? mapBounds.getSouthWest() : null; + const cameraState = { coordinates: { latitude: center.lat(), longitude: center.lng(), }, - zoom + zoom, + bounds: (ne && sw) ? { + northeast: { latitude: ne.lat(), longitude: ne.lng() }, + southwest: { latitude: sw.lat(), longitude: sw.lng() } + } : null }; if (window.kmpJsBridge) { diff --git a/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapSettingsControls.kt b/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapSettingsControls.kt index b583a3f..8d38296 100644 --- a/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapSettingsControls.kt +++ b/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapSettingsControls.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.swmansion.kmpmaps.core.CameraPosition import com.swmansion.kmpmaps.core.Coordinates +import com.swmansion.kmpmaps.core.MapBounds import com.swmansion.kmpmaps.core.MapTheme import com.swmansion.kmpmaps.core.MapType import kotlin.random.Random @@ -159,5 +160,15 @@ private fun getRandomPosition() = latitude = Random.nextDouble(50.0, 52.0), longitude = Random.nextDouble(50.0, 52.0), ), + bounds = MapBounds( + northeast = Coordinates( + latitude = Random.nextDouble(50.0, 52.0), + longitude = Random.nextDouble(50.0, 52.0), + ), + southwest = Coordinates( + latitude = Random.nextDouble(50.0, 52.0), + longitude = Random.nextDouble(50.0, 52.0), + ), + ), zoom = 13f, ) From 283f4448cdf1a16947e5c0eab38f4cdb9a70ff2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20G=C4=99siarz?= <126520293+arturgesiarz@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:24:50 +0100 Subject: [PATCH 02/11] Add bounds support to all platforms --- .../com/swmansion/kmpmaps/core/Extensions.kt | 56 ++++++++++++--- .../kotlin/com/swmansion/kmpmaps/core/Map.kt | 5 +- .../com/swmansion/kmpmaps/core/MapTypes.kt | 6 +- .../com/swmansion/kmpmaps/core/Extensions.kt | 22 ++++-- .../kmpmaps/core/MapContentLoader.kt | 17 ++++- .../kmpmaps/googlemaps/Extensions.kt | 71 +++++++++++++++---- .../kmpmaps/googlemaps/MapDelegate.kt | 2 + .../kmpmaps/sample/MapSettingsControls.kt | 10 --- .../swmansion/kmpmaps/sample/MapWrapper.kt | 5 ++ 9 files changed, 145 insertions(+), 49 deletions(-) diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt index 9ee7e5d..449a674 100644 --- a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt @@ -9,6 +9,7 @@ import androidx.core.graphics.toColorInt import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.isGranted +import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.model.CameraPosition as GoogleCameraPosition import com.google.android.gms.maps.model.Dash @@ -36,17 +37,49 @@ import com.google.maps.android.data.geojson.GeoJsonPolygonStyle import org.json.JSONObject /** - * Converts CameraPosition to Google Maps CameraPosition. + * Converts [MapBounds] to Google Maps [LatLngBounds]. * - * @return GoogleCameraPosition with coordinates, zoom, bearing, and tilt + * @return [LatLngBounds] built from northeast and southwest corners */ -internal fun CameraPosition.toGoogleMapsCameraPosition() = - GoogleCameraPosition.Builder() - .target(LatLng(coordinates.latitude, coordinates.longitude)) +internal fun MapBounds.toLatLngBounds() = + LatLngBounds.Builder() + .include(LatLng(northeast.latitude, northeast.longitude)) + .include(LatLng(southwest.latitude, southwest.longitude)) + .build() + +/** + * Converts [CameraPosition] to [GoogleCameraPosition]. + * + * When [CameraPosition.bounds] is set, the center of the bounds is used as the camera target. + * Otherwise, [CameraPosition.coordinates] is used. + * + * @return [GoogleCameraPosition] with coordinates, zoom, bearing, and tilt + */ +internal fun CameraPosition.toGoogleMapsCameraPosition(): GoogleCameraPosition { + val target = + bounds?.toLatLngBounds()?.center ?: LatLng(coordinates.latitude, coordinates.longitude) + return GoogleCameraPosition.Builder() + .target(target) .zoom(zoom) .bearing(androidCameraPosition?.bearing ?: 0f) .tilt(androidCameraPosition?.tilt ?: 0f) .build() +} + +/** + * Returns a CameraUpdate for this position. + * + * When [CameraPosition.bounds] is set, uses [CameraUpdateFactory.newLatLngBounds] so the camera + * zooms to fit the entire region. Otherwise, falls back to [CameraUpdateFactory.newCameraPosition]. + * + * @param padding Padding in pixels around the bounds (only used when bounds is set) + */ +internal fun CameraPosition.toCameraUpdate(padding: Int = 0) = + if (bounds != null) { + CameraUpdateFactory.newLatLngBounds(bounds.toLatLngBounds(), padding) + } else { + CameraUpdateFactory.newCameraPosition(toGoogleMapsCameraPosition()) + } /** * Converts Google Maps CameraPosition back to CameraPosition. @@ -58,12 +91,13 @@ internal fun GoogleCameraPosition.toCameraPosition(latLngBounds: LatLngBounds? = CameraPosition( coordinates = Coordinates(latitude = target.latitude, longitude = target.longitude), zoom = zoom, - bounds = latLngBounds?.let { - MapBounds( - northeast = Coordinates(it.northeast.latitude, it.northeast.longitude), - southwest = Coordinates(it.southwest.latitude, it.southwest.longitude), - ) - }, + bounds = + latLngBounds?.let { + MapBounds( + northeast = Coordinates(it.northeast.latitude, it.northeast.longitude), + southwest = Coordinates(it.southwest.latitude, it.southwest.longitude), + ) + }, androidCameraPosition = AndroidCameraPosition(bearing = bearing, tilt = tilt), ) diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt index 948c4fe..d0780d8 100644 --- a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.graphics.toArgb import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import com.google.android.gms.maps.CameraUpdateFactory import com.google.maps.android.compose.Circle import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.MapEffect @@ -73,9 +72,7 @@ public actual fun Map( LaunchedEffect(cameraPosition, mapLoaded) { if (mapLoaded && cameraPosition != null) { - cameraPositionState.move( - CameraUpdateFactory.newCameraPosition(cameraPosition.toGoogleMapsCameraPosition()) - ) + cameraPositionState.move(cameraPosition.toCameraUpdate()) } } diff --git a/kmp-maps/core/src/commonMain/kotlin/com/swmansion/kmpmaps/core/MapTypes.kt b/kmp-maps/core/src/commonMain/kotlin/com/swmansion/kmpmaps/core/MapTypes.kt index a4daee0..cfcade2 100644 --- a/kmp-maps/core/src/commonMain/kotlin/com/swmansion/kmpmaps/core/MapTypes.kt +++ b/kmp-maps/core/src/commonMain/kotlin/com/swmansion/kmpmaps/core/MapTypes.kt @@ -199,11 +199,7 @@ public fun Polyline.getId(): String = "polyline_${hashCode()}" * @property northeast The northeast corner coordinates of the visible region * @property southwest The southwest corner coordinates of the visible region */ -@Serializable -public data class MapBounds( - val northeast: Coordinates, - val southwest: Coordinates, -) +@Serializable public data class MapBounds(val northeast: Coordinates, val southwest: Coordinates) /** * Represents the camera position and orientation of the map. diff --git a/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt b/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt index 8381888..b11e9d1 100644 --- a/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt +++ b/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt @@ -126,10 +126,23 @@ import platform.posix.memcpy /** * Converts a CameraPosition to Apple MapKit's MKCoordinateRegion. * + * When [CameraPosition.bounds] is set, the region is derived from the bounds (center and span are + * computed from northeast/southwest corners). Otherwise, [CameraPosition.coordinates] and + * [CameraPosition.zoom] are used. + * * @return MKCoordinateRegion representing the camera's view area */ @OptIn(ExperimentalForeignApi::class) internal fun CameraPosition.toMKCoordinateRegion(): CValue { + if (bounds != null) { + val centerLat = (bounds.northeast.latitude + bounds.southwest.latitude) / 2.0 + val centerLng = (bounds.northeast.longitude + bounds.southwest.longitude) / 2.0 + val latDelta = kotlin.math.abs(bounds.northeast.latitude - bounds.southwest.latitude) + val lngDelta = kotlin.math.abs(bounds.northeast.longitude - bounds.southwest.longitude) + val coordinate = CLLocationCoordinate2DMake(centerLat, centerLng) + val span = MKCoordinateSpanMake(latDelta, lngDelta) + return MKCoordinateRegionMake(coordinate, span) + } val coordinate = CLLocationCoordinate2DMake(coordinates.latitude, coordinates.longitude) val span = MKCoordinateSpanMake( @@ -152,10 +165,11 @@ internal fun CValue.toCameraPosition() = useContents { val halfLat = span.latitudeDelta / 2.0 val halfLng = span.longitudeDelta / 2.0 - val bounds = MapBounds( - northeast = Coordinates(center.latitude + halfLat, center.longitude + halfLng), - southwest = Coordinates(center.latitude - halfLat, center.longitude - halfLng), - ) + val bounds = + MapBounds( + northeast = Coordinates(center.latitude + halfLat, center.longitude + halfLng), + southwest = Coordinates(center.latitude - halfLat, center.longitude - halfLng), + ) CameraPosition( coordinates = Coordinates(center.latitude, center.longitude), diff --git a/kmp-maps/core/src/jvmMain/kotlin/com/swmansion/kmpmaps/core/MapContentLoader.kt b/kmp-maps/core/src/jvmMain/kotlin/com/swmansion/kmpmaps/core/MapContentLoader.kt index c265e28..dd04170 100644 --- a/kmp-maps/core/src/jvmMain/kotlin/com/swmansion/kmpmaps/core/MapContentLoader.kt +++ b/kmp-maps/core/src/jvmMain/kotlin/com/swmansion/kmpmaps/core/MapContentLoader.kt @@ -19,12 +19,25 @@ internal fun loadHTMLContent( properties: MapProperties?, ): String { val html = readResource("web/google_map.html") + val bounds = cameraPosition?.bounds + val initialLat = + if (bounds != null) { + (bounds.northeast.latitude + bounds.southwest.latitude) / 2.0 + } else { + cameraPosition?.coordinates?.latitude + } + val initialLng = + if (bounds != null) { + (bounds.northeast.longitude + bounds.southwest.longitude) / 2.0 + } else { + cameraPosition?.coordinates?.longitude + } val js = readResource("web/google_map.js") .replace("{{INITIAL_MAP_ID}}", properties?.webMapProperties?.mapId ?: "DEMO_MAP_ID") .replace("{{INITIAL_COLOR_SCHEME}}", properties?.mapTheme?.name ?: MapTheme.SYSTEM.name) - .replace("{{INITIAL_LAT}}", cameraPosition?.coordinates?.latitude.toString()) - .replace("{{INITIAL_LNG}}", cameraPosition?.coordinates?.longitude.toString()) + .replace("{{INITIAL_LAT}}", initialLat.toString()) + .replace("{{INITIAL_LNG}}", initialLng.toString()) .replace("{{INITIAL_ZOOM}}", cameraPosition?.zoom.toString()) return html.replace("{{API_KEY}}", apiKey).replace("{{LOCAL_JS_CONTENT}}", js) diff --git a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt index bcbb556..436f695 100644 --- a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt +++ b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt @@ -12,6 +12,8 @@ import cocoapods.GoogleMaps.kGMSTypeNormal import cocoapods.GoogleMaps.kGMSTypeSatellite import cocoapods.GoogleMaps.kGMSTypeTerrain import cocoapods.Google_Maps_iOS_Utils.GMSCameraPosition +import cocoapods.Google_Maps_iOS_Utils.GMSCameraUpdate +import cocoapods.Google_Maps_iOS_Utils.GMSCoordinateBounds import cocoapods.Google_Maps_iOS_Utils.GMSMapStyle import cocoapods.Google_Maps_iOS_Utils.GMSMapView as UtilsGMSMapView import cocoapods.Google_Maps_iOS_Utils.GMSMarker @@ -23,7 +25,9 @@ import cocoapods.Google_Maps_iOS_Utils.GMUGeometryRenderer import cocoapods.Google_Maps_iOS_Utils.GMUNonHierarchicalDistanceBasedAlgorithm import com.swmansion.kmpmaps.core.CameraPosition import com.swmansion.kmpmaps.core.Circle +import com.swmansion.kmpmaps.core.Coordinates import com.swmansion.kmpmaps.core.GoogleMapsMapStyleOptions +import com.swmansion.kmpmaps.core.MapBounds import com.swmansion.kmpmaps.core.MapType import com.swmansion.kmpmaps.core.MapUISettings import com.swmansion.kmpmaps.core.Marker @@ -33,6 +37,7 @@ import com.swmansion.kmpmaps.core.getId import com.swmansion.kmpmaps.core.toAppleMapsColor import kotlin.collections.set import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents import platform.CoreGraphics.CGPointMake import platform.CoreLocation.CLLocationCoordinate2DMake import platform.Foundation.NSDictionary @@ -259,26 +264,66 @@ public fun UtilsGMSMapView.renderGeoJson(geoJson: String): GMUGeometryRenderer? } /** - * Converts the [CameraPosition] to a native [GMSCameraPosition] and applies it to this map view. + * Converts the [CameraPosition] to a native camera update and applies it to this map view. * - * @param position The camera position to convert + * When [CameraPosition.bounds] is set, uses [GMSCameraUpdate.fitBounds] so the camera zooms to fit + * the entire region. Otherwise, falls back to a direct [GMSCameraPosition]. + * + * @param position The camera position to apply */ @OptIn(ExperimentalForeignApi::class) public fun UtilsGMSMapView.setUpGMSCameraPosition(position: CameraPosition) { - setCamera( - GMSCameraPosition.cameraWithTarget( - target = - CLLocationCoordinate2DMake( - position.coordinates.latitude, - position.coordinates.longitude, - ), - zoom = position.zoom, - bearing = (position.iosCameraPosition?.gmsBearing ?: 0f).toDouble(), - viewingAngle = (position.iosCameraPosition?.gmsViewingAngle ?: 0f).toDouble(), + val bounds = position.bounds + if (bounds != null) { + moveCamera( + GMSCameraUpdate.fitBounds( + GMSCoordinateBounds( + CLLocationCoordinate2DMake( + bounds.northeast.latitude, + bounds.northeast.longitude, + ), + CLLocationCoordinate2DMake( + bounds.southwest.latitude, + bounds.southwest.longitude, + ), + ) + ) ) - ) + } else { + setCamera( + GMSCameraPosition.cameraWithTarget( + target = + CLLocationCoordinate2DMake( + position.coordinates.latitude, + position.coordinates.longitude, + ), + zoom = position.zoom, + bearing = (position.iosCameraPosition?.gmsBearing ?: 0f).toDouble(), + viewingAngle = (position.iosCameraPosition?.gmsViewingAngle ?: 0f).toDouble(), + ) + ) + } } +/** + * Returns the current visible geographic bounds of the map view. + * + * Uses the map's projection to read the visible region, mapping [farRight] to the northeast corner + * and [nearLeft] to the southwest corner of the returned [MapBounds]. + * + * @return The current visible geographic bounds of the map. + */ +@OptIn(ExperimentalForeignApi::class) +internal fun UtilsGMSMapView.getVisibleMapBounds(): MapBounds = + projection().let { proj -> + proj.visibleRegion().useContents { + MapBounds( + northeast = Coordinates(farRight.latitude, farRight.longitude), + southwest = Coordinates(nearLeft.latitude, nearLeft.longitude), + ) + } + } + /** * Converts an optional Compose Color to a UIColor, using the provided fallback when null. * diff --git a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/MapDelegate.kt b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/MapDelegate.kt index b9d57d9..39adf71 100644 --- a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/MapDelegate.kt +++ b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/MapDelegate.kt @@ -57,10 +57,12 @@ internal class MapDelegate( override fun mapView(mapView: GMSMapView, didChangeCameraPosition: GMSCameraPosition) { onCameraMove?.let { didChangeCameraPosition.target().useContents { + val visibleBounds = mapView.getVisibleMapBounds() val cameraPosition = CameraPosition( coordinates = Coordinates(latitude = latitude, longitude = longitude), zoom = didChangeCameraPosition.zoom(), + bounds = visibleBounds, ) it(cameraPosition) } diff --git a/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapSettingsControls.kt b/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapSettingsControls.kt index 8d38296..2bd90f6 100644 --- a/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapSettingsControls.kt +++ b/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapSettingsControls.kt @@ -160,15 +160,5 @@ private fun getRandomPosition() = latitude = Random.nextDouble(50.0, 52.0), longitude = Random.nextDouble(50.0, 52.0), ), - bounds = MapBounds( - northeast = Coordinates( - latitude = Random.nextDouble(50.0, 52.0), - longitude = Random.nextDouble(50.0, 52.0), - ), - southwest = Coordinates( - latitude = Random.nextDouble(50.0, 52.0), - longitude = Random.nextDouble(50.0, 52.0), - ), - ), zoom = 13f, ) diff --git a/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapWrapper.kt b/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapWrapper.kt index bec8c14..bd4582b 100644 --- a/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapWrapper.kt +++ b/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapWrapper.kt @@ -10,6 +10,7 @@ import com.swmansion.kmpmaps.core.ClusterSettings import com.swmansion.kmpmaps.core.Coordinates import com.swmansion.kmpmaps.core.GeoJsonLayer import com.swmansion.kmpmaps.core.Map as CoreMap +import com.swmansion.kmpmaps.core.MapBounds import com.swmansion.kmpmaps.core.MapProperties import com.swmansion.kmpmaps.core.MapTheme import com.swmansion.kmpmaps.core.MapType @@ -28,6 +29,10 @@ internal data class MapOptions( CameraPosition( coordinates = Coordinates(latitude = 50.0619, longitude = 19.9373), zoom = 14f, + bounds = MapBounds( + northeast = Coordinates(latitude = 40.9176, longitude = -73.7004), + southwest = Coordinates(latitude = 40.4774, longitude = -74.2591) + ) ), val showAllComponents: Boolean = true, val useGoogleMapsMapView: Boolean = true, From 511878d9648a5f423c74f6788ebb8890e8a01dee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20G=C4=99siarz?= <126520293+arturgesiarz@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:27:26 +0100 Subject: [PATCH 03/11] Formatting --- .../com/swmansion/kmpmaps/sample/MapSettingsControls.kt | 1 - .../kotlin/com/swmansion/kmpmaps/sample/MapWrapper.kt | 9 +++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapSettingsControls.kt b/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapSettingsControls.kt index 2bd90f6..b583a3f 100644 --- a/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapSettingsControls.kt +++ b/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapSettingsControls.kt @@ -16,7 +16,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.swmansion.kmpmaps.core.CameraPosition import com.swmansion.kmpmaps.core.Coordinates -import com.swmansion.kmpmaps.core.MapBounds import com.swmansion.kmpmaps.core.MapTheme import com.swmansion.kmpmaps.core.MapType import kotlin.random.Random diff --git a/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapWrapper.kt b/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapWrapper.kt index bd4582b..ae21189 100644 --- a/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapWrapper.kt +++ b/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapWrapper.kt @@ -29,10 +29,11 @@ internal data class MapOptions( CameraPosition( coordinates = Coordinates(latitude = 50.0619, longitude = 19.9373), zoom = 14f, - bounds = MapBounds( - northeast = Coordinates(latitude = 40.9176, longitude = -73.7004), - southwest = Coordinates(latitude = 40.4774, longitude = -74.2591) - ) + bounds = + MapBounds( + northeast = Coordinates(latitude = 40.9176, longitude = -73.7004), + southwest = Coordinates(latitude = 40.4774, longitude = -74.2591), + ), ), val showAllComponents: Boolean = true, val useGoogleMapsMapView: Boolean = true, From 3c7bc4090729ef3202e08316c6038c4c3b69dc43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20G=C4=99siarz?= <126520293+arturgesiarz@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:57:35 +0100 Subject: [PATCH 04/11] Improve bounds on Android --- .gitignore | 1 + .../com/swmansion/kmpmaps/core/Extensions.kt | 19 ++++++-- .../kotlin/com/swmansion/kmpmaps/core/Map.kt | 12 ++++- .../com/swmansion/kmpmaps/core/Utils.kt | 47 +++++++++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt diff --git a/.gitignore b/.gitignore index ae20dee..7880ee5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ xcuserdata **/Pods/ sample/kcef-bundle/ kmp-maps/core/bin +.claude diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt index 449a674..990c789 100644 --- a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt @@ -50,17 +50,28 @@ internal fun MapBounds.toLatLngBounds() = /** * Converts [CameraPosition] to [GoogleCameraPosition]. * - * When [CameraPosition.bounds] is set, the center of the bounds is used as the camera target. - * Otherwise, [CameraPosition.coordinates] is used. + * When [CameraPosition.bounds] is set and viewport dimensions are provided, computes the zoom level + * to fit the bounds using the Mercator projection formula. Otherwise, uses [CameraPosition.zoom]. * + * @param viewportWidth Map viewport width in pixels (used to compute zoom for bounds) + * @param viewportHeight Map viewport height in pixels (used to compute zoom for bounds) * @return [GoogleCameraPosition] with coordinates, zoom, bearing, and tilt */ -internal fun CameraPosition.toGoogleMapsCameraPosition(): GoogleCameraPosition { +internal fun CameraPosition.toGoogleMapsCameraPosition( + viewportWidth: Int = 0, + viewportHeight: Int = 0, +): GoogleCameraPosition { val target = bounds?.toLatLngBounds()?.center ?: LatLng(coordinates.latitude, coordinates.longitude) + val computedZoom = + if (bounds != null && viewportWidth > 0 && viewportHeight > 0) { + calculateZoomFromViewport(viewportWidth, viewportHeight, bounds) + } else { + zoom + } return GoogleCameraPosition.Builder() .target(target) - .zoom(zoom) + .zoom(computedZoom) .bearing(androidCameraPosition?.bearing ?: 0f) .tilt(androidCameraPosition?.tilt ?: 0f) .build() diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt index d0780d8..69ea1ac 100644 --- a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt @@ -14,6 +14,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -58,10 +60,18 @@ public actual fun Map( customMarkerContent: Map Unit>, webCustomMarkerContent: Map String>, ) { + val density = LocalDensity.current + val containerSize = LocalWindowInfo.current.containerSize + + val viewportWidthDp = with(density) { containerSize.width.toDp().value.toInt() } + val viewportHeightDp = with(density) { containerSize.height.toDp().value.toInt() } + var mapLoaded by remember { mutableStateOf(false) } val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) val cameraPositionState = rememberCameraPositionState { - cameraPosition?.let { position = it.toGoogleMapsCameraPosition() } + cameraPosition?.let { + position = it.toGoogleMapsCameraPosition(viewportWidthDp, viewportHeightDp) + } } LaunchedEffect(properties.isMyLocationEnabled) { diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt new file mode 100644 index 0000000..af9009b --- /dev/null +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt @@ -0,0 +1,47 @@ +package com.swmansion.kmpmaps.core + +import kotlin.math.PI +import kotlin.math.ln +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +/** + * Calculates the zoom level required to fit [bounds] within the given viewport using the + * Mercator projection formula. Returns a value clamped to [0, 21]. + * + * The zoom is computed independently for latitude and longitude, then the minimum of the two is + * taken so that the entire bounds are visible. Latitude uses the Mercator (non-linear) projection, + * longitude is linear. + * + * @param viewportWidth Viewport width in dp + * @param viewportHeight Viewport height in dp + * @param bounds Geographic bounds to fit + * @return Zoom level in [0, 21] that fits the bounds within the viewport + */ +internal fun calculateZoomFromViewport( + viewportWidth: Int, + viewportHeight: Int, + bounds: MapBounds, +): Float { + val latFraction = (latRad(bounds.northeast.latitude) - latRad(bounds.southwest.latitude)) / PI + val lngDiff = bounds.northeast.longitude - bounds.southwest.longitude + val lngFraction = if (lngDiff < 0) (lngDiff + 360.0) / 360.0 else lngDiff / 360.0 + val latZoom = + if (latFraction > 0.0) ln(viewportHeight / 256.0 / latFraction) / ln(2.0) else 21.0 + val lngZoom = if (lngFraction > 0.0) ln(viewportWidth / 256.0 / lngFraction) / ln(2.0) else 21.0 + return min(latZoom, lngZoom).toFloat().coerceIn(0f, 21f) +} + +/** + * Converts a latitude in decimal degrees to its Mercator projection radian value, clamped to + * the valid Mercator range [-π/2, π/2]. + * + * @param lat Latitude in decimal degrees + * @return Mercator radian value + */ +private fun latRad(lat: Double): Double { + val s = sin(lat * PI / 180.0) + val r = ln((1.0 + s) / (1.0 - s)) / 2.0 + return max(min(r, PI), -PI) / 2.0 +} From 4729e619609bafd5e49fc3f54ee263b155c923df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20G=C4=99siarz?= <126520293+arturgesiarz@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:34:12 +0100 Subject: [PATCH 05/11] Formatting --- .../kotlin/com/swmansion/kmpmaps/core/Utils.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt index af9009b..a132a52 100644 --- a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt @@ -7,8 +7,8 @@ import kotlin.math.min import kotlin.math.sin /** - * Calculates the zoom level required to fit [bounds] within the given viewport using the - * Mercator projection formula. Returns a value clamped to [0, 21]. + * Calculates the zoom level required to fit [bounds] within the given viewport using the Mercator + * projection formula. Returns a value clamped to [0, 21]. * * The zoom is computed independently for latitude and longitude, then the minimum of the two is * taken so that the entire bounds are visible. Latitude uses the Mercator (non-linear) projection, @@ -34,8 +34,8 @@ internal fun calculateZoomFromViewport( } /** - * Converts a latitude in decimal degrees to its Mercator projection radian value, clamped to - * the valid Mercator range [-π/2, π/2]. + * Converts a latitude in decimal degrees to its Mercator projection radian value, clamped to the + * valid Mercator range [-π/2, π/2]. * * @param lat Latitude in decimal degrees * @return Mercator radian value From 5b03e0c5a9e497e37628b566ef5c941ccd9d230c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20G=C4=99siarz?= <126520293+arturgesiarz@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:54:42 +0100 Subject: [PATCH 06/11] Refactor --- .../kotlin/com/swmansion/kmpmaps/core/Map.kt | 403 +++++++++--------- .../kmpmaps/core/MapContentLoader.kt | 21 +- .../src/jvmMain/resources/web/google_map.js | 12 +- .../kmpmaps/googlemaps/Extensions.kt | 13 +- .../swmansion/kmpmaps/sample/MapWrapper.kt | 6 - 5 files changed, 227 insertions(+), 228 deletions(-) diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt index 69ea1ac..57eaf45 100644 --- a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt @@ -2,6 +2,7 @@ package com.swmansion.kmpmaps.core import android.Manifest import android.util.Log +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -14,8 +15,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -60,19 +59,8 @@ public actual fun Map( customMarkerContent: Map Unit>, webCustomMarkerContent: Map String>, ) { - val density = LocalDensity.current - val containerSize = LocalWindowInfo.current.containerSize - - val viewportWidthDp = with(density) { containerSize.width.toDp().value.toInt() } - val viewportHeightDp = with(density) { containerSize.height.toDp().value.toInt() } - var mapLoaded by remember { mutableStateOf(false) } val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) - val cameraPositionState = rememberCameraPositionState { - cameraPosition?.let { - position = it.toGoogleMapsCameraPosition(viewportWidthDp, viewportHeightDp) - } - } LaunchedEffect(properties.isMyLocationEnabled) { if (properties.isMyLocationEnabled && !locationPermissionState.status.isGranted) { @@ -80,223 +68,240 @@ public actual fun Map( } } - LaunchedEffect(cameraPosition, mapLoaded) { - if (mapLoaded && cameraPosition != null) { - cameraPositionState.move(cameraPosition.toCameraUpdate()) - } - } + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val viewportWidthDp = maxWidth.value.toInt() + val viewportHeightDp = maxHeight.value.toInt() - GoogleMap( - mapColorScheme = properties.mapTheme.toGoogleMapsTheme(), - modifier = modifier.fillMaxSize(), - cameraPositionState = cameraPositionState, - properties = properties.toGoogleMapsProperties(locationPermissionState), - uiSettings = uiSettings.toGoogleMapsUiSettings(), - onMapClick = - onMapClick?.let { callback -> - { latLng -> callback(Coordinates(latLng.latitude, latLng.longitude)) } - }, - onMapLongClick = - onMapLongClick?.let { callback -> - { latLng -> callback(Coordinates(latLng.latitude, latLng.longitude)) } - }, - onPOIClick = - onPOIClick?.let { callback -> - { poi -> callback(Coordinates(poi.latLng.latitude, poi.latLng.longitude)) } - }, - onMapLoaded = { - mapLoaded = true - onMapLoaded?.invoke() - }, - ) { - MapEffect(properties.contentPadding) { map -> - properties.contentPadding?.run { - map.setPadding(start.toInt(), top.toInt(), end.toInt(), bottom.toInt()) + val cameraPositionState = rememberCameraPositionState { + cameraPosition?.let { + position = it.toGoogleMapsCameraPosition(viewportWidthDp, viewportHeightDp) } } - var androidGeoJsonLayers by remember { - mutableStateOf>(emptyMap()) + LaunchedEffect(cameraPosition, mapLoaded) { + if (mapLoaded && cameraPosition != null) { + cameraPositionState.move(cameraPosition.toCameraUpdate()) + } } - var geoJsonExtractedMarkers by remember { - mutableStateOf>>(emptyMap()) - } + GoogleMap( + mapColorScheme = properties.mapTheme.toGoogleMapsTheme(), + modifier = modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + properties = properties.toGoogleMapsProperties(locationPermissionState), + uiSettings = uiSettings.toGoogleMapsUiSettings(), + onMapClick = + onMapClick?.let { callback -> + { latLng -> callback(Coordinates(latLng.latitude, latLng.longitude)) } + }, + onMapLongClick = + onMapLongClick?.let { callback -> + { latLng -> callback(Coordinates(latLng.latitude, latLng.longitude)) } + }, + onPOIClick = + onPOIClick?.let { callback -> + { poi -> callback(Coordinates(poi.latLng.latitude, poi.latLng.longitude)) } + }, + onMapLoaded = { + mapLoaded = true + onMapLoaded?.invoke() + }, + ) { + MapEffect(properties.contentPadding) { map -> + properties.contentPadding?.run { + map.setPadding(start.toInt(), top.toInt(), end.toInt(), bottom.toInt()) + } + } - MapEffect(geoJsonLayers) { map -> - runCatching { - val desiredKeys = geoJsonLayers.indices.toSet() - val keysToRemove = androidGeoJsonLayers.keys - desiredKeys - keysToRemove.forEach { k -> androidGeoJsonLayers[k]?.removeLayerFromMap() } + var androidGeoJsonLayers by remember { + mutableStateOf>(emptyMap()) + } - androidGeoJsonLayers = androidGeoJsonLayers.filterKeys(desiredKeys::contains) - geoJsonExtractedMarkers = - geoJsonExtractedMarkers.filterKeys(desiredKeys::contains) + var geoJsonExtractedMarkers by remember { + mutableStateOf>>(emptyMap()) + } - geoJsonLayers.forEachIndexed { index, geo -> - if (geo.visible == false) { - androidGeoJsonLayers[index]?.removeLayerFromMap() - androidGeoJsonLayers = androidGeoJsonLayers - index - geoJsonExtractedMarkers = geoJsonExtractedMarkers - index - return@forEachIndexed - } + MapEffect(geoJsonLayers) { map -> + runCatching { + val desiredKeys = geoJsonLayers.indices.toSet() + val keysToRemove = androidGeoJsonLayers.keys - desiredKeys + keysToRemove.forEach { k -> androidGeoJsonLayers[k]?.removeLayerFromMap() } + + androidGeoJsonLayers = + androidGeoJsonLayers.filterKeys(desiredKeys::contains) + geoJsonExtractedMarkers = + geoJsonExtractedMarkers.filterKeys(desiredKeys::contains) + + geoJsonLayers.forEachIndexed { index, geo -> + if (geo.visible == false) { + androidGeoJsonLayers[index]?.removeLayerFromMap() + androidGeoJsonLayers = androidGeoJsonLayers - index + geoJsonExtractedMarkers = geoJsonExtractedMarkers - index + return@forEachIndexed + } - androidGeoJsonLayers[index]?.removeLayerFromMap() + androidGeoJsonLayers[index]?.removeLayerFromMap() - map.renderGeoJsonLayer(geo, clusterSettings, onMarkerClick)?.let { - androidGeoJsonLayers = androidGeoJsonLayers + (index to it.layer) - geoJsonExtractedMarkers = - geoJsonExtractedMarkers + (index to it.extractedMarkers) + map.renderGeoJsonLayer(geo, clusterSettings, onMarkerClick)?.let { + androidGeoJsonLayers = androidGeoJsonLayers + (index to it.layer) + geoJsonExtractedMarkers = + geoJsonExtractedMarkers + (index to it.extractedMarkers) + } } } - } - .onFailure { t -> Log.e("KMPMaps", "Failed to render GeoJSON layers", t) } - } - - DisposableEffect(Unit) { - onDispose { androidGeoJsonLayers.values.forEach(Layer::removeLayerFromMap) } - } + .onFailure { t -> Log.e("KMPMaps", "Failed to render GeoJSON layers", t) } + } - if (clusterSettings.enabled) { - val clusterItems = - remember(markers, geoJsonExtractedMarkers) { - (markers + geoJsonExtractedMarkers.values.flatten()).map(::MarkerClusterItem) - } + DisposableEffect(Unit) { + onDispose { androidGeoJsonLayers.values.forEach(Layer::removeLayerFromMap) } + } - Clustering( - items = clusterItems, - onClusterClick = { androidCluster -> - clusterSettings.onClusterClick?.invoke(androidCluster.toNativeCluster()) - ?: false - }, - onClusterItemClick = { clusterItem -> - onMarkerClick?.invoke(clusterItem.marker) - onMarkerClick == null - }, - clusterContent = { androidCluster -> - if (clusterSettings.clusterContent != null) { - clusterSettings.clusterContent.invoke(androidCluster.toNativeCluster()) - } else { - DefaultCluster(size = androidCluster.size) + if (clusterSettings.enabled) { + val clusterItems = + remember(markers, geoJsonExtractedMarkers) { + (markers + geoJsonExtractedMarkers.values.flatten()).map( + ::MarkerClusterItem + ) } - }, - clusterItemContent = { clusterItem -> - customMarkerContent[clusterItem.marker.contentId]?.invoke(clusterItem.marker) - ?: DefaultPin(clusterItem.marker) - }, - ) - } else { - markers.forEach { marker -> - key(marker.getId(), marker.contentId) { - val markerState = - remember(marker.getId()) { - MarkerState(marker.coordinates.toGoogleMapsLatLng()) + + Clustering( + items = clusterItems, + onClusterClick = { androidCluster -> + clusterSettings.onClusterClick?.invoke(androidCluster.toNativeCluster()) + ?: false + }, + onClusterItemClick = { clusterItem -> + onMarkerClick?.invoke(clusterItem.marker) + onMarkerClick == null + }, + clusterContent = { androidCluster -> + if (clusterSettings.clusterContent != null) { + clusterSettings.clusterContent.invoke(androidCluster.toNativeCluster()) + } else { + DefaultCluster(size = androidCluster.size) } + }, + clusterItemContent = { clusterItem -> + customMarkerContent[clusterItem.marker.contentId]?.invoke( + clusterItem.marker + ) ?: DefaultPin(clusterItem.marker) + }, + ) + } else { + markers.forEach { marker -> + key(marker.getId(), marker.contentId) { + val markerState = + remember(marker.getId()) { + MarkerState(marker.coordinates.toGoogleMapsLatLng()) + } - LaunchedEffect(marker.coordinates) { - val newLatLng = marker.coordinates.toGoogleMapsLatLng() - if (markerState.position != newLatLng) { - markerState.position = newLatLng + LaunchedEffect(marker.coordinates) { + val newLatLng = marker.coordinates.toGoogleMapsLatLng() + if (markerState.position != newLatLng) { + markerState.position = newLatLng + } } - } - val content = customMarkerContent[marker.contentId] + val content = customMarkerContent[marker.contentId] - if (marker.androidMarkerOptions.draggable) { - LaunchedEffect(markerState.isDragging) { - if (!markerState.isDragging) { - marker.coordinates = markerState.position.toCoordinates() - onMarkerDragEnd?.invoke(marker) + if (marker.androidMarkerOptions.draggable) { + LaunchedEffect(markerState.isDragging) { + if (!markerState.isDragging) { + marker.coordinates = markerState.position.toCoordinates() + onMarkerDragEnd?.invoke(marker) + } } } - } - if (content != null) { - MarkerComposable( - state = markerState, - title = marker.title, - anchor = marker.androidMarkerOptions.anchor.toOffset(), - draggable = marker.androidMarkerOptions.draggable, - snippet = marker.androidMarkerOptions.snippet, - zIndex = marker.androidMarkerOptions.zIndex ?: 0.0f, - onClick = { - onMarkerClick?.invoke(marker) - onMarkerClick == null - }, - content = { content(marker) }, - ) - } else { - Marker( - state = markerState, - title = marker.title, - anchor = marker.androidMarkerOptions.anchor.toOffset(), - draggable = marker.androidMarkerOptions.draggable, - snippet = marker.androidMarkerOptions.snippet, - zIndex = marker.androidMarkerOptions.zIndex ?: 0.0f, - onClick = { - onMarkerClick?.invoke(marker) - onMarkerClick == null - }, - ) + if (content != null) { + MarkerComposable( + state = markerState, + title = marker.title, + anchor = marker.androidMarkerOptions.anchor.toOffset(), + draggable = marker.androidMarkerOptions.draggable, + snippet = marker.androidMarkerOptions.snippet, + zIndex = marker.androidMarkerOptions.zIndex ?: 0.0f, + onClick = { + onMarkerClick?.invoke(marker) + onMarkerClick == null + }, + content = { content(marker) }, + ) + } else { + Marker( + state = markerState, + title = marker.title, + anchor = marker.androidMarkerOptions.anchor.toOffset(), + draggable = marker.androidMarkerOptions.draggable, + snippet = marker.androidMarkerOptions.snippet, + zIndex = marker.androidMarkerOptions.zIndex ?: 0.0f, + onClick = { + onMarkerClick?.invoke(marker) + onMarkerClick == null + }, + ) + } } } } - } - circles.forEach { circle -> - Circle( - center = circle.center.toGoogleMapsLatLng(), - radius = circle.radius.toDouble(), - strokeColor = Color(circle.lineColor?.toArgb() ?: android.graphics.Color.BLACK), - strokeWidth = circle.lineWidth ?: 10f, - fillColor = Color(circle.color?.toArgb() ?: android.graphics.Color.TRANSPARENT), - clickable = true, - onClick = { - if (onCircleClick != null) { - onCircleClick(circle) - } else { - onMapClick?.invoke(circle.center) - } - }, - ) - } + circles.forEach { circle -> + Circle( + center = circle.center.toGoogleMapsLatLng(), + radius = circle.radius.toDouble(), + strokeColor = Color(circle.lineColor?.toArgb() ?: android.graphics.Color.BLACK), + strokeWidth = circle.lineWidth ?: 10f, + fillColor = Color(circle.color?.toArgb() ?: android.graphics.Color.TRANSPARENT), + clickable = true, + onClick = { + if (onCircleClick != null) { + onCircleClick(circle) + } else { + onMapClick?.invoke(circle.center) + } + }, + ) + } - polygons.forEach { polygon -> - Polygon( - points = polygon.coordinates.map(Coordinates::toGoogleMapsLatLng), - strokeColor = Color(polygon.lineColor?.toArgb() ?: android.graphics.Color.BLACK), - strokeWidth = polygon.lineWidth, - fillColor = Color(polygon.color?.toArgb() ?: android.graphics.Color.TRANSPARENT), - clickable = true, - onClick = { - if (onPolygonClick != null) { - onPolygonClick(polygon) - } else { - onMapClick?.invoke(polygon.coordinates[0]) - } - }, - ) - } + polygons.forEach { polygon -> + Polygon( + points = polygon.coordinates.map(Coordinates::toGoogleMapsLatLng), + strokeColor = + Color(polygon.lineColor?.toArgb() ?: android.graphics.Color.BLACK), + strokeWidth = polygon.lineWidth, + fillColor = + Color(polygon.color?.toArgb() ?: android.graphics.Color.TRANSPARENT), + clickable = true, + onClick = { + if (onPolygonClick != null) { + onPolygonClick(polygon) + } else { + onMapClick?.invoke(polygon.coordinates[0]) + } + }, + ) + } - polylines.forEach { polyline -> - Polyline( - points = polyline.coordinates.map(Coordinates::toGoogleMapsLatLng), - color = Color(polyline.lineColor?.toArgb() ?: android.graphics.Color.BLACK), - width = polyline.width, - clickable = true, - onClick = { - if (onPolylineClick != null) { - onPolylineClick(polyline) - } else { - onMapClick?.invoke(polyline.coordinates[0]) - } - }, - ) - } + polylines.forEach { polyline -> + Polyline( + points = polyline.coordinates.map(Coordinates::toGoogleMapsLatLng), + color = Color(polyline.lineColor?.toArgb() ?: android.graphics.Color.BLACK), + width = polyline.width, + clickable = true, + onClick = { + if (onPolylineClick != null) { + onPolylineClick(polyline) + } else { + onMapClick?.invoke(polyline.coordinates[0]) + } + }, + ) + } - LaunchedEffect(cameraPositionState.position) { - val bounds = cameraPositionState.projection?.visibleRegion?.latLngBounds - onCameraMove?.invoke(cameraPositionState.position.toCameraPosition(bounds)) + LaunchedEffect(cameraPositionState.position) { + val bounds = cameraPositionState.projection?.visibleRegion?.latLngBounds + onCameraMove?.invoke(cameraPositionState.position.toCameraPosition(bounds)) + } } } } diff --git a/kmp-maps/core/src/jvmMain/kotlin/com/swmansion/kmpmaps/core/MapContentLoader.kt b/kmp-maps/core/src/jvmMain/kotlin/com/swmansion/kmpmaps/core/MapContentLoader.kt index dd04170..e4ba4ee 100644 --- a/kmp-maps/core/src/jvmMain/kotlin/com/swmansion/kmpmaps/core/MapContentLoader.kt +++ b/kmp-maps/core/src/jvmMain/kotlin/com/swmansion/kmpmaps/core/MapContentLoader.kt @@ -20,25 +20,22 @@ internal fun loadHTMLContent( ): String { val html = readResource("web/google_map.html") val bounds = cameraPosition?.bounds - val initialLat = + val fitBoundsCall = if (bounds != null) { - (bounds.northeast.latitude + bounds.southwest.latitude) / 2.0 + "map.fitBounds(new google.maps.LatLngBounds(" + + "{lat: ${bounds.southwest.latitude}, lng: ${bounds.southwest.longitude}}, " + + "{lat: ${bounds.northeast.latitude}, lng: ${bounds.northeast.longitude}}));" } else { - cameraPosition?.coordinates?.latitude - } - val initialLng = - if (bounds != null) { - (bounds.northeast.longitude + bounds.southwest.longitude) / 2.0 - } else { - cameraPosition?.coordinates?.longitude + "" } val js = readResource("web/google_map.js") .replace("{{INITIAL_MAP_ID}}", properties?.webMapProperties?.mapId ?: "DEMO_MAP_ID") .replace("{{INITIAL_COLOR_SCHEME}}", properties?.mapTheme?.name ?: MapTheme.SYSTEM.name) - .replace("{{INITIAL_LAT}}", initialLat.toString()) - .replace("{{INITIAL_LNG}}", initialLng.toString()) - .replace("{{INITIAL_ZOOM}}", cameraPosition?.zoom.toString()) + .replace("{{INITIAL_LAT}}", (cameraPosition?.coordinates?.latitude ?: 0f).toString()) + .replace("{{INITIAL_LNG}}", (cameraPosition?.coordinates?.longitude ?: 0f).toString()) + .replace("{{INITIAL_ZOOM}}", (cameraPosition?.zoom ?: 0f).toString()) + .replace("{{FIT_BOUNDS_CALL}}", fitBoundsCall) return html.replace("{{API_KEY}}", apiKey).replace("{{LOCAL_JS_CONTENT}}", js) } diff --git a/kmp-maps/core/src/jvmMain/resources/web/google_map.js b/kmp-maps/core/src/jvmMain/resources/web/google_map.js index 211b32d..7af7e46 100644 --- a/kmp-maps/core/src/jvmMain/resources/web/google_map.js +++ b/kmp-maps/core/src/jvmMain/resources/web/google_map.js @@ -42,6 +42,8 @@ async function initMap() { disableDefaultUI: true, }); + {{FIT_BOUNDS_CALL}} + if (window.markerClusterer) { markerCluster = new markerClusterer.MarkerClusterer({ map, markers: [] }); } @@ -67,22 +69,14 @@ async function initMap() { map.addListener("idle", () => { const center = map.getCenter(); const zoom = map.getZoom(); - const mapBounds = map.getBounds(); if (center) { - const ne = mapBounds ? mapBounds.getNorthEast() : null; - const sw = mapBounds ? mapBounds.getSouthWest() : null; - const cameraState = { coordinates: { latitude: center.lat(), longitude: center.lng(), }, - zoom, - bounds: (ne && sw) ? { - northeast: { latitude: ne.lat(), longitude: ne.lng() }, - southwest: { latitude: sw.lat(), longitude: sw.lng() } - } : null + zoom }; if (window.kmpJsBridge) { diff --git a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt index 436f695..07938c4 100644 --- a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt +++ b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt @@ -317,9 +317,18 @@ public fun UtilsGMSMapView.setUpGMSCameraPosition(position: CameraPosition) { internal fun UtilsGMSMapView.getVisibleMapBounds(): MapBounds = projection().let { proj -> proj.visibleRegion().useContents { + val lats = + listOf(nearLeft.latitude, nearRight.latitude, farLeft.latitude, farRight.latitude) + val lngs = + listOf( + nearLeft.longitude, + nearRight.longitude, + farLeft.longitude, + farRight.longitude, + ) MapBounds( - northeast = Coordinates(farRight.latitude, farRight.longitude), - southwest = Coordinates(nearLeft.latitude, nearLeft.longitude), + northeast = Coordinates(lats.max(), lngs.max()), + southwest = Coordinates(lats.min(), lngs.min()), ) } } diff --git a/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapWrapper.kt b/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapWrapper.kt index ae21189..bec8c14 100644 --- a/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapWrapper.kt +++ b/sample/src/commonMain/kotlin/com/swmansion/kmpmaps/sample/MapWrapper.kt @@ -10,7 +10,6 @@ import com.swmansion.kmpmaps.core.ClusterSettings import com.swmansion.kmpmaps.core.Coordinates import com.swmansion.kmpmaps.core.GeoJsonLayer import com.swmansion.kmpmaps.core.Map as CoreMap -import com.swmansion.kmpmaps.core.MapBounds import com.swmansion.kmpmaps.core.MapProperties import com.swmansion.kmpmaps.core.MapTheme import com.swmansion.kmpmaps.core.MapType @@ -29,11 +28,6 @@ internal data class MapOptions( CameraPosition( coordinates = Coordinates(latitude = 50.0619, longitude = 19.9373), zoom = 14f, - bounds = - MapBounds( - northeast = Coordinates(latitude = 40.9176, longitude = -73.7004), - southwest = Coordinates(latitude = 40.4774, longitude = -74.2591), - ), ), val showAllComponents: Boolean = true, val useGoogleMapsMapView: Boolean = true, From a9adc92c4e799517928b79e39e2266a5f19d1ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20G=C4=99siarz?= <126520293+arturgesiarz@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:56:13 +0100 Subject: [PATCH 07/11] Refactor --- .../com/swmansion/kmpmaps/core/Extensions.kt | 27 +++++++++++-------- .../kotlin/com/swmansion/kmpmaps/core/Map.kt | 8 +++--- .../com/swmansion/kmpmaps/core/Utils.kt | 13 ++++----- .../kmpmaps/googlemaps/Extensions.kt | 3 ++- 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt index 990c789..86f901a 100644 --- a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt @@ -39,13 +39,18 @@ import org.json.JSONObject /** * Converts [MapBounds] to Google Maps [LatLngBounds]. * - * @return [LatLngBounds] built from northeast and southwest corners + * Uses the direct [LatLngBounds] constructor rather than [LatLngBounds.Builder] to preserve + * antimeridian-crossing semantics: when + * [MapBounds.southwest].longitude > [MapBounds.northeast].longitude the bounds cross the + * antimeridian, and the Builder would silently widen them to go the other way round instead. + * + * @return [LatLngBounds] with the original southwest and northeast corners intact */ internal fun MapBounds.toLatLngBounds() = - LatLngBounds.Builder() - .include(LatLng(northeast.latitude, northeast.longitude)) - .include(LatLng(southwest.latitude, southwest.longitude)) - .build() + LatLngBounds( + LatLng(southwest.latitude, southwest.longitude), + LatLng(northeast.latitude, northeast.longitude), + ) /** * Converts [CameraPosition] to [GoogleCameraPosition]. @@ -53,19 +58,19 @@ internal fun MapBounds.toLatLngBounds() = * When [CameraPosition.bounds] is set and viewport dimensions are provided, computes the zoom level * to fit the bounds using the Mercator projection formula. Otherwise, uses [CameraPosition.zoom]. * - * @param viewportWidth Map viewport width in pixels (used to compute zoom for bounds) - * @param viewportHeight Map viewport height in pixels (used to compute zoom for bounds) + * @param viewportWidthPx Map viewport width in pixels (used to compute zoom for bounds) + * @param viewportHeightPx Map viewport height in pixels (used to compute zoom for bounds) * @return [GoogleCameraPosition] with coordinates, zoom, bearing, and tilt */ internal fun CameraPosition.toGoogleMapsCameraPosition( - viewportWidth: Int = 0, - viewportHeight: Int = 0, + viewportWidthPx: Int = 0, + viewportHeightPx: Int = 0, ): GoogleCameraPosition { val target = bounds?.toLatLngBounds()?.center ?: LatLng(coordinates.latitude, coordinates.longitude) val computedZoom = - if (bounds != null && viewportWidth > 0 && viewportHeight > 0) { - calculateZoomFromViewport(viewportWidth, viewportHeight, bounds) + if (bounds != null && viewportWidthPx > 0 && viewportHeightPx > 0) { + calculateZoomFromViewport(viewportWidthPx, viewportHeightPx, bounds) } else { zoom } diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt index 57eaf45..9eafc90 100644 --- a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -69,12 +70,13 @@ public actual fun Map( } BoxWithConstraints(modifier = modifier.fillMaxSize()) { - val viewportWidthDp = maxWidth.value.toInt() - val viewportHeightDp = maxHeight.value.toInt() + val density = LocalDensity.current + val viewportWidthPx = with(density) { maxWidth.roundToPx() } + val viewportHeightPx = with(density) { maxHeight.roundToPx() } val cameraPositionState = rememberCameraPositionState { cameraPosition?.let { - position = it.toGoogleMapsCameraPosition(viewportWidthDp, viewportHeightDp) + position = it.toGoogleMapsCameraPosition(viewportWidthPx, viewportHeightPx) } } diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt index a132a52..fdda0d9 100644 --- a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt @@ -14,22 +14,23 @@ import kotlin.math.sin * taken so that the entire bounds are visible. Latitude uses the Mercator (non-linear) projection, * longitude is linear. * - * @param viewportWidth Viewport width in dp - * @param viewportHeight Viewport height in dp + * @param viewportWidthPx Viewport width in pixels + * @param viewportHeightPx Viewport height in pixels * @param bounds Geographic bounds to fit * @return Zoom level in [0, 21] that fits the bounds within the viewport */ internal fun calculateZoomFromViewport( - viewportWidth: Int, - viewportHeight: Int, + viewportWidthPx: Int, + viewportHeightPx: Int, bounds: MapBounds, ): Float { val latFraction = (latRad(bounds.northeast.latitude) - latRad(bounds.southwest.latitude)) / PI val lngDiff = bounds.northeast.longitude - bounds.southwest.longitude val lngFraction = if (lngDiff < 0) (lngDiff + 360.0) / 360.0 else lngDiff / 360.0 val latZoom = - if (latFraction > 0.0) ln(viewportHeight / 256.0 / latFraction) / ln(2.0) else 21.0 - val lngZoom = if (lngFraction > 0.0) ln(viewportWidth / 256.0 / lngFraction) / ln(2.0) else 21.0 + if (latFraction > 0.0) ln(viewportHeightPx / 256.0 / latFraction) / ln(2.0) else 21.0 + val lngZoom = + if (lngFraction > 0.0) ln(viewportWidthPx / 256.0 / lngFraction) / ln(2.0) else 21.0 return min(latZoom, lngZoom).toFloat().coerceIn(0f, 21f) } diff --git a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt index 07938c4..7a98a9b 100644 --- a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt +++ b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt @@ -286,7 +286,8 @@ public fun UtilsGMSMapView.setUpGMSCameraPosition(position: CameraPosition) { bounds.southwest.latitude, bounds.southwest.longitude, ), - ) + ), + withPadding = 0.0, ) ) } else { From bb091337ff1c9353c84fcacf50c69ebb9f28c60d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20G=C4=99siarz?= <126520293+arturgesiarz@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:03:39 +0100 Subject: [PATCH 08/11] Docs update --- .../kotlin/com/swmansion/kmpmaps/core/Extensions.kt | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt index 86f901a..c362239 100644 --- a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt @@ -39,11 +39,6 @@ import org.json.JSONObject /** * Converts [MapBounds] to Google Maps [LatLngBounds]. * - * Uses the direct [LatLngBounds] constructor rather than [LatLngBounds.Builder] to preserve - * antimeridian-crossing semantics: when - * [MapBounds.southwest].longitude > [MapBounds.northeast].longitude the bounds cross the - * antimeridian, and the Builder would silently widen them to go the other way round instead. - * * @return [LatLngBounds] with the original southwest and northeast corners intact */ internal fun MapBounds.toLatLngBounds() = @@ -83,12 +78,9 @@ internal fun CameraPosition.toGoogleMapsCameraPosition( } /** - * Returns a CameraUpdate for this position. - * - * When [CameraPosition.bounds] is set, uses [CameraUpdateFactory.newLatLngBounds] so the camera - * zooms to fit the entire region. Otherwise, falls back to [CameraUpdateFactory.newCameraPosition]. + * Fits bounds if available; otherwise uses coordinates and zoom. * - * @param padding Padding in pixels around the bounds (only used when bounds is set) + * @param padding Bounds padding (px). */ internal fun CameraPosition.toCameraUpdate(padding: Int = 0) = if (bounds != null) { From 9393e44b47659f5440b1339d4f8d19657f6aaf24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20G=C4=99siarz?= <126520293+arturgesiarz@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:07:35 +0100 Subject: [PATCH 09/11] Refactor --- .../kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt index 7a98a9b..099ed44 100644 --- a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt +++ b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt @@ -328,8 +328,8 @@ internal fun UtilsGMSMapView.getVisibleMapBounds(): MapBounds = farRight.longitude, ) MapBounds( - northeast = Coordinates(lats.max(), lngs.max()), - southwest = Coordinates(lats.min(), lngs.min()), + northeast = Coordinates(lats.maxOrNull()!!, lngs.maxOrNull()!!), + southwest = Coordinates(lats.minOrNull()!!, lngs.minOrNull()!!), ) } } From cb5bb901ad2406758ee840d9d032c1d4fcc2cd12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20G=C4=99siarz?= <126520293+arturgesiarz@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:43:00 +0100 Subject: [PATCH 10/11] Improvment --- .../com/swmansion/kmpmaps/core/Extensions.kt | 39 +++++++++++++------ .../kmpmaps/googlemaps/Extensions.kt | 34 +++++++++------- 2 files changed, 47 insertions(+), 26 deletions(-) diff --git a/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt b/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt index b11e9d1..837327e 100644 --- a/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt +++ b/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt @@ -134,15 +134,7 @@ import platform.posix.memcpy */ @OptIn(ExperimentalForeignApi::class) internal fun CameraPosition.toMKCoordinateRegion(): CValue { - if (bounds != null) { - val centerLat = (bounds.northeast.latitude + bounds.southwest.latitude) / 2.0 - val centerLng = (bounds.northeast.longitude + bounds.southwest.longitude) / 2.0 - val latDelta = kotlin.math.abs(bounds.northeast.latitude - bounds.southwest.latitude) - val lngDelta = kotlin.math.abs(bounds.northeast.longitude - bounds.southwest.longitude) - val coordinate = CLLocationCoordinate2DMake(centerLat, centerLng) - val span = MKCoordinateSpanMake(latDelta, lngDelta) - return MKCoordinateRegionMake(coordinate, span) - } + if (bounds != null) return bounds.toMKCoordinateRegion() val coordinate = CLLocationCoordinate2DMake(coordinates.latitude, coordinates.longitude) val span = MKCoordinateSpanMake( @@ -152,6 +144,26 @@ internal fun CameraPosition.toMKCoordinateRegion(): CValue { return MKCoordinateRegionMake(coordinate, span) } +/** + * Converts [MapBounds] to [MKCoordinateRegion] + */ +@OptIn(ExperimentalForeignApi::class) +private fun MapBounds.toMKCoordinateRegion(): CValue { + val centerLat = (northeast.latitude + southwest.latitude) / 2.0 + val rawDiff = northeast.longitude - southwest.longitude + val lngDelta = + when { + rawDiff > 180.0 -> rawDiff - 360.0 + rawDiff < -180.0 -> rawDiff + 360.0 + else -> rawDiff + } + val centerLng = wrapLng(southwest.longitude + lngDelta / 2.0) + val latDelta = kotlin.math.abs(northeast.latitude - southwest.latitude) + val coordinate = CLLocationCoordinate2DMake(centerLat, centerLng) + val span = MKCoordinateSpanMake(latDelta, kotlin.math.abs(lngDelta)) + return MKCoordinateRegionMake(coordinate, span) +} + /** * Converts Apple MapKit's MKCoordinateRegion back to CameraPosition. * @@ -165,10 +177,12 @@ internal fun CValue.toCameraPosition() = useContents { val halfLat = span.latitudeDelta / 2.0 val halfLng = span.longitudeDelta / 2.0 + val neLng = wrapLng(center.longitude + halfLng) + val swLng = wrapLng(center.longitude - halfLng) val bounds = MapBounds( - northeast = Coordinates(center.latitude + halfLat, center.longitude + halfLng), - southwest = Coordinates(center.latitude - halfLat, center.longitude - halfLng), + northeast = Coordinates(center.latitude + halfLat, neLng), + southwest = Coordinates(center.latitude - halfLat, swLng), ) CameraPosition( @@ -178,6 +192,9 @@ internal fun CValue.toCameraPosition() = useContents { ) } +/** Normalizes [lng] to the range [-180, 180]. */ +private fun wrapLng(lng: Double): Double = ((lng + 180.0) % 360.0 + 360.0) % 360.0 - 180.0 + /** * Updates Apple Maps markers by removing existing annotations and adding new ones. * diff --git a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt index 099ed44..2016da3 100644 --- a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt +++ b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt @@ -309,8 +309,9 @@ public fun UtilsGMSMapView.setUpGMSCameraPosition(position: CameraPosition) { /** * Returns the current visible geographic bounds of the map view. * - * Uses the map's projection to read the visible region, mapping [farRight] to the northeast corner - * and [nearLeft] to the southwest corner of the returned [MapBounds]. + * Uses [GMSCoordinateBounds] built from all four corners of the visible region so that + * antimeridian-crossing regions are handled correctly by the SDK rather than by naive longitude + * min/max arithmetic. * * @return The current visible geographic bounds of the map. */ @@ -318,19 +319,22 @@ public fun UtilsGMSMapView.setUpGMSCameraPosition(position: CameraPosition) { internal fun UtilsGMSMapView.getVisibleMapBounds(): MapBounds = projection().let { proj -> proj.visibleRegion().useContents { - val lats = - listOf(nearLeft.latitude, nearRight.latitude, farLeft.latitude, farRight.latitude) - val lngs = - listOf( - nearLeft.longitude, - nearRight.longitude, - farLeft.longitude, - farRight.longitude, - ) - MapBounds( - northeast = Coordinates(lats.maxOrNull()!!, lngs.maxOrNull()!!), - southwest = Coordinates(lats.minOrNull()!!, lngs.minOrNull()!!), - ) + val gmsBounds = + GMSCoordinateBounds( + CLLocationCoordinate2DMake(nearLeft.latitude, nearLeft.longitude), + CLLocationCoordinate2DMake(nearRight.latitude, nearRight.longitude), + ) + .includingCoordinate( + CLLocationCoordinate2DMake(farLeft.latitude, farLeft.longitude) + ) + .includingCoordinate( + CLLocationCoordinate2DMake(farRight.latitude, farRight.longitude) + ) + val neLat = gmsBounds.northEast().useContents { latitude } + val neLon = gmsBounds.northEast().useContents { longitude } + val swLat = gmsBounds.southWest().useContents { latitude } + val swLon = gmsBounds.southWest().useContents { longitude } + MapBounds(northeast = Coordinates(neLat, neLon), southwest = Coordinates(swLat, swLon)) } } From cf684e6c5d4a670f7b21a5aa02dabdb0eb0cd885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20G=C4=99siarz?= <126520293+arturgesiarz@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:44:21 +0100 Subject: [PATCH 11/11] Formatting --- .../iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt b/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt index 837327e..d89239f 100644 --- a/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt +++ b/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt @@ -144,9 +144,7 @@ internal fun CameraPosition.toMKCoordinateRegion(): CValue { return MKCoordinateRegionMake(coordinate, span) } -/** - * Converts [MapBounds] to [MKCoordinateRegion] - */ +/** Converts [MapBounds] to [MKCoordinateRegion] */ @OptIn(ExperimentalForeignApi::class) private fun MapBounds.toMKCoordinateRegion(): CValue { val centerLat = (northeast.latitude + southwest.latitude) / 2.0