Skip to content

refactor(widgets): use android-maps-utils Units API in ScaleBar - #1072

Open
dkhawk wants to merge 2 commits into
mainfrom
feat/scale-bar-units
Open

dkhawk wants to merge 2 commits into
mainfrom
feat/scale-bar-units

Conversation

@dkhawk

@dkhawk dkhawk commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Note: Stacked on #1073.

Summary

  1. Adopt the android-maps-utils Units API in ScaleBar:

    • Replaces manual meter/foot/kilometer/mile string formatting in ScaleBar with MetricUnitsConverter, ImperialUnitsConverter(milesThreshold = 1.miles), NumberFormatSymbols.COMPACT, and Length.toDistanceString(...).
    • Uses a 1.miles (5,280 ft) threshold so the imperial scale bar transitions from feet to miles at 1 mile, matching the metric transition from meters to kilometers at 1 km (1,000 m).
  2. Fix ScaleBar ground distance calculation to measure at the center of the map viewport (measureAtCenter = true):

    • The problem: Previously, calculateDistance computed the ground distance between Point(0, 0) and Point(widthInPixels, 0) at the top-left corner of the map viewport, and only recomputed when cameraPositionState.position.zoom changed.
    • Why that matters (Web Mercator distortion): Google Maps uses a conformal Web Mercator projection (EPSG:3857), which stretches map scale isotropically in both latitude (North–South) and longitude (East–West) by a factor of 1 / cos(latitude) as you move toward the poles. Because of this stretching, the ground distance per pixel at the top corner of the screen can differ dramatically from the center of the screen where the user is looking.
      • For example, when the map is centered on Iceland (~65° N) at zoom = 3.15, the ScaleBar width at the center of the screen spans the full coast-to-coast width of Iceland (429 km / 267 mi). However, the top corner of the screen (Point(0, 0)) lies up in northern Greenland (~82° N), causing the top-corner calculation to report only 140 km / 87 mi (over 3x smaller than the scale at the center of the map).
    • The fix:
      • By default (measureAtCenter: Boolean = true), ScaleBar and DisappearingScaleBar now project cameraPositionState.position.target to screen coordinates and sample the horizontal pixel span across the center of the viewport (centerPoint.y).
      • Observes cameraPositionState.position, cameraPositionState.isMoving, and LocalConfiguration.current inside derivedStateOf so the scale bar updates accurately across latitude panning at constant zoom, map bearing rotation, camera tilt, and device orientation changes.
      • Adds measureAtCenter: Boolean = true to ScaleBar and DisappearingScaleBar so callers can preserve the legacy top-corner behavior (measureAtCenter = false) if desired.
  3. Interactive visual verification in ScaleBarActivity:

    • Draws three sets of crossed East–West (red) and North–South (blue) reference polylines at 10%, 50%, and 90% of the viewport height, where each line's ground length on Earth equals the exact distance reported by the ScaleBar.
    • You can see the Web Mercator stretching as you move north (stretching to a similar degree in both latitude and longitude, as intended for a conformal projection), while the center crosshairs match the exact width of the ScaleBar.
    • Includes a semi-transparent overlay box with checkboxes to toggle "Measure at center" (new center-target behavior vs. legacy top-corner behavior) and "Show crosshairs".

Testing

  • Connected Instrumentation Tests (:maps-app:connectedDebugAndroidTest & :maps-compose-widgets:connectedDebugAndroidTest):
    • ScaleBarTests: Verifies initial state, fractional kilometers (< 10 km), fractional miles (< 10 mi), latitude changes at constant zoom (Equator vs. Iceland), map bearing rotation (0°, 45°, 90°, 180°, 270°), in-place and full-Activity device rotation (Portrait <-> Landscape), and legacy top-corner measurement (measureAtCenter = false).
    • ScaleBarUnitTest: Verifies calculateDistance centered at target, legacy top-left fallback, and portrait-to-landscape viewport rotation.
  • Unit Tests (:maps-app:testDebugUnitTest):
    • ScaleBarRenderingTests: Verifies formatMetricDistance and formatImperialDistance across meters/kilometers and feet/miles thresholds.

