Skip to content

Commit 3f211be

Browse files
committed
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.
1 parent df72c4c commit 3f211be

2 files changed

Lines changed: 154 additions & 30 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

maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarTests.kt

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,29 @@
1414

1515
package com.google.maps.android.compose
1616

17+
import android.graphics.Point
18+
import androidx.compose.foundation.layout.Box
1719
import androidx.compose.ui.test.junit4.createComposeRule
1820
import androidx.compose.ui.test.onNodeWithText
21+
import androidx.compose.ui.unit.dp
1922
import com.google.android.gms.maps.model.CameraPosition
2023
import com.google.android.gms.maps.model.LatLng
2124
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
2228
import org.junit.Rule
2329
import org.junit.Test
2430
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
2540

2641
class ScaleBarTests {
2742

@@ -33,13 +48,25 @@ class ScaleBarTests {
3348
private fun initScaleBar(initialZoom: Float, initialPosition: LatLng) {
3449
check(hasValidApiKey) { "Maps API key not specified" }
3550

51+
val countDownLatch = CountDownLatch(1)
52+
3653
cameraPositionState = CameraPositionState(
3754
position = CameraPosition.fromLatLngZoom(initialPosition, initialZoom)
3855
)
3956

4057
composeTestRule.setContent {
41-
ScaleBar(cameraPositionState = cameraPositionState)
58+
Box {
59+
GoogleMap(
60+
cameraPositionState = cameraPositionState,
61+
onMapLoaded = {
62+
countDownLatch.countDown()
63+
}
64+
)
65+
ScaleBar(cameraPositionState = cameraPositionState)
66+
}
4267
}
68+
val mapLoaded = countDownLatch.await(5, TimeUnit.SECONDS)
69+
assertTrue(mapLoaded)
4370
}
4471

4572
@Test
@@ -50,15 +77,54 @@ class ScaleBarTests {
5077

5178
composeTestRule.waitForIdle()
5279

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+
53115
composeTestRule.onNodeWithText(
54-
text = "ft",
55-
substring = true,
56-
ignoreCase = false
116+
text = imperialText,
57117
).assertExists()
58118
composeTestRule.onNodeWithText(
59-
text = "m",
60-
substring = true,
61-
ignoreCase = false
119+
text = metricText,
62120
).assertExists()
63121
}
64-
}
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: 80 additions & 22 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,22 +75,45 @@ public fun ScaleBar(
7275
lineColor: Color = DarkGray,
7376
shadowColor: Color = Color.White,
7477
) {
75-
var horizontalLineWidthMeters by remember {
76-
mutableIntStateOf(0)
77-
}
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
7895

79-
LaunchedEffect(key1 = cameraPositionState.position) {
80-
val upperLeftLatLng = cameraPositionState.projection?.fromScreenLocation(Point(0, 0)) ?: LatLng(0.0, 0.0)
81-
val upperRightLatLng = cameraPositionState.projection?.fromScreenLocation(Point(0, width.value.toInt())) ?: LatLng(0.0, 0.0)
82-
val canvasWidthMeters = upperLeftLatLng.sphericalDistance(upperRightLatLng)
83-
val eightNinthsCanvasMeters = (canvasWidthMeters * 8 / 9).toInt()
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()))
84101

85-
horizontalLineWidthMeters = eightNinthsCanvasMeters
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()
109+
}
86110
}
87111

