Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -72,53 +75,76 @@ 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
val twoThirdsHeight = size.height * 2 / 3
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),
end = Offset(size.width, midHeight),
strokeWidth = shadowStrokeWidth,
cap = StrokeCap.Round
)
// Top vertical line shadow (drawn under main lines)
// Top vertical line shadow
drawLine(
color = shadowColor,
start = Offset(oneNinthWidth, oneThirdHeight),
end = Offset(oneNinthWidth, midHeight),
strokeWidth = shadowStrokeWidth,
cap = StrokeCap.Round
)
// Bottom vertical line shadow (drawn under main lines)
// Bottom vertical line shadow
drawLine(
color = shadowColor,
start = Offset(oneNinthWidth, midHeight),
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -263,15 +309,17 @@ 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 {
return this * CENTIMETERS_IN_METER / CENTIMETERS_IN_INCH / INCHES_IN_FOOT
}

/**
* 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 {
Expand Down