@dkhawk dkhawk changed the title refactor(widgets): use android-maps-utils 7.0.0 Units API in ScaleBar feat!: raise minSdk to 24 and use android-maps-utils 7.0.0 Units API in ScaleBar Nov 3, 2026
@dkhawk
dkhawk force-pushed the feat/scale-bar-units branch from 61326dc to d1f772a Compare November 3, 2026 21:10
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Coverage (unit tests)

Compared against d8d77e9 on main (#1076).

Module Line % Change Branch % Change
maps-compose 15.27% no change 3.76% no change
maps-compose-utils 6.12% no change 4.39% no change
maps-compose-widgets 3.87% 🔻 -7.01 0.00% 🔻 -11.11
TOTAL 12.60% 🔻 -0.39 3.76% 🔻 -0.35

Line and branch coverage from unit test reports. History is recorded on the coverage-history branch after each merge to main.

@dkhawk
dkhawk force-pushed the feat/scale-bar-units branch from d1f772a to 4b87319 Compare November 3, 2026 21:39
@dkhawk dkhawk changed the title feat!: raise minSdk to 24 and use android-maps-utils 7.0.0 Units API in ScaleBar refactor(widgets): use android-maps-utils 7.0.0 Units API in ScaleBar Nov 3, 2026
@dkhawk
dkhawk changed the base branch from main to feat/maps-utils-7.0.0 November 3, 2026 21:42
@dkhawk dkhawk changed the title refactor(widgets): use android-maps-utils 7.0.0 Units API in ScaleBar refactor(widgets): use android-maps-utils Units API in ScaleBar Nov 3, 2026
@dkhawk
dkhawk force-pushed the feat/scale-bar-units branch from 4b87319 to 12eb5aa Compare November 3, 2026 21:52
@dkhawk
dkhawk added this pull request to stack #1074 November 3, 2026 21:52
@dkhawk
dkhawk marked this pull request as ready for review November 3, 2026 21:53
@dkhawk
dkhawk requested a review from kikoso November 3, 2026 21:54
@dkhawk
dkhawk force-pushed the feat/scale-bar-units branch from 12eb5aa to ae29c41 Compare November 4, 2026 00:02
Base automatically changed from feat/maps-utils-7.0.0 to main November 4, 2026 07:31
@kikoso
kikoso force-pushed the feat/scale-bar-units branch from ae29c41 to c9b2c1f Compare November 4, 2026 07:31
@googlemaps-bot

googlemaps-bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Coverage (instrumentation tests)

Overall Project 45.97% -0.73% 🍏
Files changed 76.38% 🍏

Module Coverage
maps-app 80.28% -17.66% ❌
debug 28.54% -0.54% 🍏
Files
Module File Coverage
maps-app ScaleBar.kt 80.28% -17.66% ❌
debug ScaleBarActivity.kt 89.96% -8.37% 🍏

textColor: Color = DarkGray,
lineColor: Color = DarkGray,
shadowColor: Color = Color.White,
measureAtCenter: Boolean = true,

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.

Adding measureAtCenter changes the JVM signature of both ScaleBar and DisappearingScaleBar (the old ones are gone from maps-compose-widgets.api), so an app or library compiled against 9.x would fail with NoSuchMethodError at runtime. Could we keep the previous signatures as @Deprecated(level = DeprecationLevel.HIDDEN) overloads that forward to the new ones? That keeps the change additive, as AGENTS.md asks.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch! Fixed in f4607747 — added @Deprecated("Retained for binary compatibility", level = DeprecationLevel.HIDDEN) overloads for both ScaleBar and DisappearingScaleBar that delegate to the new functions with measureAtCenter = true, restoring ScaleBar-7pV8VcA and DisappearingScaleBar-JXI6SxA in maps-compose-widgets.api.

// Read both position and isMoving so the scale updates whenever the camera pans,
// zooms, rotates, tilts, or finishes an animation/gesture.
val position = cameraPositionState.position
cameraPositionState.isMoving

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.

Is there a reason why we read isMoving here? position already changes on every camera update, so I think this only adds one more recalculation when the gesture ends, with the same projection.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good point! Fixed in f4607747 — cameraPositionState.position already updates on every camera movement frame, so reading isMoving only triggered an extra recalculation when the gesture ended. Removed cameraPositionState.isMoving from derivedStateOf in both ScaleBar.kt and ScaleBarActivity.kt.

<activity android:name="androidx.activity.ComponentActivity" />
<activity
android:name="androidx.activity.ComponentActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize" />

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.

This applies to every test in maps-app that uses createComposeRule(), not only the scale bar ones: none of them will be recreated on rotation anymore. Could we use a dedicated test activity with these configChanges for the in-place rotation test instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f4607747 — restored <activity android:name="androidx.activity.ComponentActivity" /> without configChanges in AndroidManifest.xml, and added a dedicated InPlaceRotationTestActivity with android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize" used only by ScaleBarTests.

}

