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 3be09312..8db8b2e3 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 d95e8c0a..a6163b7d 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 5ed9a8a4..d34f7d1e 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 b560f79b..6874c2a5 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 8130333a..01d1f5f3 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 f7d6b215..08bc9948 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 428ecd2b..9b9f951b 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