Skip to content

Commit ca8cf2d

Browse files
kikosodkhawk
andauthored
fix: updating DisappearingScaleBar (#762)
* fix: updating DisappearingScaleBar * fix: added test * fix: added test * fix: added test * refactor(widgets): improve ScaleBar performance, tests, and documentation This commit enhances the `ScaleBar` composable with performance optimizations, more robust tests, and comprehensive documentation. - **Performance**: The `ScaleBar` now uses `remember` with the zoom level as a key, and `derivedStateOf` to ensure the scale is only recalculated when the map's zoom level changes, not on every pan. This significantly improves efficiency. - **Tests**: The `ScaleBarTests` are now more reliable. They use a null-safe `let` block to prevent potential crashes and calculate the expected scale text based on the map's projection for more accurate verification. - **Documentation**: - Added additional KDocs and inline comments to `ScaleBar` and `DisappearingScaleBar`, explaining complex calculations and advanced Compose concepts. * refactor: Simplify comment in DisappearingScaleBar --------- Co-authored-by: dkhawk <107309+dkhawk@users.noreply.github.com>
1 parent 999daae commit ca8cf2d

2 files changed

Lines changed: 211 additions & 33 deletions

File tree

  • maps-app/src/androidTest/java/com/google/maps/android/compose
  • maps-compose-widgets/src/main/java/com/google/maps/android/compose/widgets
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.maps.android.compose
16+
17+
import android.graphics.Point
18+
import androidx.compose.foundation.layout.Box
19+
import androidx.compose.ui.test.junit4.createComposeRule
20+
import androidx.compose.ui.test.onNodeWithText
21+
import androidx.compose.ui.unit.dp
22+
import com.google.android.gms.maps.model.CameraPosition
23+
import com.google.android.gms.maps.model.LatLng
24+
import com.google.maps.android.compose.widgets.ScaleBar
25+
import com.google.maps.android.ktx.utils.sphericalDistance
26+
import org.junit.Assert.assertTrue
27+
import org.junit.Assert.fail
28+
import org.junit.Rule
29+
import org.junit.Test
30+
import java.util.concurrent.CountDownLatch
31+
import java.util.concurrent.TimeUnit
32+
33+
// These constants are used for converting between metric and imperial units
34+
// to ensure the scale bar displays distances correctly in both systems.
35+
private const val CENTIMETERS_IN_METER: Double = 100.0
36+
private const val METERS_IN_KILOMETER: Double = 1000.0
37+
private const val CENTIMETERS_IN_INCH: Double = 2.54
38+
private const val INCHES_IN_FOOT: Double = 12.0
39+
private const val FEET_IN_MILE: Double = 5280.0
40+
41+
class ScaleBarTests {
42+
43+
@get:Rule
44+
val composeTestRule = createComposeRule()
45+
46+
private lateinit var cameraPositionState: CameraPositionState
47+
48+
private fun initScaleBar(initialZoom: Float, initialPosition: LatLng) {
49+
check(hasValidApiKey) { "Maps API key not specified" }
50+
51+
val countDownLatch = CountDownLatch(1)
52+
53+
cameraPositionState = CameraPositionState(
54+
position = CameraPosition.fromLatLngZoom(initialPosition, initialZoom)
55+
)
56+
57+
composeTestRule.setContent {
58+
Box {
59+
GoogleMap(
60+
cameraPositionState = cameraPositionState,
61+
onMapLoaded = {
62+
countDownLatch.countDown()
63+
}
64+
)
65+
ScaleBar(cameraPositionState = cameraPositionState)
66+
}
67+
}
68+
val mapLoaded = countDownLatch.await(5, TimeUnit.SECONDS)
69+
assertTrue(mapLoaded)
70+
}
71+
72+
@Test
73+
fun testScaleBarInitialState() {
74+
val initialZoom = 15f
75+
val initialPosition = LatLng(37.7749, -122.4194) // San Francisco
76+
initScaleBar(initialZoom, initialPosition)
77+
78+
composeTestRule.waitForIdle()
79+
80+
var imperialText = ""
81+
var metricText = ""
82+
83+
composeTestRule.runOnIdle {
84+
// We use a `let` block to safely handle the projection, which can be null.
85+
// If the projection is null, the test will fail explicitly, preventing
86+
// any potential NullPointerExceptions and ensuring the test is robust.
87+
val projection = cameraPositionState.projection
88+
projection?.let { proj ->
89+
val widthInDp = 65.dp
90+
val widthInPixels = widthInDp.value.toInt()
91+
92+
val upperLeftLatLng = proj.fromScreenLocation(Point(0, 0))
93+
val upperRightLatLng = proj.fromScreenLocation(Point(0, widthInPixels))
94+
val canvasWidthMeters = upperLeftLatLng.sphericalDistance(upperRightLatLng)
95+
val horizontalLineWidthMeters = (canvasWidthMeters * 8 / 9).toInt()
96+
97+
var metricUnits = "m"
98+
var metricDistance = horizontalLineWidthMeters
99+
if (horizontalLineWidthMeters > METERS_IN_KILOMETER) {
100+
metricUnits = "km"
101+
metricDistance /= METERS_IN_KILOMETER.toInt()
102+
}
103+
104+
var imperialUnits = "ft"
105+
var imperialDistance = horizontalLineWidthMeters.toDouble().toFeet()
106+
if (imperialDistance > FEET_IN_MILE) {
107+
imperialUnits = "mi"
108+
imperialDistance = imperialDistance.toMiles()
109+
}
110+
imperialText = "${imperialDistance.toInt()} $imperialUnits"
111+
metricText = "$metricDistance $metricUnits"
112+
} ?: fail("Projection should not be null")
113+
}
114+
115+
composeTestRule.onNodeWithText(
116+
text = imperialText,
117+
).assertExists()
118+
composeTestRule.onNodeWithText(
119+
text = metricText,
120+
).assertExists()
121+
}
122+
}
123+
124+
internal fun Double.toFeet(): Double {
125+
return this * CENTIMETERS_IN_METER / CENTIMETERS_IN_INCH / INCHES_IN_FOOT
126+
}
127+
128+
internal fun Double.toMiles(): Double {
129+
return this / FEET_IN_MILE
130+
}

maps-compose-widgets/src/main/java/com/google/maps/android/compose/widgets/ScaleBar.kt

Lines changed: 81 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,9 @@ import androidx.compose.material.MaterialTheme
3131
import androidx.compose.material.Text
3232
import androidx.compose.runtime.Composable
3333
import androidx.compose.runtime.LaunchedEffect
34+
import androidx.compose.runtime.derivedStateOf
3435
import androidx.compose.runtime.getValue
35-
import androidx.compose.runtime.mutableIntStateOf
3636
import androidx.compose.runtime.remember
37-
import androidx.compose.runtime.setValue
3837
import androidx.compose.ui.Alignment.Companion.End
3938
import androidx.compose.ui.Modifier
4039
import androidx.compose.ui.geometry.Offset
@@ -46,7 +45,6 @@ import androidx.compose.ui.unit.Dp
4645
import androidx.compose.ui.unit.dp
4746
import androidx.compose.ui.unit.em
4847
import androidx.compose.ui.unit.sp
49-
import com.google.android.gms.maps.model.LatLng
5048
import com.google.maps.android.compose.CameraPositionState
5149
import com.google.maps.android.ktx.utils.sphericalDistance
5250
import kotlinx.coroutines.delay
@@ -59,8 +57,13 @@ private val defaultHeight: Dp = 50.dp
5957
* A scale bar composable that shows the current scale of the map in feet and meters when zoomed in
6058
* to the map, changing to miles and kilometers, respectively, when zooming out.
6159
*
62-
* Implement your own observer on camera move events using [CameraPositionState] and pass it in
63-
* as [cameraPositionState].
60+
* @param modifier Modifier to be applied to the composable.
61+
* @param width The width of the composable.
62+
* @param height The height of the composable.
63+
* @param cameraPositionState The state of the camera position, used to calculate the scale.
64+
* @param textColor The color of the text on the scale bar.
65+
* @param lineColor The color of the lines on the scale bar.
66+
* @param shadowColor The color of the shadow behind the text and lines.
6467
*/
6568
@Composable
6669
public fun ScaleBar(
@@ -72,53 +75,76 @@ public fun ScaleBar(
7275
lineColor: Color = DarkGray,
7376
shadowColor: Color = Color.White,
7477
) {
75-
Box(
76-
modifier = modifier
77-
.size(width = width, height = height)
78-
) {
79-
var horizontalLineWidthMeters by remember {
80-
mutableIntStateOf(0)
78+
// This is the core logic for calculating the scale of the map.
79+
//
80+
// `remember` with a key (`cameraPositionState.position.zoom`) is used for performance.
81+
// It ensures that the calculation inside is only re-executed when the zoom level changes.
82+
// This is important because we don't need to recalculate the scale every time the map pans,
83+
// only when the zoom level changes.
84+
//
85+
// `derivedStateOf` is a Compose state function that creates a new state object that is
86+
// derived from other state objects. The calculation inside `derivedStateOf` is only
87+
// re-executed when one of the state objects it reads from changes. In this case, it's
88+
// `cameraPositionState.projection`. This is another performance optimization that
89+
// prevents unnecessary recalculations.
90+
val horizontalLineWidthMeters by remember(cameraPositionState.position.zoom) {
91+
derivedStateOf {
92+
// The projection is used to convert between screen coordinates (pixels) and
93+
// geographical coordinates (LatLng). It can be null if the map is not ready yet.
94+
val projection = cameraPositionState.projection ?: return@derivedStateOf 0
95+
96+
// We get the geographical coordinates of two points on the screen: the top-left
97+
// corner (0, 0) and a point to the right of it, at the width of the scale bar.
98+
val upperLeftLatLng = projection.fromScreenLocation(Point(0, 0))
99+
val upperRightLatLng =
100+
projection.fromScreenLocation(Point(0, width.value.toInt()))
101+
102+
// We then calculate the spherical distance between these two points in meters.
103+
// This gives us the distance that the scale bar represents on the map.
104+
val canvasWidthMeters = upperLeftLatLng.sphericalDistance(upperRightLatLng)
105+
106+
// We take 8/9th of the canvas width to provide some padding on the right side
107+
// of the scale bar.
108+
(canvasWidthMeters * 8 / 9).toInt()
81109
}
110+
}
82111

112+
Box(
113+
modifier = modifier.size(width = width, height = height)
114+
) {
115+
// The Canvas composable is used for custom drawing. Here, we are drawing the
116+
// lines of the scale bar.
83117
Canvas(
84118
modifier = Modifier.fillMaxSize(),
85119
onDraw = {
86-
// Get width of canvas in meters
87-
val upperLeftLatLng =
88-
cameraPositionState.projection?.fromScreenLocation(Point(0, 0))
89-
?: LatLng(0.0, 0.0)
90-
val upperRightLatLng =
91-
cameraPositionState.projection?.fromScreenLocation(Point(0, size.width.toInt()))
92-
?: LatLng(0.0, 0.0)
93-
val canvasWidthMeters = upperLeftLatLng.sphericalDistance(upperRightLatLng)
94-
val eightNinthsCanvasMeters = (canvasWidthMeters * 8 / 9).toInt()
95-
96-
horizontalLineWidthMeters = eightNinthsCanvasMeters
97-
98120
val oneNinthWidth = size.width / 9
99121
val midHeight = size.height / 2
100122
val oneThirdHeight = size.height / 3
101123
val twoThirdsHeight = size.height * 2 / 3
102124
val strokeWidth = 4f
103125
val shadowStrokeWidth = strokeWidth + 3
104126

105-
// Middle horizontal line shadow (drawn under main lines)
127+
// The shadows are drawn first, slightly offset from the main lines, to create
128+
// a "drop shadow" effect. This makes the scale bar more readable on different
129+
// map backgrounds.
130+
131+
// Middle horizontal line shadow
106132
drawLine(
107133
color = shadowColor,
108134
start = Offset(oneNinthWidth, midHeight),
109135
end = Offset(size.width, midHeight),
110136
strokeWidth = shadowStrokeWidth,
111137
cap = StrokeCap.Round
112138
)
113-
// Top vertical line shadow (drawn under main lines)
139+
// Top vertical line shadow
114140
drawLine(
115141
color = shadowColor,
116142
start = Offset(oneNinthWidth, oneThirdHeight),
117143
end = Offset(oneNinthWidth, midHeight),
118144
strokeWidth = shadowStrokeWidth,
119145
cap = StrokeCap.Round
120146
)
121-
// Bottom vertical line shadow (drawn under main lines)
147+
// Bottom vertical line shadow
122148
drawLine(
123149
color = shadowColor,
124150
start = Offset(oneNinthWidth, midHeight),
@@ -127,6 +153,8 @@ public fun ScaleBar(
127153
cap = StrokeCap.Round
128154
)
129155

156+
// These are the main lines of the scale bar.
157+
130158
// Middle horizontal line
131159
drawLine(
132160
color = lineColor,
@@ -157,6 +185,9 @@ public fun ScaleBar(
157185
modifier = Modifier.fillMaxSize(),
158186
verticalArrangement = Arrangement.SpaceAround
159187
) {
188+
// Here, we determine the appropriate units (meters/kilometers and feet/miles)
189+
// based on the calculated distance in meters.
190+
160191
var metricUnits = "m"
161192
var metricDistance = horizontalLineWidthMeters
162193
if (horizontalLineWidthMeters > METERS_IN_KILOMETER) {
@@ -173,6 +204,8 @@ public fun ScaleBar(
173204
imperialDistance = imperialDistance.toMiles()
174205
}
175206

207+
// We display the calculated distances in two Text composables, one for imperial
208+
// and one for metric units.
176209
ScaleText(
177210
modifier = Modifier.align(End),
178211
textColor = textColor,
@@ -193,8 +226,16 @@ public fun ScaleBar(
193226
* An animated scale bar that appears when the zoom level of the map changes, and then disappears
194227
* after [visibilityDurationMillis]. This composable wraps [ScaleBar] with visibility animations.
195228
*
196-
* Implement your own observer on camera move events using [CameraPositionState] and pass it in
197-
* as [cameraPositionState].
229+
* @param modifier Modifier to be applied to the composable.
230+
* @param width The width of the composable.
231+
* @param height The height of the composable.
232+
* @param cameraPositionState The state of the camera position, used to calculate the scale.
233+
* @param textColor The color of the text on the scale bar.
234+
* @param lineColor The color of the lines on the scale bar.
235+
* @param shadowColor The color of the shadow behind the text and lines.
236+
* @param visibilityDurationMillis The duration in milliseconds that the scale bar will be visible.
237+
* @param enterTransition The animation to use when the scale bar appears.
238+
* @param exitTransition The animation to use when the scale bar disappears.
198239
*/
199240
@Composable
200241
public fun DisappearingScaleBar(
@@ -213,14 +254,19 @@ public fun DisappearingScaleBar(
213254
MutableTransitionState(true)
214255
}
215256

216-
LaunchedEffect(key1 = cameraPositionState.position.zoom) {
217-
// Show ScaleBar
257+
// This effect is re-launched every time the camera position changes.
258+
//
259+
// The effect itself makes the scale bar visible, waits for the specified duration,
260+
// and then makes it invisible again. This creates the "disappearing" effect.
261+
LaunchedEffect(key1 = cameraPositionState.position) {
218262
visible.targetState = true
219263
delay(visibilityDurationMillis.toLong())
220-
// Hide ScaleBar after timeout period
221264
visible.targetState = false
222265
}
223266

267+
// `AnimatedVisibility` is a composable that animates the appearance and disappearance
268+
// of its content. We are using it here to wrap the `ScaleBar` and provide the
269+
// fade-in and fade-out animations.
224270
AnimatedVisibility(
225271
visibleState = visible,
226272
modifier = modifier,
@@ -263,15 +309,17 @@ private fun ScaleText(
263309
}
264310

265311
/**
266-
* Converts [this] value in meters to the corresponding value in feet
312+
* Converts [this] value in meters to the corresponding value in feet.
313+
* This is a utility function used for unit conversion.
267314
* @return [this] meters value converted to feet
268315
*/
269316
internal fun Double.toFeet(): Double {
270317
return this * CENTIMETERS_IN_METER / CENTIMETERS_IN_INCH / INCHES_IN_FOOT
271318
}
272319

273320
/**
274-
* Converts [this] value in feet to the corresponding value in miles
321+
* Converts [this] value in feet to the corresponding value in miles.
322+
* This is a utility function used for unit conversion.
275323
* @return [this] feet value converted to miles
276324
*/
277325
internal fun Double.toMiles(): Double {

0 commit comments

Comments
 (0)