@Test
public fun testScaleBarCalculationAfterDeviceRotationToLandscape() {

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.

nit: both orientations return the same LatLngs from the mock, so the results are equal by construction. I think this mostly repeats testScaleBarCalculationAtTargetCenter with different points. Should we drop it, or make the landscape projection return a different span?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f4607747 — updated testScaleBarCalculationAfterDeviceRotationToLandscape so the portrait and landscape screen coordinates in the mock Projection return distinct LatLng spans, verifying that calculateDistance queries the updated landscapeCenter coordinates and produces the corresponding landscape span.

@kikoso kikoso left a comment

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.

Can you take a look at the comments, @dkhawk ? Since this adds a public parameter and changes where the scale is measured by default, should we title it feat(widgets): instead of refactor? Otherwise release-please won't list it.

Adopt the Units API from android-maps-utils (MetricUnitsConverter, ImperialUnitsConverter with a 1-mile threshold, NumberFormatSymbols.COMPACT, and toDistanceString) for formatting metric and imperial distances in ScaleBar.

Also fix a pre-existing accuracy bug in ScaleBar where the ground distance was previously calculated between Point(0, 0) and Point(widthInPixels, 0) at the top-left corner of the viewport and only recomputed when cameraPositionState.position.zoom changed:

- Because Google Maps uses a conformal Web Mercator projection (EPSG:3857), map scale stretches isotropically in both latitude (North-South) and longitude (East-West) by a factor of 1 / cos(latitude) as you move toward the poles. Measuring at the top corner of the screen rather than the center of the map caused the scale bar to reflect the scale at the top edge of the viewport instead of where the camera is focused (for example, when centered on Iceland at zoom 3.15, the top edge of the screen lies in northern Greenland near 82N, causing the top-corner calculation to report 140 km / 87 mi instead of the 429 km / 267 mi width at Iceland).

- Measure the scale across the camera target (cameraPositionState.position.target) at the center of the viewport by default, and observe cameraPositionState.position, cameraPositionState.isMoving, and LocalConfiguration.current so the scale bar stays accurate across latitude panning, map bearing rotation, camera tilt, and device orientation changes.

- Add a measureAtCenter: Boolean = true parameter to ScaleBar and DisappearingScaleBar so callers can preserve the legacy top-corner behavior (measureAtCenter = false) if desired.

- Update ScaleBarActivity with three sets of East-West and North-South reference crosshairs (at 10%, 50%, and 90% of the viewport height) whose ground lengths match the ScaleBar distance, plus UI checkboxes to toggle 'Measure at center' and 'Show crosshairs'.

- Add unit and instrumentation tests covering Units API formatting, latitude changes at constant zoom, map bearing rotation, device rotation, and legacy top-corner measurement.
…lity and tests

- Add @deprecated(level = DeprecationLevel.HIDDEN) overloads for ScaleBar and DisappearingScaleBar to preserve binary compatibility with pre-existing callers
- Remove redundant cameraPositionState.isMoving reads from derivedStateOf blocks in ScaleBar and ScaleBarActivity
- Restore generic ComponentActivity in AndroidManifest.xml and add dedicated InPlaceRotationTestActivity for ScaleBarTests
- Use distinct portrait and landscape screen coordinates in ScaleBarUnitTest mock Projection
- Migrate ScaleBarActivity imports from material to material3 to resolve UsingMaterialAndMaterial3Libraries lint warnings
@dkhawk
dkhawk force-pushed the feat/scale-bar-units branch from f460774 to 1b7b62f Compare November 4, 2026 23:04

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants