Skip to content
Open
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,87 @@
// Copyright 2026 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.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.assertIsDisplayed
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.Projection
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 io.mockk.every
import io.mockk.mockk
import org.junit.Rule
import org.junit.Test

class ScaleBarRenderingTests {
@get:Rule
val composeTestRule = createComposeRule()

@Test
fun fractionalLabelsRenderOnLightBackground() {
assertFractionalLabels(dark = false)
}

@Test
fun fractionalLabelsRenderOnDarkBackground() {
assertFractionalLabels(dark = true)
}

@Test
fun largeLabelsRenderOnLightBackground() {
assertFractionalLabels(dark = false, large = true)
}

@Test
fun largeLabelsRenderOnDarkBackground() {
assertFractionalLabels(dark = true, large = true)
}

private fun assertFractionalLabels(dark: Boolean, large: Boolean = false) {
val projection = mockk<Projection>()
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<Point>().x == 0) LatLng(0.0, 0.0) else LatLng(0.0, if (large) 20.0 else 0.02)
}
val camera = mockk<CameraPositionState>()
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
val foreground = if (dark) Color.White else Color.Black
composeTestRule.setContent {
MaterialTheme {
Box(Modifier.background(background).padding(16.dp)) {
ScaleBar(
cameraPositionState = camera,
textColor = foreground,
lineColor = foreground,
shadowColor = background,
)
}
}
}
composeTestRule.onNodeWithText(if (large) "1977 km" else "2 km").assertIsDisplayed()
composeTestRule.onNodeWithText(if (large) "1228 mi" else "1.2 mi").assertIsDisplayed()
}
Comment on lines +48 to +86

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling captureToImage() and writing PNGs to getExternalFilesDir(null) was a great way to visually verify the rendering during development! For the checked-in test suite, could we remove the bitmap capture and file write (along with the java.io.File and java.util.Locale imports) so the test stays fast, self-contained, and avoids unasserted disk I/O in CI?

    private fun assertFractionalLabels(dark: Boolean) {
        val projection = mockk<Projection>()
        every { projection.fromScreenLocation(any()) } answers {
            // At the equator this spans about 2,224 m, or 1,976 m for the 8/9-width bar.
            if (firstArg<Point>().x == 0) LatLng(0.0, 0.0) else LatLng(0.0, 0.02)
        }
        val camera = mockk<CameraPositionState>()
        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
        val foreground = if (dark) Color.White else Color.Black
        composeTestRule.setContent {
            MaterialTheme {
                Box(Modifier.background(background).padding(16.dp)) {
                    ScaleBar(
                        cameraPositionState = camera,
                        textColor = foreground,
                        lineColor = foreground,
                        shadowColor = background,
                    )
                }
            }
        }
        composeTestRule.onNodeWithText("1.98 km").assertIsDisplayed()
        composeTestRule.onNodeWithText("1.23 mi").assertIsDisplayed()
    }

}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import org.junit.Rule
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt

// These constants are used for converting between metric and imperial units
// to ensure the scale bar displays distances correctly in both systems.
Expand Down Expand Up @@ -75,7 +76,20 @@ class ScaleBarTests {

@Test
fun testScaleBarInitialState() {
val initialZoom = 15f
assertScaleBarLabels(initialZoom = 15f)
}

@Test
fun testScaleBarFractionalKilometers() {
assertScaleBarLabels(initialZoom = 12f)
}

@Test
fun testScaleBarFractionalMiles() {
assertScaleBarLabels(initialZoom = 10f)
}

private fun assertScaleBarLabels(initialZoom: Float) {
val initialPosition = LatLng(37.7749, -122.4194) // San Francisco
initScaleBar(initialZoom, initialPosition)

Expand All @@ -96,25 +110,21 @@ class ScaleBarTests {
}

val upperLeftLatLng = proj.fromScreenLocation(Point(0, 0))
val upperRightLatLng = proj.fromScreenLocation(Point(0, widthInPixels))
val upperRightLatLng = proj.fromScreenLocation(Point(widthInPixels, 0))
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()
metricText = if (horizontalLineWidthMeters >= METERS_IN_KILOMETER) {
"${formatTestDistance(horizontalLineWidthMeters / METERS_IN_KILOMETER)} km"
} else {
"$horizontalLineWidthMeters m"
}

var imperialUnits = "ft"
var imperialDistance = horizontalLineWidthMeters.toDouble().toFeet()
if (imperialDistance > FEET_IN_MILE) {
imperialUnits = "mi"
imperialDistance = imperialDistance.toMiles()
val feet = horizontalLineWidthMeters.toDouble().toFeet()
imperialText = if (feet >= FEET_IN_MILE) {
"${formatTestDistance(feet.toMiles())} mi"
} else {
"${feet.roundToInt()} ft"
}
imperialText = "${imperialDistance.toInt()} $imperialUnits"
metricText = "$metricDistance $metricUnits"
} ?: fail("Projection should not be null")
}

Expand All @@ -130,3 +140,9 @@ class ScaleBarTests {
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}"
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import com.google.android.gms.maps.Projection
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.sphericalDistance
import kotlinx.coroutines.delay
import kotlin.math.roundToInt

internal fun calculateDistance(projection: Projection, width: Dp, density: Density): Int {
val widthInPixels = with(density) {
Expand Down Expand Up @@ -177,43 +178,47 @@ 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) {
// Switch from meters to kilometers as unit
metricUnits = "km"
metricDistance /= METERS_IN_KILOMETER.toInt()
}

var imperialUnits = "ft"
var imperialDistance = horizontalLineWidthMeters.toDouble().toFeet()
if (imperialDistance > FEET_IN_MILE) {
// Switch from ft to miles as unit
imperialUnits = "mi"
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,
shadowColor = shadowColor,
text = "${imperialDistance.toInt()} $imperialUnits",
text = formatImperialDistance(horizontalLineWidthMeters),
)
ScaleText(
modifier = Modifier.align(End),
textColor = textColor,
shadowColor = shadowColor,
text = "$metricDistance $metricUnits",
text = formatMetricDistance(horizontalLineWidthMeters),
)
}
}
}

internal fun formatMetricDistance(meters: Int): String = if (meters >= METERS_IN_KILOMETER) {
"${formatScaleDistance(meters / METERS_IN_KILOMETER)} km"
} else {
"$meters m"
}

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"
}
}

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}"
}
Comment on lines +214 to +220

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @bubenheimer mentioned, two decimals are a bit too much for someone reading a map ("1.76 mi"). What about rounding to one decimal, which also matches what we are doing in the utils Units API? The tests would need an update (e.g. 1.01 km becomes 1 km, 1.24 mi becomes 1.2 mi).

Suggested change
private fun formatScaleDistance(distance: Double): String {
if (distance >= 10.0) {
return distance.roundToInt().toString()
}
val scaled = (distance * 100.0).roundToInt()
val whole = scaled / 100
val remainder = scaled % 100
return when {
remainder == 0 -> "$whole"
remainder % 10 == 0 -> "$whole.${remainder / 10}"
else -> "$whole.${remainder.toString().padStart(2, '0')}"
}
}
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}"
}


