From c9b2c1f452b731822f9ef7efd2fdce0e3d91f1dc Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:37:04 -0700 Subject: [PATCH] refactor(widgets): use android-maps-utils Units API in ScaleBar Adopt the Units API from android-maps-utils (MetricUnitsConverter, ImperialUnitsConverter with a 1-mile threshold, NumberFormatSymbols.COMPACT, and toDistanceString) for formatting metric and imperial distances in ScaleBar. Also fix a pre-existing accuracy bug in ScaleBar where the ground distance was previously calculated between Point(0, 0) and Point(widthInPixels, 0) at the top-left corner of the viewport and only recomputed when cameraPositionState.position.zoom changed: - Because Google Maps uses a conformal Web Mercator projection (EPSG:3857), map scale stretches isotropically in both latitude (North-South) and longitude (East-West) by a factor of 1 / cos(latitude) as you move toward the poles. Measuring at the top corner of the screen rather than the center of the map caused the scale bar to reflect the scale at the top edge of the viewport instead of where the camera is focused (for example, when centered on Iceland at zoom 3.15, the top edge of the screen lies in northern Greenland near 82N, causing the top-corner calculation to report 140 km / 87 mi instead of the 429 km / 267 mi width at Iceland). - Measure the scale across the camera target (cameraPositionState.position.target) at the center of the viewport by default, and observe cameraPositionState.position, cameraPositionState.isMoving, and LocalConfiguration.current so the scale bar stays accurate across latitude panning, map bearing rotation, camera tilt, and device orientation changes. - Add a measureAtCenter: Boolean = true parameter to ScaleBar and DisappearingScaleBar so callers can preserve the legacy top-corner behavior (measureAtCenter = false) if desired. - Update ScaleBarActivity with three sets of East-West and North-South reference crosshairs (at 10%, 50%, and 90% of the viewport height) whose ground lengths match the ScaleBar distance, plus UI checkboxes to toggle 'Measure at center' and 'Show crosshairs'. - Add unit and instrumentation tests covering Units API formatting, latitude changes at constant zoom, map bearing rotation, device rotation, and legacy top-corner measurement. --- .../maps/android/compose/ScaleBarTests.kt | 259 +++++++++++++++--- maps-app/src/main/AndroidManifest.xml | 4 +- .../maps/android/compose/ScaleBarActivity.kt | 160 ++++++++++- .../android/compose/ScaleBarRenderingTests.kt | 5 +- .../api/maps-compose-widgets.api | 4 +- .../maps/android/compose/ScaleBarUnitTest.kt | 54 ++++ .../maps/android/compose/widgets/ScaleBar.kt | 134 +++++---- 7 files changed, 522 insertions(+), 98 deletions(-) diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarTests.kt index 3be093128..8db8b2e3a 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarTests.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarTests.kt @@ -14,42 +14,59 @@ package com.google.maps.android.compose +import android.content.pm.ActivityInfo import android.graphics.Point +import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.ImperialUnitsConverter +import com.google.maps.android.MetricUnitsConverter +import com.google.maps.android.NumberFormatSymbols import com.google.maps.android.compose.widgets.ScaleBar +import com.google.maps.android.meters +import com.google.maps.android.miles import com.google.maps.android.sphericalDistance +import com.google.maps.android.toDistanceString +import org.junit.After import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Rule import org.junit.Test import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -import kotlin.math.roundToInt +import kotlin.math.abs -// These constants are used for converting between metric and imperial units -// to ensure the scale bar displays distances correctly in both systems. -private const val CENTIMETERS_IN_METER: Double = 100.0 -private const val METERS_IN_KILOMETER: Double = 1000.0 -private const val CENTIMETERS_IN_INCH: Double = 2.54 -private const val INCHES_IN_FOOT: Double = 12.0 -private const val FEET_IN_MILE: Double = 5280.0 +private val scaleBarImperialConverter = ImperialUnitsConverter(milesThreshold = 1.miles) class ScaleBarTests { @get:Rule - val composeTestRule = createComposeRule() + val composeTestRule = createAndroidComposeRule() private lateinit var cameraPositionState: CameraPositionState private lateinit var density: Density - private fun initScaleBar(initialZoom: Float, initialPosition: LatLng) { + @After + fun tearDown() { + runCatching { + composeTestRule.activityRule.scenario.onActivity { activity -> + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + } + + private fun initScaleBar(initialZoom: Float, initialPosition: LatLng, measureAtCenter: Boolean = true) { assumeValidApiKey() val countDownLatch = CountDownLatch(1) @@ -67,7 +84,10 @@ class ScaleBarTests { countDownLatch.countDown() }, ) - ScaleBar(cameraPositionState = cameraPositionState) + ScaleBar( + cameraPositionState = cameraPositionState, + measureAtCenter = measureAtCenter, + ) } } val mapLoaded = countDownLatch.await(MAP_LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS) @@ -89,19 +109,164 @@ class ScaleBarTests { assertScaleBarLabels(initialZoom = 10f) } - private fun assertScaleBarLabels(initialZoom: Float) { - val initialPosition = LatLng(37.7749, -122.4194) // San Francisco - initScaleBar(initialZoom, initialPosition) + @Test + fun testScaleBarUpdatesWhenLatitudeChangesAtSameZoom() { + val equator = LatLng(0.0, 0.0) + val iceland = LatLng(64.9631, -19.0208) + initScaleBar(initialZoom = 10f, initialPosition = equator) + composeTestRule.waitForIdle() + + val (_, equatorMetric, equatorMeters) = computeExpectedLabels() + composeTestRule.onNodeWithText(equatorMetric).assertExists() + + composeTestRule.runOnIdle { + cameraPositionState.move(CameraUpdateFactory.newLatLngZoom(iceland, 10f)) + } + composeTestRule.waitUntil(timeoutMillis = 5_000) { + !cameraPositionState.isMoving && + abs(cameraPositionState.position.target.latitude - iceland.latitude) < 0.1 + } + composeTestRule.waitForIdle() + + val (icelandImperial, icelandMetric, icelandMeters) = computeExpectedLabels() + assertThat(icelandMeters).isLessThan(equatorMeters / 2) + composeTestRule.onNodeWithText(icelandImperial).assertExists() + composeTestRule.onNodeWithText(icelandMetric).assertExists() + } + + @Test + fun testScaleBarAcrossDeviceRotation() { + val iceland = LatLng(64.9631, -19.0208) + composeTestRule.activityRule.scenario.onActivity { activity -> + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + + initScaleBar(initialZoom = 5f, initialPosition = iceland) + composeTestRule.waitForIdle() + + val (portraitImperial, portraitMetric, portraitMeters) = computeExpectedLabels() + composeTestRule.onNodeWithText(portraitImperial).assertExists() + composeTestRule.onNodeWithText(portraitMetric).assertExists() + + // Rotate the device to landscape + composeTestRule.activityRule.scenario.onActivity { activity -> + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + composeTestRule.waitForIdle() + + val (landscapeImperial, landscapeMetric, landscapeMeters) = computeExpectedLabels() + assertThat(landscapeImperial).isEqualTo(portraitImperial) + assertThat(landscapeMetric).isEqualTo(portraitMetric) + assertThat(abs(landscapeMeters - portraitMeters)).isAtMost(5) + composeTestRule.onNodeWithText(landscapeImperial).assertExists() + composeTestRule.onNodeWithText(landscapeMetric).assertExists() + + // Rotate the device back to portrait + composeTestRule.activityRule.scenario.onActivity { activity -> + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + composeTestRule.waitForIdle() + + val (restoredImperial, restoredMetric, restoredMeters) = computeExpectedLabels() + assertThat(restoredImperial).isEqualTo(portraitImperial) + assertThat(restoredMetric).isEqualTo(portraitMetric) + assertThat(abs(restoredMeters - portraitMeters)).isAtMost(5) + composeTestRule.onNodeWithText(restoredImperial).assertExists() + composeTestRule.onNodeWithText(restoredMetric).assertExists() + } + + @Test + fun testScaleBarAcrossMapBearingRotation() { + val iceland = LatLng(64.9631, -19.0208) + initScaleBar(initialZoom = 10f, initialPosition = iceland) + composeTestRule.waitForIdle() + + val (northUpImperial, northUpMetric, northUpMeters) = computeExpectedLabels() + composeTestRule.onNodeWithText(northUpImperial).assertExists() + composeTestRule.onNodeWithText(northUpMetric).assertExists() + + for (bearing in listOf(45f, 90f, 180f, 270f)) { + composeTestRule.runOnIdle { + cameraPositionState.move( + CameraUpdateFactory.newCameraPosition( + CameraPosition.Builder() + .target(iceland) + .zoom(10f) + .bearing(bearing) + .build(), + ), + ) + } + composeTestRule.waitUntil(timeoutMillis = 5_000) { + !cameraPositionState.isMoving && + abs(cameraPositionState.position.bearing - bearing) < 1f + } + composeTestRule.waitForIdle() + + val (rotatedImperial, rotatedMetric, rotatedMeters) = computeExpectedLabels() + assertThat(rotatedImperial).isEqualTo(northUpImperial) + assertThat(rotatedMetric).isEqualTo(northUpMetric) + assertThat(abs(rotatedMeters - northUpMeters)).isAtMost(10) + composeTestRule.onNodeWithText(rotatedImperial).assertExists() + composeTestRule.onNodeWithText(rotatedMetric).assertExists() + } + } + @Test + fun testScaleBarActivitySurvivesDeviceRotation() { + assumeValidApiKey() + ActivityScenario.launch(ScaleBarActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + composeTestRule.waitUntil(timeoutMillis = MAP_LOAD_TIMEOUT_SECONDS * 1_000) { + composeTestRule.onAllNodesWithText("4.4 km").fetchSemanticsNodes().isNotEmpty() && + composeTestRule.onAllNodesWithText("2.7 mi").fetchSemanticsNodes().isNotEmpty() + } + + scenario.onActivity { activity -> + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + composeTestRule.waitUntil(timeoutMillis = MAP_LOAD_TIMEOUT_SECONDS * 1_000) { + composeTestRule.onAllNodesWithText("4.4 km").fetchSemanticsNodes().isNotEmpty() && + composeTestRule.onAllNodesWithText("2.7 mi").fetchSemanticsNodes().isNotEmpty() + } + + scenario.onActivity { activity -> + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + composeTestRule.waitUntil(timeoutMillis = MAP_LOAD_TIMEOUT_SECONDS * 1_000) { + composeTestRule.onAllNodesWithText("4.4 km").fetchSemanticsNodes().isNotEmpty() && + composeTestRule.onAllNodesWithText("2.7 mi").fetchSemanticsNodes().isNotEmpty() + } + } + } + + @Test + fun testScaleBarLegacyTopLeftMeasurementWhenMeasureAtCenterIsFalse() { + val iceland = LatLng(64.9631, -19.0208) + initScaleBar(initialZoom = 5f, initialPosition = iceland, measureAtCenter = false) composeTestRule.waitForIdle() + val (legacyImperial, legacyMetric, legacyMeters) = computeExpectedLabels(measureAtCenter = false) + val (_, _, centerMeters) = computeExpectedLabels(measureAtCenter = true) + assertThat(legacyMeters).isLessThan(centerMeters) + composeTestRule.onNodeWithText(legacyImperial).assertExists() + composeTestRule.onNodeWithText(legacyMetric).assertExists() + } + + private fun computeExpectedLabels(measureAtCenter: Boolean = true): Triple { var imperialText = "" var metricText = "" + var meters = 0 composeTestRule.runOnIdle { - // We use a `let` block to safely handle the projection, which can be null. - // If the projection is null, the test will fail explicitly, preventing - // any potential NullPointerExceptions and ensuring the test is robust. val projection = cameraPositionState.projection projection?.let { proj -> val widthInDp = 65.dp @@ -109,25 +274,45 @@ class ScaleBarTests { widthInDp.toPx().toInt() } - val upperLeftLatLng = proj.fromScreenLocation(Point(0, 0)) - val upperRightLatLng = proj.fromScreenLocation(Point(widthInPixels, 0)) - val canvasWidthMeters = upperLeftLatLng.sphericalDistance(upperRightLatLng) - val horizontalLineWidthMeters = (canvasWidthMeters * 8 / 9).toInt() - - metricText = if (horizontalLineWidthMeters >= METERS_IN_KILOMETER) { - "${formatTestDistance(horizontalLineWidthMeters / METERS_IN_KILOMETER)} km" - } else { - "$horizontalLineWidthMeters m" - } - val feet = horizontalLineWidthMeters.toDouble().toFeet() - imperialText = if (feet >= FEET_IN_MILE) { - "${formatTestDistance(feet.toMiles())} mi" + val startPoint: Point + val endPoint: Point + if (measureAtCenter) { + val centerPoint = proj.toScreenLocation(cameraPositionState.position.target) + val halfWidthInPixels = widthInPixels / 2 + startPoint = Point(centerPoint.x - halfWidthInPixels, centerPoint.y) + endPoint = Point(startPoint.x + widthInPixels, centerPoint.y) } else { - "${feet.roundToInt()} ft" + startPoint = Point(0, 0) + endPoint = Point(widthInPixels, 0) } + val startLatLng = proj.fromScreenLocation(startPoint) + val endLatLng = proj.fromScreenLocation(endPoint) + val canvasWidthMeters = startLatLng.sphericalDistance(endLatLng) + val horizontalLineWidthMeters = (canvasWidthMeters * 8 / 9).toInt() + meters = horizontalLineWidthMeters + + metricText = horizontalLineWidthMeters.meters.toDistanceString( + MetricUnitsConverter, + NumberFormatSymbols.COMPACT, + ) + imperialText = horizontalLineWidthMeters.meters.toDistanceString( + scaleBarImperialConverter, + NumberFormatSymbols.COMPACT, + ) } ?: fail("Projection should not be null") } + return Triple(imperialText, metricText, meters) + } + + private fun assertScaleBarLabels(initialZoom: Float) { + val initialPosition = LatLng(37.7749, -122.4194) // San Francisco + initScaleBar(initialZoom, initialPosition) + + composeTestRule.waitForIdle() + + val (imperialText, metricText, _) = computeExpectedLabels() + composeTestRule.onNodeWithText( text = imperialText, ).assertExists() @@ -136,13 +321,3 @@ class ScaleBarTests { ).assertExists() } } - -internal fun Double.toFeet(): Double = this * CENTIMETERS_IN_METER / CENTIMETERS_IN_INCH / INCHES_IN_FOOT - -internal fun Double.toMiles(): Double = this / FEET_IN_MILE - -private fun formatTestDistance(distance: Double): String { - if (distance >= 10.0) return distance.roundToInt().toString() - val scaled = (distance * 10.0).roundToInt() - return if (scaled % 10 == 0) "${scaled / 10}" else "${scaled / 10}.${scaled % 10}" -} diff --git a/maps-app/src/main/AndroidManifest.xml b/maps-app/src/main/AndroidManifest.xml index d95e8c0ae..a6163b7dd 100644 --- a/maps-app/src/main/AndroidManifest.xml +++ b/maps-app/src/main/AndroidManifest.xml @@ -120,7 +120,9 @@ android:exported="true" /> - + \ No newline at end of file diff --git a/maps-app/src/main/java/com/google/maps/android/compose/ScaleBarActivity.kt b/maps-app/src/main/java/com/google/maps/android/compose/ScaleBarActivity.kt index 5ed9a8a4e..d34f7d1e6 100644 --- a/maps-app/src/main/java/com/google/maps/android/compose/ScaleBarActivity.kt +++ b/maps-app/src/main/java/com/google/maps/android/compose/ScaleBarActivity.kt @@ -14,6 +14,7 @@ package com.google.maps.android.compose +import android.graphics.Point import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -24,27 +25,40 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.Checkbox import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng -import com.google.maps.android.compose.theme.MapsComposeSampleTheme import com.google.maps.android.compose.widgets.DarkGray import com.google.maps.android.compose.widgets.DisappearingScaleBar import com.google.maps.android.compose.widgets.ScaleBar +import com.google.maps.android.sphericalDistance +import com.google.maps.android.withSphericalOffset + +private val scaleBarWidth = 65.dp class ScaleBarActivity : ComponentActivity() { @@ -53,13 +67,26 @@ class ScaleBarActivity : ComponentActivity() { enableEdgeToEdge() setContent { var isMapLoaded by remember { mutableStateOf(false) } + var measureAtCenter by rememberSaveable { mutableStateOf(true) } + var showCrosshairs by rememberSaveable { mutableStateOf(true) } // To control and observe the map camera val cameraPositionState = rememberCameraPositionState { - position = defaultCameraPosition + position = if (intent.hasExtra("lat") && intent.hasExtra("lng")) { + CameraPosition.fromLatLngZoom( + LatLng( + intent.getFloatExtra("lat", defaultCameraPosition.target.latitude.toFloat()).toDouble(), + intent.getFloatExtra("lng", defaultCameraPosition.target.longitude.toFloat()).toDouble(), + ), + intent.getFloatExtra("zoom", defaultCameraPosition.zoom), + ) + } else { + defaultCameraPosition + } } val scaleBackground = MaterialTheme.colors.background.copy(alpha = 0.4f) + val controlsBackground = MaterialTheme.colors.background.copy(alpha = 0.7f) val scaleBorderStroke = BorderStroke(width = 1.dp, DarkGray.copy(alpha = 0.2f)) Box( @@ -72,7 +99,14 @@ class ScaleBarActivity : ComponentActivity() { onMapLoaded = { isMapLoaded = true }, - ) + ) { + if (showCrosshairs) { + ScaleBarReferencePolylines( + cameraPositionState = cameraPositionState, + measureAtCenter = measureAtCenter, + ) + } + } Box( modifier = Modifier @@ -90,6 +124,7 @@ class ScaleBarActivity : ComponentActivity() { DisappearingScaleBar( modifier = Modifier.padding(end = 4.dp), cameraPositionState = cameraPositionState, + measureAtCenter = measureAtCenter, ) } @@ -109,8 +144,54 @@ class ScaleBarActivity : ComponentActivity() { ScaleBar( modifier = Modifier.padding(end = 4.dp), cameraPositionState = cameraPositionState, + measureAtCenter = measureAtCenter, ) } + + Column( + modifier = Modifier + .padding(start = 8.dp, bottom = 40.dp) + .align(Alignment.BottomStart) + .background( + controlsBackground, + shape = MaterialTheme.shapes.medium, + ) + .border( + scaleBorderStroke, + shape = MaterialTheme.shapes.medium, + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Row( + modifier = Modifier.clickable { measureAtCenter = !measureAtCenter }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = measureAtCenter, + onCheckedChange = { measureAtCenter = it }, + ) + Text( + text = "Measure at center", + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(end = 4.dp), + ) + } + Row( + modifier = Modifier.clickable { showCrosshairs = !showCrosshairs }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = showCrosshairs, + onCheckedChange = { showCrosshairs = it }, + ) + Text( + text = "Show crosshairs", + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(end = 4.dp), + ) + } + } + if (!isMapLoaded) { AnimatedVisibility( modifier = Modifier @@ -130,3 +211,76 @@ class ScaleBarActivity : ComponentActivity() { } } } + +/** + * Draws three sets of crossed reference polylines (at 10%, 50%, and 90% of the viewport height) + * aligned with geographic East–West (`270°`–`90°`, red) and North–South (`180°`–`0°`, blue), where + * every line's ground length on the Earth's surface matches the [ScaleBar]'s horizontal bar + * distance at the camera target (`position.target`). + * + * Because Google Maps uses a conformal Web Mercator projection (`EPSG:3857`), map scale stretches + * isotropically (in both East–West and North–South directions) by a factor of `1 / cos(latitude)` + * as latitude moves from the Equator toward either pole: + * - At the **middle cross** (`50%` height, anchored at `position.target`), the ground distance of + * both arms matches the exact pixel width of the [ScaleBar]'s horizontal line. + * - At the **top (`10%`)** and **bottom (`90%`)** crosses, the exact same ground distance in meters + * appears visibly larger in screen pixels when closer to the poles (higher `|latitude|`) and + * smaller in screen pixels when closer to the Equator. + */ +@Composable +@GoogleMapComposable +private fun ScaleBarReferencePolylines(cameraPositionState: CameraPositionState, measureAtCenter: Boolean = true) { + val configuration = LocalConfiguration.current + val density = LocalDensity.current + val referenceCrosses by remember(cameraPositionState, density, configuration, measureAtCenter) { + derivedStateOf { + val position = cameraPositionState.position + cameraPositionState.isMoving + val projection = cameraPositionState.projection ?: return@derivedStateOf emptyList() + val widthInPixels = with(density) { scaleBarWidth.toPx().toInt() } + val centerPoint = projection.toScreenLocation(position.target) + if (centerPoint.x <= 0 || centerPoint.y <= 0) return@derivedStateOf emptyList() + + val startPoint: Point + val endPoint: Point + if (measureAtCenter) { + val halfWidthInPixels = widthInPixels / 2 + startPoint = Point(centerPoint.x - halfWidthInPixels, centerPoint.y) + endPoint = Point(startPoint.x + widthInPixels, centerPoint.y) + } else { + startPoint = Point(0, 0) + endPoint = Point(widthInPixels, 0) + } + val startLatLng = projection.fromScreenLocation(startPoint) + val endLatLng = projection.fromScreenLocation(endPoint) + val scaleBarMeters = (startLatLng.sphericalDistance(endLatLng) * 8 / 9).toInt().toDouble() + if (scaleBarMeters <= 0.0) return@derivedStateOf emptyList() + + val halfDistanceMeters = scaleBarMeters / 2.0 + val topAnchor = projection.fromScreenLocation(Point(centerPoint.x, centerPoint.y / 5)) + val middleAnchor = position.target + val bottomAnchor = projection.fromScreenLocation(Point(centerPoint.x, centerPoint.y * 9 / 5)) + + listOf(topAnchor, middleAnchor, bottomAnchor).map { anchor -> + val west = anchor.withSphericalOffset(halfDistanceMeters, 270.0) + val east = anchor.withSphericalOffset(halfDistanceMeters, 90.0) + val south = anchor.withSphericalOffset(halfDistanceMeters, 180.0) + val north = anchor.withSphericalOffset(halfDistanceMeters, 0.0) + listOf(west, east) to listOf(south, north) + } + } + } + + referenceCrosses.forEach { (eastWestPoints, northSouthPoints) -> + Polyline( + points = eastWestPoints, + color = Color(0xFFD93025), + width = 6f, + ) + Polyline( + points = northSouthPoints, + color = Color(0xFF1A73E8), + width = 6f, + ) + } +} diff --git a/maps-app/src/test/java/com/google/maps/android/compose/ScaleBarRenderingTests.kt b/maps-app/src/test/java/com/google/maps/android/compose/ScaleBarRenderingTests.kt index b560f79bc..6874c2a59 100644 --- a/maps-app/src/test/java/com/google/maps/android/compose/ScaleBarRenderingTests.kt +++ b/maps-app/src/test/java/com/google/maps/android/compose/ScaleBarRenderingTests.kt @@ -65,11 +65,12 @@ class ScaleBarRenderingTests { private fun assertFractionalLabels(dark: Boolean, large: Boolean = false) { val projection = mockk() + every { projection.toScreenLocation(any()) } returns Point(100, 100) every { projection.fromScreenLocation(any()) } answers { // At the equator 0.02 degrees spans about 2,224 m, or 1,976 m for the 8/9-width bar. - if (firstArg().x == 0) LatLng(0.0, 0.0) else LatLng(0.0, if (large) 20.0 else 0.02) + if (firstArg().x <= 100) LatLng(0.0, 0.0) else LatLng(0.0, if (large) 20.0 else 0.02) } - val camera = mockk() + val camera = mockk(relaxed = true) every { camera.position } returns CameraPosition.fromLatLngZoom(LatLng(0.0, 0.0), 12f) every { camera.projection } returns projection val background = if (dark) Color.Black else Color.White diff --git a/maps-compose-widgets/api/maps-compose-widgets.api b/maps-compose-widgets/api/maps-compose-widgets.api index 8130333a6..01d1f5f3d 100644 --- a/maps-compose-widgets/api/maps-compose-widgets.api +++ b/maps-compose-widgets/api/maps-compose-widgets.api @@ -1,6 +1,6 @@ public final class com/google/maps/android/compose/widgets/ScaleBarKt { - public static final fun DisappearingScaleBar-JXI6SxA (Landroidx/compose/ui/Modifier;FFLcom/google/maps/android/compose/CameraPositionState;JJJILandroidx/compose/animation/EnterTransition;Landroidx/compose/animation/ExitTransition;Landroidx/compose/runtime/Composer;II)V - public static final fun ScaleBar-7pV8VcA (Landroidx/compose/ui/Modifier;FFLcom/google/maps/android/compose/CameraPositionState;JJJLandroidx/compose/runtime/Composer;II)V + public static final fun DisappearingScaleBar-BrNgjN8 (Landroidx/compose/ui/Modifier;FFLcom/google/maps/android/compose/CameraPositionState;JJJILandroidx/compose/animation/EnterTransition;Landroidx/compose/animation/ExitTransition;ZLandroidx/compose/runtime/Composer;III)V + public static final fun ScaleBar-2VBzJY8 (Landroidx/compose/ui/Modifier;FFLcom/google/maps/android/compose/CameraPositionState;JJJZLandroidx/compose/runtime/Composer;II)V public static final fun getDarkGray ()J } diff --git a/maps-compose-widgets/src/androidTest/java/com/google/maps/android/compose/ScaleBarUnitTest.kt b/maps-compose-widgets/src/androidTest/java/com/google/maps/android/compose/ScaleBarUnitTest.kt index f7d6b2154..08bc9948c 100644 --- a/maps-compose-widgets/src/androidTest/java/com/google/maps/android/compose/ScaleBarUnitTest.kt +++ b/maps-compose-widgets/src/androidTest/java/com/google/maps/android/compose/ScaleBarUnitTest.kt @@ -54,4 +54,58 @@ public class ScaleBarUnitTest { assertThat(result).isEqualTo(expectedResult) } + + @Test + public fun testScaleBarCalculationAtTargetCenter() { + val projection = mockk(relaxed = true) + val density = Density(1f, 1f) + val width = 100.dp + val target = LatLng(64.9631, -19.0208) + val centerPoint = Point(540, 1200) + + val startPoint = Point(490, 1200) + val endPoint = Point(590, 1200) + + val startLatLng = LatLng(64.9631, -19.5) + val endLatLng = LatLng(64.9631, -18.5) + + every { projection.toScreenLocation(target) } returns centerPoint + every { projection.fromScreenLocation(startPoint) } returns startLatLng + every { projection.fromScreenLocation(endPoint) } returns endLatLng + + val expectedDistance = startLatLng.sphericalDistance(endLatLng) + val expectedResult = (expectedDistance * 8 / 9).toInt() + + val result = calculateDistance(projection, width, density, target) + + assertThat(result).isEqualTo(expectedResult) + } + + @Test + public fun testScaleBarCalculationAfterDeviceRotationToLandscape() { + val projection = mockk(relaxed = true) + val density = Density(1f, 1f) + val width = 100.dp + val target = LatLng(64.9631, -19.0208) + + val portraitCenter = Point(540, 1200) + val landscapeCenter = Point(1200, 540) + + val startLatLng = LatLng(64.9631, -19.5) + val endLatLng = LatLng(64.9631, -18.5) + + every { projection.toScreenLocation(target) } returns portraitCenter + every { projection.fromScreenLocation(Point(490, 1200)) } returns startLatLng + every { projection.fromScreenLocation(Point(590, 1200)) } returns endLatLng + + val portraitResult = calculateDistance(projection, width, density, target) + + every { projection.toScreenLocation(target) } returns landscapeCenter + every { projection.fromScreenLocation(Point(1150, 540)) } returns startLatLng + every { projection.fromScreenLocation(Point(1250, 540)) } returns endLatLng + + val landscapeResult = calculateDistance(projection, width, density, target) + + assertThat(landscapeResult).isEqualTo(portraitResult) + } } diff --git a/maps-compose-widgets/src/main/java/com/google/maps/android/compose/widgets/ScaleBar.kt b/maps-compose-widgets/src/main/java/com/google/maps/android/compose/widgets/ScaleBar.kt index 428ecd2ba..9b9f951b6 100644 --- a/maps-compose-widgets/src/main/java/com/google/maps/android/compose/widgets/ScaleBar.kt +++ b/maps-compose-widgets/src/main/java/com/google/maps/android/compose/widgets/ScaleBar.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Density @@ -48,21 +49,63 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import com.google.android.gms.maps.Projection +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.ImperialUnitsConverter +import com.google.maps.android.MetricUnitsConverter +import com.google.maps.android.NumberFormatSymbols import com.google.maps.android.compose.CameraPositionState +import com.google.maps.android.meters +import com.google.maps.android.miles import com.google.maps.android.sphericalDistance +import com.google.maps.android.toDistanceString import kotlinx.coroutines.delay -import kotlin.math.roundToInt -internal fun calculateDistance(projection: Projection, width: Dp, density: Density): Int { +/** + * Computes the ground distance (in meters) represented by the horizontal bar of the [ScaleBar]. + * + * Google Maps uses a Web Mercator projection (`EPSG:3857`), which is conformal: at any latitude + * `φ`, the map scale stretches isotropically (in both East–West and North–South directions) by a + * factor of `sec(φ) = 1 / cos(φ)`. Because of this latitude-dependent distortion—as well as + * perspective foreshortening when the camera is tilted—the ground distance per screen pixel can + * vary dramatically between the top edge of the screen, the center of the viewport, and the bottom + * edge of the screen (especially at low zoom levels or high latitudes). + * + * When a non-null [target] (typically `cameraPositionState.position.target`) is supplied, this + * function projects that geographic target to its screen coordinate `centerPoint` and measures a + * horizontal pixel span of [width] centered on `(centerPoint.x, centerPoint.y)`: + * - This measures the map scale at the exact focal point the user is looking at. + * - Because the span is sampled horizontally in screen space across `centerPoint.y` and converted + * back to geographic coordinates via [Projection.fromScreenLocation], it automatically accounts + * for map bearing rotation, camera tilt, and device orientation changes. + * + * When [target] is `null` (used when `measureAtCenter = false`), this function falls back to the + * legacy behavior of sampling horizontally along the top-left edge of the viewport from + * `Point(0, 0)` to `Point(widthInPixels, 0)`. + * + * Finally, because the drawn horizontal scale line spans `8 / 9` of the canvas width (from + * `size.width / 9` to `size.width`), the full canvas ground distance is scaled by `8 / 9`. + */ +internal fun calculateDistance(projection: Projection, width: Dp, density: Density, target: LatLng? = null): Int { val widthInPixels = with(density) { width.toPx().toInt() } - val upperLeftLatLng = projection.fromScreenLocation(Point(0, 0)) - val upperRightLatLng = - projection.fromScreenLocation(Point(widthInPixels, 0)) + val startPoint: Point + val endPoint: Point + if (target != null) { + val centerPoint = projection.toScreenLocation(target) + val halfWidthInPixels = widthInPixels / 2 + startPoint = Point(centerPoint.x - halfWidthInPixels, centerPoint.y) + endPoint = Point(startPoint.x + widthInPixels, centerPoint.y) + } else { + startPoint = Point(0, 0) + endPoint = Point(widthInPixels, 0) + } + + val startLatLng = projection.fromScreenLocation(startPoint) + val endLatLng = projection.fromScreenLocation(endPoint) - val canvasWidthMeters = upperLeftLatLng.sphericalDistance(upperRightLatLng) + val canvasWidthMeters = startLatLng.sphericalDistance(endLatLng) return (canvasWidthMeters * 8 / 9).toInt() } @@ -82,6 +125,11 @@ private val defaultHeight: Dp = 50.dp * @param textColor The color of the text on the scale bar. * @param lineColor The color of the lines on the scale bar. * @param shadowColor The color of the shadow behind the text and lines. + * @param measureAtCenter When `true` (the default), the scale distance is measured across the + * camera target at the center of the map viewport. When `false`, the legacy behavior is used, + * measuring the scale along the top edge of the map from `Point(0, 0)`. Because the Web Mercator + * projection stretches map scale by `1 / cos(latitude)` toward the poles, measuring at the + * center of the viewport more accurately reflects the scale where the camera is focused. */ @Composable public fun ScaleBar( @@ -92,12 +140,28 @@ public fun ScaleBar( textColor: Color = DarkGray, lineColor: Color = DarkGray, shadowColor: Color = Color.White, + measureAtCenter: Boolean = true, ) { + // Observe LocalConfiguration so that device rotation or window resizing (which changes the + // viewport dimensions and Projection screen coordinates without necessarily changing + // CameraPosition) triggers a recalculation. + val configuration = LocalConfiguration.current val density = LocalDensity.current - val horizontalLineWidthMeters by remember(cameraPositionState.position.zoom) { + val horizontalLineWidthMeters by remember( + cameraPositionState, + width, + density, + configuration, + measureAtCenter, + ) { derivedStateOf { + // Read both position and isMoving so the scale updates whenever the camera pans, + // zooms, rotates, tilts, or finishes an animation/gesture. + val position = cameraPositionState.position + cameraPositionState.isMoving + val target = if (measureAtCenter) position.target else null cameraPositionState.projection?.let { - calculateDistance(it, width, density) + calculateDistance(it, width, density, target) } ?: 0 } } @@ -196,28 +260,17 @@ public fun ScaleBar( } } -internal fun formatMetricDistance(meters: Int): String = if (meters >= METERS_IN_KILOMETER) { - "${formatScaleDistance(meters / METERS_IN_KILOMETER)} km" -} else { - "$meters m" -} +// Use a 1-mile threshold so the imperial scale bar transitions from feet to miles at 1 mile +// (5,280 ft), matching the metric transition from meters to kilometers at 1 km (1,000 m), with +// compact US/invariant number formatting (`NumberFormatSymbols.COMPACT`) so labels fit inside the +// compact scale bar canvas. +private val scaleBarImperialConverter = ImperialUnitsConverter(milesThreshold = 1.miles) -internal fun formatImperialDistance(meters: Int): String { - val feet = meters.toDouble().toFeet() - return if (feet >= FEET_IN_MILE) { - "${formatScaleDistance(feet.toMiles())} mi" - } else { - "${feet.roundToInt()} ft" - } -} +internal fun formatMetricDistance(meters: Int): String = + meters.meters.toDistanceString(MetricUnitsConverter, NumberFormatSymbols.COMPACT) -private fun formatScaleDistance(distance: Double): String { - if (distance >= 10.0) { - return distance.roundToInt().toString() - } - val scaled = (distance * 10.0).roundToInt() - return if (scaled % 10 == 0) "${scaled / 10}" else "${scaled / 10}.${scaled % 10}" -} +internal fun formatImperialDistance(meters: Int): String = + meters.meters.toDistanceString(scaleBarImperialConverter, NumberFormatSymbols.COMPACT) /** * An animated scale bar that appears when the zoom level of the map changes, and then disappears @@ -233,6 +286,9 @@ private fun formatScaleDistance(distance: Double): String { * @param visibilityDurationMillis The duration in milliseconds that the scale bar will be visible. * @param enterTransition The animation to use when the scale bar appears. * @param exitTransition The animation to use when the scale bar disappears. + * @param measureAtCenter When `true` (the default), the scale distance is measured across the + * camera target at the center of the map viewport. When `false`, the legacy behavior is used, + * measuring the scale along the top edge of the map from `Point(0, 0)`. */ @Composable public fun DisappearingScaleBar( @@ -246,6 +302,7 @@ public fun DisappearingScaleBar( visibilityDurationMillis: Int = 3_000, enterTransition: EnterTransition = fadeIn(), exitTransition: ExitTransition = fadeOut(), + measureAtCenter: Boolean = true, ) { val visible = remember { MutableTransitionState(true) @@ -277,6 +334,7 @@ public fun DisappearingScaleBar( textColor = textColor, lineColor = lineColor, shadowColor = shadowColor, + measureAtCenter = measureAtCenter, ) } } @@ -304,23 +362,3 @@ private fun ScaleText( ), ) } - -/** - * Converts [this] value in meters to the corresponding value in feet. - * This is a utility function used for unit conversion. - * @return [this] meters value converted to feet - */ -internal fun Double.toFeet(): Double = this * CENTIMETERS_IN_METER / CENTIMETERS_IN_INCH / INCHES_IN_FOOT - -/** - * Converts [this] value in feet to the corresponding value in miles. - * This is a utility function used for unit conversion. - * @return [this] feet value converted to miles - */ -internal fun Double.toMiles(): Double = this / FEET_IN_MILE - -private const val CENTIMETERS_IN_METER: Double = 100.0 -private const val METERS_IN_KILOMETER: Double = 1000.0 -private const val CENTIMETERS_IN_INCH: Double = 2.54 -private const val INCHES_IN_FOOT: Double = 12.0 -private const val FEET_IN_MILE: Double = 5280.0