Skip to content

Commit 686774f

Browse files
authored
feat(library): Add polyline progress utilities to SphericalUtil (#1588)
* feat(library): Add polyline progress utilities to SphericalUtil This commit introduces two new utility functions to `SphericalUtil` for calculating points and prefixes on a polyline based on a percentage of its total length. A new demo has also been added to showcase this functionality. The key changes are: - **`SphericalUtil.getPointOnPolyline()`**: A new function that returns a `LatLng` at a specified percentage along a given polyline. - **`SphericalUtil.getPolylinePrefix()`**: A new function that returns a new list of `LatLng`s representing a prefix of the original polyline up to a specified percentage. - **New Demo**: A `PolylineProgressDemoActivity` has been added to the demo application. It demonstrates how to animate progress along a polyline using the new utility functions, complete with a `SeekBar` for user control. - **Tests**: Added comprehensive unit tests for `getPointOnPolyline` and `getPolylinePrefix` to ensure correctness and handle edge cases. * refactor(demo): Modernize PolylineProgressDemoActivity This commit significantly refactors the `PolylineProgressDemoActivity` to align with modern Android development practices and better showcase the library's features. Key changes include: - **View Binding**: Replaced `findViewById` with View Binding for type-safe and more concise access to UI components. This required enabling `viewBinding` in the demo's `build.gradle.kts`. - **Lifecycle-Aware Coroutines**: The animation now uses `lifecycleScope`, ensuring the coroutine is automatically canceled when the Activity is destroyed, preventing memory leaks. - **State Management**: Replaced multiple `MutableLiveData` instances with a single `LiveData<AnimationState>` data class. This creates a single source of truth for the animation's state, leading to more predictable and maintainable UI updates. - **Code Structure and Documentation**: The code has been reorganized into smaller, more focused functions. Extensive KDoc comments have been added to explain the implementation, highlight the use of `SphericalUtil`, and document the modern Android patterns being used. * Add missing copyright header * chore: Add missing copyright headers
1 parent 77fecaa commit 686774f

File tree

10 files changed

+481
-0
lines changed

10 files changed

+481
-0
lines changed

demo/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ android {
4444
}
4545
}
4646

47+
buildFeatures {
48+
viewBinding = true
49+
}
50+
4751
kotlinOptions {
4852
jvmTarget = "17"
4953
}

demo/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@
7070
<activity
7171
android:name=".PolySimplifyDemoActivity"
7272
android:exported="true" />
73+
<activity
74+
android:name=".PolylineProgressDemoActivity"
75+
android:exported="true" />
7376
<activity
7477
android:name=".IconGeneratorDemoActivity"
7578
android:exported="true" />

demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ protected void onCreate(Bundle savedInstanceState) {
7777
addDemo("Clustering: Force on Zoom", ZoomClusteringDemoActivity.class);
7878
addDemo("PolyUtil.decode", PolyDecodeDemoActivity.class);
7979
addDemo("PolyUtil.simplify", PolySimplifyDemoActivity.class);
80+
addDemo("Polyline Progress", PolylineProgressDemoActivity.class);
8081
addDemo("IconGenerator", IconGeneratorDemoActivity.class);
8182
addDemo("SphericalUtil.computeDistanceBetween", DistanceDemoActivity.class);
8283
addDemo("Generating tiles", TileProviderAndProjectionDemo.class);

demo/src/main/java/com/google/maps/android/utils/demo/PolySimplifyDemoActivity.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
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.
114
/*
215
* Copyright 2015 Sean J. Barbeau
316
*
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.maps.android.utils.demo
18+
19+
import android.graphics.Canvas
20+
import android.graphics.Color
21+
import android.view.ViewGroup
22+
import android.widget.SeekBar
23+
import androidx.core.content.ContextCompat
24+
import androidx.core.graphics.createBitmap
25+
import androidx.core.graphics.toColorInt
26+
import androidx.lifecycle.MutableLiveData
27+
import androidx.lifecycle.lifecycleScope
28+
import com.google.android.gms.maps.CameraUpdateFactory
29+
import com.google.android.gms.maps.model.BitmapDescriptor
30+
import com.google.android.gms.maps.model.BitmapDescriptorFactory
31+
import com.google.android.gms.maps.model.LatLng
32+
import com.google.android.gms.maps.model.LatLngBounds
33+
import com.google.android.gms.maps.model.Marker
34+
import com.google.android.gms.maps.model.MarkerOptions
35+
import com.google.android.gms.maps.model.Polyline
36+
import com.google.android.gms.maps.model.PolylineOptions
37+
import com.google.maps.android.SphericalUtil
38+
import com.google.maps.android.utils.demo.databinding.ActivityPolylineProgressDemoBinding
39+
import kotlinx.coroutines.Job
40+
import kotlinx.coroutines.delay
41+
import kotlinx.coroutines.launch
42+
43+
/**
44+
* This demo showcases how to animate a marker along a geodesic polyline, illustrating
45+
* key features of the Android Maps Utils library and modern Android development practices.
46+
*/
47+
class PolylineProgressDemoActivity : BaseDemoActivity(), SeekBar.OnSeekBarChangeListener {
48+
49+
companion object {
50+
private const val POLYLINE_WIDTH = 15f
51+
private const val PROGRESS_POLYLINE_WIDTH = 7f
52+
private const val ANIMATION_STEP_SIZE = 1
53+
private const val ANIMATION_DELAY_MS = 75L
54+
}
55+
56+
private lateinit var binding: ActivityPolylineProgressDemoBinding
57+
private lateinit var originalPolyline: Polyline
58+
private var progressPolyline: Polyline? = null
59+
private var progressMarker: Marker? = null
60+
61+
private val planeIcon: BitmapDescriptor by lazy {
62+
bitmapDescriptorFromVector(R.drawable.baseline_airplanemode_active_24, "#FFD700".toColorInt())
63+
}
64+
65+
private data class AnimationState(val progress: Int, val direction: Int)
66+
67+
private val animationState = MutableLiveData<AnimationState>()
68+
private var animationJob: Job? = null
69+
70+
private val polylinePoints = listOf(
71+
LatLng(40.7128, -74.0060), // New York
72+
LatLng(47.6062, -122.3321), // Seattle
73+
LatLng(39.7392, -104.9903), // Denver
74+
LatLng(37.7749, -122.4194), // San Francisco
75+
LatLng(34.0522, -118.2437), // Los Angeles
76+
LatLng(41.8781, -87.6298), // Chicago
77+
LatLng(29.7604, -95.3698), // Houston
78+
LatLng(39.9526, -75.1652) // Philadelphia
79+
)
80+
81+
override fun getLayoutId(): Int = R.layout.activity_polyline_progress_demo
82+
83+
/**
84+
* This is where the demo begins. It is called from the base activity's `onMapReady` callback.
85+
*/
86+
override fun startDemo(isRestore: Boolean) {
87+
// The layout is already inflated by the base class. We can now bind to it.
88+
val rootView = (findViewById<ViewGroup>(android.R.id.content)).getChildAt(0)
89+
binding = ActivityPolylineProgressDemoBinding.bind(rootView)
90+
91+
setupMap()
92+
setupUI()
93+
// Set the initial state. The observer in setupUI will handle the first UI update.
94+
animationState.value = AnimationState(progress = 0, direction = 1)
95+
startAnimation()
96+
}
97+
98+
private fun setupMap() {
99+
originalPolyline = map.addPolyline(
100+
PolylineOptions()
101+
.addAll(polylinePoints)
102+
.color(Color.GRAY)
103+
.width(POLYLINE_WIDTH)
104+
.geodesic(true) // A geodesic polyline follows the curvature of the Earth.
105+
)
106+
107+
val bounds = LatLngBounds.builder().apply {
108+
polylinePoints.forEach { include(it) }
109+
}.build()
110+
map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 100))
111+
}
112+
113+
private fun setupUI() {
114+
binding.seekBar.setOnSeekBarChangeListener(this)
115+
binding.resetButton.setOnClickListener {
116+
stopAnimation()
117+
animationState.value = AnimationState(progress = 0, direction = 1)
118+
startAnimation()
119+
}
120+
binding.pauseButton.setOnClickListener {
121+
if (animationJob?.isActive == true) {
122+
stopAnimation()
123+
} else {
124+
startAnimation()
125+
}
126+
}
127+
128+
animationState.observe(this) { state ->
129+
binding.seekBar.progress = state.progress
130+
binding.percentageTextView.text = getString(R.string.percentage_format, state.progress)
131+
updateProgressOnMap(state.progress / 100.0, state.direction)
132+
}
133+
}
134+
135+
private fun startAnimation() {
136+
stopAnimation()
137+
val currentState = animationState.value ?: return
138+
139+
animationJob = lifecycleScope.launch {
140+
var progress = currentState.progress
141+
var direction = currentState.direction
142+
while (true) {
143+
progress = when {
144+
progress > 100 -> {
145+
direction = -1
146+
100
147+
}
148+
progress < 0 -> {
149+
direction = 1
150+
0
151+
}
152+
else -> progress + direction * ANIMATION_STEP_SIZE
153+
}
154+
155+
animationState.postValue(AnimationState(progress, direction))
156+
delay(ANIMATION_DELAY_MS)
157+
}
158+
}
159+
}
160+
161+
private fun stopAnimation() {
162+
animationJob?.cancel()
163+
animationJob = null
164+
}
165+
166+
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
167+
if (fromUser) {
168+
stopAnimation()
169+
animationState.value = AnimationState(progress, animationState.value?.direction ?: 1)
170+
}
171+
}
172+
173+
override fun onStartTrackingTouch(seekBar: SeekBar?) { /* No-op */ }
174+
175+
override fun onStopTrackingTouch(seekBar: SeekBar?) { /* No-op */ }
176+
177+
private fun updateProgressOnMap(percentage: Double, direction: Int) {
178+
progressPolyline?.remove()
179+
180+
val prefix = SphericalUtil.getPolylinePrefix(polylinePoints, percentage)
181+
if (prefix.isNotEmpty()) {
182+
progressPolyline = map.addPolyline(
183+
PolylineOptions()
184+
.addAll(prefix)
185+
.color(Color.BLUE)
186+
.width(PROGRESS_POLYLINE_WIDTH)
187+
.zIndex(1f)
188+
.geodesic(true)
189+
)
190+
}
191+
192+
SphericalUtil.getPointOnPolyline(polylinePoints, percentage)?.let { point ->
193+
updateMarker(point, percentage, direction)
194+
}
195+
}
196+
197+
private fun updateMarker(point: LatLng, percentage: Double, direction: Int) {
198+
val heading = SphericalUtil.getPointOnPolyline(polylinePoints, percentage + 0.0001)
199+
?.let { SphericalUtil.computeHeading(point, it) }
200+
?.let { if (direction == -1) it + 180 else it } // Adjust for reverse direction.
201+
202+
if (progressMarker == null) {
203+
progressMarker = map.addMarker(
204+
MarkerOptions()
205+
.position(point)
206+
.flat(true)
207+
.draggable(false)
208+
.icon(planeIcon)
209+
.apply { heading?.let { rotation(it.toFloat()) } }
210+
)
211+
} else {
212+
progressMarker?.also {
213+
it.position = point
214+
heading?.let { newHeading -> it.rotation = newHeading.toFloat() }
215+
}
216+
}
217+
}
218+
219+
private fun bitmapDescriptorFromVector(vectorResId: Int, color: Int): BitmapDescriptor {
220+
val vectorDrawable = ContextCompat.getDrawable(this, vectorResId)!!
221+
vectorDrawable.setTint(color)
222+
val bitmap = createBitmap(
223+
vectorDrawable.intrinsicWidth,
224+
vectorDrawable.intrinsicHeight
225+
)
226+
val canvas = Canvas(bitmap)
227+
vectorDrawable.setBounds(0, 0, canvas.width, canvas.height)
228+
vectorDrawable.draw(canvas)
229+
return BitmapDescriptorFactory.fromBitmap(bitmap)
230+
}
231+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
Copyright 2025 Google LLC
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
-->
17+
18+
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
19+
20+
<path android:fillColor="@android:color/white" android:pathData="M22,16v-2l-8.5,-5V3.5C13.5,2.67 12.83,2 12,2s-1.5,0.67 -1.5,1.5V9L2,14v2l8.5,-2.5V19L8,20.5L8,22l4,-1l4,1l0,-1.5L13.5,19v-5.5L22,16z"/>
21+
22+
</vector>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
Copyright 2025 Google LLC
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
-->
17+
18+
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
19+
android:layout_width="match_parent"
20+
android:layout_height="match_parent"
21+
android:orientation="vertical">
22+
23+
<RelativeLayout
24+
android:layout_width="match_parent"
25+
android:layout_height="wrap_content"
26+
android:layout_marginVertical="24dp"
27+
android:layout_marginHorizontal="16dp">
28+
29+
<SeekBar
30+
android:id="@+id/seekBar"
31+
android:layout_width="match_parent"
32+
android:layout_height="wrap_content"
33+
android:layout_toStartOf="@+id/percentageTextView"
34+
android:layout_centerVertical="true"
35+
android:max="100" />
36+
37+
<TextView
38+
android:id="@+id/percentageTextView"
39+
android:layout_width="wrap_content"
40+
android:layout_height="wrap_content"
41+
android:layout_alignParentEnd="true"
42+
android:layout_centerVertical="true"
43+
android:text="0%%"
44+
android:minWidth="40dp"
45+
android:gravity="center"
46+
/>
47+
</RelativeLayout>
48+
49+
<LinearLayout
50+
android:layout_width="match_parent"
51+
android:layout_height="wrap_content"
52+
android:orientation="horizontal">
53+
54+
<Button
55+
android:id="@+id/resetButton"
56+
android:layout_width="0dp"
57+
android:layout_height="wrap_content"
58+
android:layout_marginHorizontal="16dp"
59+
android:layout_weight="1"
60+
android:text="Restart" />
61+
62+
<Button
63+
android:id="@+id/pauseButton"
64+
android:layout_width="0dp"
65+
android:layout_marginHorizontal="16dp"
66+
android:layout_height="wrap_content"
67+
android:layout_weight="1"
68+
android:text="Pause" />
69+
70+
</LinearLayout>
71+
72+
<fragment
73+
android:id="@+id/map"
74+
class="com.google.android.gms.maps.SupportMapFragment"
75+
android:layout_width="match_parent"
76+
android:layout_height="0dp"
77+
android:layout_weight="1" />
78+
79+
80+
</LinearLayout>

demo/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,5 @@
3434
<string name="button_gradient">Gradient</string>
3535
<string name="button_opacity">Opacity</string>
3636
<string name="bad_maps_api_key">The Google Maps API key looks invalid or is missing. If you are sure your key is correct, you can remove this check from the demo.</string>
37+
<string name="percentage_format">%1$d%%</string>
3738
</resources>

0 commit comments

Comments
 (0)