88112
Box(
89113
modifier = modifier.size(width = width, height = height)
90114
) {
115+
// The Canvas composable is used for custom drawing. Here, we are drawing the
116+
// lines of the scale bar.
91117
Canvas(
92118
modifier = Modifier.fillMaxSize(),
93119
onDraw = {
@@ -98,23 +124,27 @@ public fun ScaleBar(
98124
val strokeWidth = 4f
99125
val shadowStrokeWidth = strokeWidth + 3
100126

101-
// 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
102132
drawLine(
103133
color = shadowColor,
104134
start = Offset(oneNinthWidth, midHeight),
105135
end = Offset(size.width, midHeight),
106136
strokeWidth = shadowStrokeWidth,
107137
cap = StrokeCap.Round
108138
)
109-
// Top vertical line shadow (drawn under main lines)
139+
// Top vertical line shadow
110140
drawLine(
111141
color = shadowColor,
112142
start = Offset(oneNinthWidth, oneThirdHeight),
113143
end = Offset(oneNinthWidth, midHeight),
114144
strokeWidth = shadowStrokeWidth,
115145
cap = StrokeCap.Round
116146
)
117-
// Bottom vertical line shadow (drawn under main lines)
147+
// Bottom vertical line shadow
118148
drawLine(
119149
color = shadowColor,
120150
start = Offset(oneNinthWidth, midHeight),
@@ -123,6 +153,8 @@ public fun ScaleBar(
123153
cap = StrokeCap.Round
124154
)
125155

156+
// These are the main lines of the scale bar.
157+
126158
// Middle horizontal line
127159
drawLine(
128160
color = lineColor,
@@ -153,6 +185,9 @@ public fun ScaleBar(
153185
modifier = Modifier.fillMaxSize(),
154186
verticalArrangement = Arrangement.SpaceAround
155187
) {
188+
// Here, we determine the appropriate units (meters/kilometers and feet/miles)
189+
// based on the calculated distance in meters.
190+
156191
var metricUnits = "m"
157192
var metricDistance = horizontalLineWidthMeters
158193
if (horizontalLineWidthMeters > METERS_IN_KILOMETER) {
@@ -169,6 +204,8 @@ public fun ScaleBar(
169204
imperialDistance = imperialDistance.toMiles()
170205
}
171206

207+
// We display the calculated distances in two Text composables, one for imperial
208+
// and one for metric units.
172209
ScaleText(
173210
modifier = Modifier.align(End),
174211
textColor = textColor,
@@ -189,8 +226,16 @@ public fun ScaleBar(
189226
* An animated scale bar that appears when the zoom level of the map changes, and then disappears
190227
* after [visibilityDurationMillis]. This composable wraps [ScaleBar] with visibility animations.
191228
*
192-
* Implement your own observer on camera move events using [CameraPositionState] and pass it in
193-
* 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.
194239
*/
195240
@Composable
196241
public fun DisappearingScaleBar(
@@ -208,13 +253,24 @@ public fun DisappearingScaleBar(
208253
val visible = remember {
209254
MutableTransitionState(true)
210255
}
211-
// This effect controls visibility, not data updates
256+
257+
// `LaunchedEffect` is a coroutine-based effect that is launched when the composable
258+
// enters the composition. The `key1` parameter is used to re-launch the effect
259+
// whenever the value of the key changes. In this case, we are using
260+
// `cameraPositionState.position` as the key, so the effect will be re-launched
261+
// every time the camera position changes.
262+
//
263+
// The effect itself makes the scale bar visible, waits for the specified duration,
264+
// and then makes it invisible again. This creates the "disappearing" effect.
212265
LaunchedEffect(key1 = cameraPositionState.position) {
213266
visible.targetState = true
214267
delay(visibilityDurationMillis.toLong())
215268
visible.targetState = false
216269
}
217270

271+
// `AnimatedVisibility` is a composable that animates the appearance and disappearance
272+
// of its content. We are using it here to wrap the `ScaleBar` and provide the
273+
// fade-in and fade-out animations.
218274
AnimatedVisibility(
219275
visibleState = visible,
220276
modifier = modifier,
@@ -257,15 +313,17 @@ private fun ScaleText(
257313
}
258314

259315
/**
260-
* Converts [this] value in meters to the corresponding value in feet
316+
* Converts [this] value in meters to the corresponding value in feet.
317+
* This is a utility function used for unit conversion.
261318
* @return [this] meters value converted to feet
262319
*/
263320
internal fun Double.toFeet(): Double {
264321
return this * CENTIMETERS_IN_METER / CENTIMETERS_IN_INCH / INCHES_IN_FOOT
265322
}
266323

267324
/**
268-
* Converts [this] value in feet to the corresponding value in miles
325+
* Converts [this] value in feet to the corresponding value in miles.
326+
* This is a utility function used for unit conversion.
269327
* @return [this] feet value converted to miles
270328
*/
271329
internal fun Double.toMiles(): Double {

0 commit comments

Comments
 (0)