/**
Comment on lines +201 to 222

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are working toward making this codebase Kotlin Multiplatform (KMP) friendly, we'd love to avoid introducing java.text.NumberFormat (and java.* imports in general) into :maps-compose-widgets, which currently has zero java.* imports.

Also, when zoomed far out (e.g., continental/world zoom levels where the bar spans hundreds or thousands of kilometers/miles), formatting with 2 decimal places produces long labels like "3456.78 km" (10 characters) that can wrap or clip inside the default 65.dp width. For distances >= 10 km/mi, rounding to a whole number keeps the label compact and readable.

We can handle both cleanly in pure Kotlin using kotlin.math.roundToInt with zero JVM dependencies:

import kotlin.math.roundToInt

internal fun formatMetricDistance(meters: Int): String = if (meters >= METERS_IN_KILOMETER) {
    "${formatScaleDistance(meters / METERS_IN_KILOMETER)} km"
} else {
    "$meters m"
}

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"
    }
}

private fun formatScaleDistance(distance: Double): String {
    if (distance >= 10.0) {
        return distance.roundToInt().toString()
    }
    val scaled = (distance * 100.0).roundToInt()
    val whole = scaled / 100
    val remainder = scaled % 100
    return when {
        remainder == 0 -> "$whole"
        remainder % 10 == 0 -> "$whole.${remainder / 10}"
        else -> "$whole.${remainder.toString().padStart(2, '0')}"
    }
}

* 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright 2026 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.widgets

import org.junit.Assert.assertEquals
import org.junit.Test

internal class ScaleBarDistanceTest {
@Test
fun metricDistancePreservesFractionalKilometers() {
assertEquals("1.9 km", formatMetricDistance(1900))
assertEquals("1 km", formatMetricDistance(1010))
}

@Test
fun imperialDistancePreservesFractionalMiles() {
assertEquals("1.2 mi", formatImperialDistance(2000))
assertEquals("2 mi", formatImperialDistance(3200))
}

@Test
fun metricDistanceUsesKilometersAtUnitBoundary() {
assertEquals("999 m", formatMetricDistance(999))
assertEquals("1 km", formatMetricDistance(1000))
assertEquals("2 km", formatMetricDistance(2000))
}

@Test
fun smallDistancesKeepWholeMetersAndFeet() {
assertEquals("0 m", formatMetricDistance(0))
assertEquals("0 ft", formatImperialDistance(0))
assertEquals("100 m", formatMetricDistance(100))
assertEquals("328 ft", formatImperialDistance(100))
}

@Test
fun largeDistancesRoundToWholeUnits() {
assertEquals("10 km", formatMetricDistance(10_400))
assertEquals("12 km", formatMetricDistance(11_600))
assertEquals("10 mi", formatImperialDistance(16_100))
assertEquals("3457 km", formatMetricDistance(3_456_780))
}

@Test
fun fractionalDistancesTrimZerosAndRoundAtBoundaries() {
assertEquals("1 km", formatMetricDistance(1049))
assertEquals("1.1 km", formatMetricDistance(1050))
assertEquals("9.9 km", formatMetricDistance(9949))
assertEquals("10 km", formatMetricDistance(9950))
assertEquals("10 km", formatMetricDistance(9999))
assertEquals("10 km", formatMetricDistance(9990))
assertEquals("3 ft", formatImperialDistance(1))
assertEquals("7 ft", formatImperialDistance(2))
}
}
Comment on lines +15 to +67

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for adding unit tests for maps-compose-widgets!

Following up on the KMP comment in ScaleBar.kt, switching formatScaleDistance to pure Kotlin math means we can remove import java.util.Locale (and @Before / @After Locale.setDefault(...) mutation) here as well, and add a test case for larger distances (>= 10 km/mi):

package com.google.maps.android.compose.widgets

import org.junit.Assert.assertEquals
import org.junit.Test

internal class ScaleBarDistanceTest {
    @Test
    fun metricDistancePreservesFractionalKilometers() {
        assertEquals("1.9 km", formatMetricDistance(1900))
        assertEquals("1.01 km", formatMetricDistance(1010))
    }

    @Test
    fun imperialDistancePreservesFractionalMiles() {
        assertEquals("1.24 mi", formatImperialDistance(2000))
        assertEquals("1.99 mi", formatImperialDistance(3200))
    }

    @Test
    fun metricDistanceUsesKilometersAtUnitBoundary() {
        assertEquals("999 m", formatMetricDistance(999))
        assertEquals("1 km", formatMetricDistance(1000))
        assertEquals("2 km", formatMetricDistance(2000))
    }

    @Test
    fun largeDistancesRoundToWholeUnits() {
        assertEquals("10 km", formatMetricDistance(10_400))
        assertEquals("12 km", formatMetricDistance(11_600))
        assertEquals("10 mi", formatImperialDistance(16_100))
    }

    @Test
    fun smallDistancesKeepWholeMetersAndFeet() {
        assertEquals("0 m", formatMetricDistance(0))
        assertEquals("0 ft", formatImperialDistance(0))
        assertEquals("100 m", formatMetricDistance(100))
        assertEquals("328 ft", formatImperialDistance(100))
    }
}

Loading