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 new file mode 100644 index 00000000..e2619ada --- /dev/null +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarTests.kt @@ -0,0 +1,130 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.maps.android.compose + +import android.graphics.Point +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +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.widgets.ScaleBar +import com.google.maps.android.ktx.utils.sphericalDistance +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 + +// 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 + +class ScaleBarTests { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var cameraPositionState: CameraPositionState + + private fun initScaleBar(initialZoom: Float, initialPosition: LatLng) { + check(hasValidApiKey) { "Maps API key not specified" } + + val countDownLatch = CountDownLatch(1) + + cameraPositionState = CameraPositionState( + position = CameraPosition.fromLatLngZoom(initialPosition, initialZoom) + ) + + composeTestRule.setContent { + Box { + GoogleMap( + cameraPositionState = cameraPositionState, + onMapLoaded = { + countDownLatch.countDown() + } + ) + ScaleBar(cameraPositionState = cameraPositionState) + } + } + val mapLoaded = countDownLatch.await(5, TimeUnit.SECONDS) + assertTrue(mapLoaded) + } + + @Test + fun testScaleBarInitialState() { + val initialZoom = 15f + val initialPosition = LatLng(37.7749, -122.4194) // San Francisco + initScaleBar(initialZoom, initialPosition) + + composeTestRule.waitForIdle() + + var imperialText = "" + var metricText = "" + + 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 + val widthInPixels = widthInDp.value.toInt() + + val upperLeftLatLng = proj.fromScreenLocation(Point(0, 0)) + val upperRightLatLng = proj.fromScreenLocation(Point(0, widthInPixels)) + val canvasWidthMeters = upperLeftLatLng.sphericalDistance(upperRightLatLng) + val horizontalLineWidthMeters = (canvasWidthMeters * 8 / 9).toInt() + + var metricUnits = "m" + var metricDistance = horizontalLineWidthMeters + if (horizontalLineWidthMeters > METERS_IN_KILOMETER) { + metricUnits = "km" + metricDistance /= METERS_IN_KILOMETER.toInt() + } + + var imperialUnits = "ft" + var imperialDistance = horizontalLineWidthMeters.toDouble().toFeet() + if (imperialDistance > FEET_IN_MILE) { + imperialUnits = "mi" + imperialDistance = imperialDistance.toMiles() + } + imperialText = "${imperialDistance.toInt()} $imperialUnits" + metricText = "$metricDistance $metricUnits" + } ?: fail("Projection should not be null") + } + + composeTestRule.onNodeWithText( + text = imperialText, + ).assertExists() + composeTestRule.onNodeWithText( + text = metricText, + ).assertExists() + } +} + +internal fun Double.toFeet(): Double { + return this * CENTIMETERS_IN_METER / CENTIMETERS_IN_INCH / INCHES_IN_FOOT +} + +internal fun Double.toMiles(): Double { + return this / FEET_IN_MILE +} 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 1bdc387e..afa46463 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 @@ -31,10 +31,9 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.End import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -46,7 +45,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp -import com.google.android.gms.maps.model.LatLng import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.ktx.utils.sphericalDistance import kotlinx.coroutines.delay @@ -59,8 +57,13 @@ private val defaultHeight: Dp = 50.dp * A scale bar composable that shows the current scale of the map in feet and meters when zoomed in * to the map, changing to miles and kilometers, respectively, when zooming out. * - * Implement your own observer on camera move events using [CameraPositionState] and pass it in - * as [cameraPositionState]. + * @param modifier Modifier to be applied to the composable. + * @param width The width of the composable. + * @param height The height of the composable. + * @param cameraPositionState The state of the camera position, used to calculate the scale. + * @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. */ @Composable public fun ScaleBar( @@ -72,29 +75,48 @@ public fun ScaleBar( lineColor: Color = DarkGray, shadowColor: Color = Color.White, ) { - Box( - modifier = modifier - .size(width = width, height = height) - ) { - var horizontalLineWidthMeters by remember { - mutableIntStateOf(0) + // This is the core logic for calculating the scale of the map. + // + // `remember` with a key (`cameraPositionState.position.zoom`) is used for performance. + // It ensures that the calculation inside is only re-executed when the zoom level changes. + // This is important because we don't need to recalculate the scale every time the map pans, + // only when the zoom level changes. + // + // `derivedStateOf` is a Compose state function that creates a new state object that is + // derived from other state objects. The calculation inside `derivedStateOf` is only + // re-executed when one of the state objects it reads from changes. In this case, it's + // `cameraPositionState.projection`. This is another performance optimization that + // prevents unnecessary recalculations. + val horizontalLineWidthMeters by remember(cameraPositionState.position.zoom) { + derivedStateOf { + // The projection is used to convert between screen coordinates (pixels) and + // geographical coordinates (LatLng). It can be null if the map is not ready yet. + val projection = cameraPositionState.projection ?: return@derivedStateOf 0 + + // We get the geographical coordinates of two points on the screen: the top-left + // corner (0, 0) and a point to the right of it, at the width of the scale bar. + val upperLeftLatLng = projection.fromScreenLocation(Point(0, 0)) + val upperRightLatLng = + projection.fromScreenLocation(Point(0, width.value.toInt())) + + // We then calculate the spherical distance between these two points in meters. + // This gives us the distance that the scale bar represents on the map. + val canvasWidthMeters = upperLeftLatLng.sphericalDistance(upperRightLatLng) + + // We take 8/9th of the canvas width to provide some padding on the right side + // of the scale bar. + (canvasWidthMeters * 8 / 9).toInt() } + } + Box( + modifier = modifier.size(width = width, height = height) + ) { + // The Canvas composable is used for custom drawing. Here, we are drawing the + // lines of the scale bar. Canvas( modifier = Modifier.fillMaxSize(), onDraw = { - // Get width of canvas in meters - val upperLeftLatLng = - cameraPositionState.projection?.fromScreenLocation(Point(0, 0)) - ?: LatLng(0.0, 0.0) - val upperRightLatLng = - cameraPositionState.projection?.fromScreenLocation(Point(0, size.width.toInt())) - ?: LatLng(0.0, 0.0) - val canvasWidthMeters = upperLeftLatLng.sphericalDistance(upperRightLatLng) - val eightNinthsCanvasMeters = (canvasWidthMeters * 8 / 9).toInt() - - horizontalLineWidthMeters = eightNinthsCanvasMeters - val oneNinthWidth = size.width / 9 val midHeight = size.height / 2 val oneThirdHeight = size.height / 3 @@ -102,7 +124,11 @@ public fun ScaleBar( val strokeWidth = 4f val shadowStrokeWidth = strokeWidth + 3 - // Middle horizontal line shadow (drawn under main lines) + // The shadows are drawn first, slightly offset from the main lines, to create + // a "drop shadow" effect. This makes the scale bar more readable on different + // map backgrounds. + + // Middle horizontal line shadow drawLine( color = shadowColor, start = Offset(oneNinthWidth, midHeight), @@ -110,7 +136,7 @@ public fun ScaleBar( strokeWidth = shadowStrokeWidth, cap = StrokeCap.Round ) - // Top vertical line shadow (drawn under main lines) + // Top vertical line shadow drawLine( color = shadowColor, start = Offset(oneNinthWidth, oneThirdHeight), @@ -118,7 +144,7 @@ public fun ScaleBar( strokeWidth = shadowStrokeWidth, cap = StrokeCap.Round ) - // Bottom vertical line shadow (drawn under main lines) + // Bottom vertical line shadow drawLine( color = shadowColor, start = Offset(oneNinthWidth, midHeight), @@ -127,6 +153,8 @@ public fun ScaleBar( cap = StrokeCap.Round ) + // These are the main lines of the scale bar. + // Middle horizontal line drawLine( color = lineColor, @@ -157,6 +185,9 @@ public fun ScaleBar( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.SpaceAround ) { + // Here, we determine the appropriate units (meters/kilometers and feet/miles) + // based on the calculated distance in meters. + var metricUnits = "m" var metricDistance = horizontalLineWidthMeters if (horizontalLineWidthMeters > METERS_IN_KILOMETER) { @@ -173,6 +204,8 @@ public fun ScaleBar( imperialDistance = imperialDistance.toMiles() } + // We display the calculated distances in two Text composables, one for imperial + // and one for metric units. ScaleText( modifier = Modifier.align(End), textColor = textColor, @@ -193,8 +226,16 @@ public fun ScaleBar( * An animated scale bar that appears when the zoom level of the map changes, and then disappears * after [visibilityDurationMillis]. This composable wraps [ScaleBar] with visibility animations. * - * Implement your own observer on camera move events using [CameraPositionState] and pass it in - * as [cameraPositionState]. + * @param modifier Modifier to be applied to the composable. + * @param width The width of the composable. + * @param height The height of the composable. + * @param cameraPositionState The state of the camera position, used to calculate the scale. + * @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 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. */ @Composable public fun DisappearingScaleBar( @@ -213,14 +254,19 @@ public fun DisappearingScaleBar( MutableTransitionState(true) } - LaunchedEffect(key1 = cameraPositionState.position.zoom) { - // Show ScaleBar + // This effect is re-launched every time the camera position changes. + // + // The effect itself makes the scale bar visible, waits for the specified duration, + // and then makes it invisible again. This creates the "disappearing" effect. + LaunchedEffect(key1 = cameraPositionState.position) { visible.targetState = true delay(visibilityDurationMillis.toLong()) - // Hide ScaleBar after timeout period visible.targetState = false } + // `AnimatedVisibility` is a composable that animates the appearance and disappearance + // of its content. We are using it here to wrap the `ScaleBar` and provide the + // fade-in and fade-out animations. AnimatedVisibility( visibleState = visible, modifier = modifier, @@ -263,7 +309,8 @@ private fun ScaleText( } /** - * Converts [this] value in meters to the corresponding value in feet + * 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 { @@ -271,7 +318,8 @@ internal fun Double.toFeet(): Double { } /** - * Converts [this] value in feet to the corresponding value in miles + * 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 {