Skip to content

Commit 227b9d0

Browse files
committed
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.
1 parent 31937c9 commit 227b9d0

7 files changed

Lines changed: 434 additions & 0 deletions

File tree

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);
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
package com.google.maps.android.utils.demo
2+
3+
import android.graphics.Canvas
4+
import android.graphics.Color
5+
import android.os.Bundle
6+
import android.widget.Button
7+
import android.widget.SeekBar
8+
import android.widget.TextView
9+
import androidx.appcompat.app.AppCompatActivity
10+
import androidx.core.content.ContextCompat
11+
import androidx.lifecycle.MutableLiveData
12+
import com.google.android.gms.maps.CameraUpdateFactory
13+
import com.google.android.gms.maps.GoogleMap
14+
import com.google.android.gms.maps.OnMapReadyCallback
15+
import com.google.android.gms.maps.SupportMapFragment
16+
import com.google.android.gms.maps.model.BitmapDescriptor
17+
import com.google.android.gms.maps.model.BitmapDescriptorFactory
18+
import com.google.android.gms.maps.model.LatLng
19+
import com.google.android.gms.maps.model.Marker
20+
import com.google.android.gms.maps.model.MarkerOptions
21+
import com.google.android.gms.maps.model.Polyline
22+
import com.google.android.gms.maps.model.PolylineOptions
23+
import com.google.maps.android.SphericalUtil
24+
import kotlinx.coroutines.CoroutineScope
25+
import kotlinx.coroutines.Dispatchers
26+
import kotlinx.coroutines.Job
27+
import kotlinx.coroutines.delay
28+
import kotlinx.coroutines.launch
29+
import androidx.core.graphics.createBitmap
30+
import androidx.core.graphics.toColorInt
31+
32+
class PolylineProgressDemoActivity : AppCompatActivity(), OnMapReadyCallback, SeekBar.OnSeekBarChangeListener {
33+
34+
private var planeIcon: BitmapDescriptor? = null
35+
private lateinit var map: GoogleMap
36+
private lateinit var originalPolyline: Polyline
37+
private var progressPolyline: Polyline? = null
38+
private var progressMarker: Marker? = null
39+
40+
private lateinit var seekBar: SeekBar
41+
private lateinit var percentageTextView: TextView
42+
43+
private var isReady = false
44+
private var animationJob: Job? = null
45+
46+
private val progress = MutableLiveData<Int>()
47+
private val direction = MutableLiveData<Int>()
48+
private val stepSize = 1
49+
50+
private val polylinePoints = listOf(
51+
LatLng(40.7128, -74.0060), // New York
52+
LatLng(34.0522, -118.2437), // Los Angeles
53+
LatLng(41.8781, -87.6298), // Chicago
54+
LatLng(29.7604, -95.3698), // Houston
55+
LatLng(39.9526, -75.1652) // Philadelphia
56+
)
57+
58+
override fun onCreate(savedInstanceState: Bundle?) {
59+
super.onCreate(savedInstanceState)
60+
isReady = false
61+
setContentView(R.layout.activity_polyline_progress_demo)
62+
63+
val mapFragment = supportFragmentManager.findFragmentById(R.id.map) as SupportMapFragment
64+
mapFragment.getMapAsync(this)
65+
66+
progress.value = 0
67+
direction.value = 1
68+
}
69+
70+
override fun onMapReady(googleMap: GoogleMap) {
71+
map = googleMap
72+
isReady = true
73+
74+
planeIcon = bitmapDescriptorFromVector(R.drawable.baseline_airplanemode_active_24, "#FFD700".toColorInt())
75+
76+
originalPolyline = map.addPolyline(
77+
PolylineOptions()
78+
.addAll(polylinePoints)
79+
.color(Color.GRAY)
80+
.width(15f) // Set the width of the base polyline
81+
.geodesic(true) // Be sure to set the geodesic flag!
82+
)
83+
84+
val bounds = with(com.google.android.gms.maps.model.LatLngBounds.Builder()) {
85+
for (point in polylinePoints) {
86+
include(point)
87+
}
88+
build()
89+
}
90+
91+
map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 100))
92+
93+
percentageTextView = findViewById(R.id.percentageTextView)
94+
seekBar = findViewById<SeekBar>(R.id.seekBar).also {
95+
it.setOnSeekBarChangeListener(this)
96+
it.progress = 0
97+
}
98+
99+
findViewById<Button>(R.id.resetButton).setOnClickListener {
100+
if (animationJob != null) {
101+
animationJob?.cancel()
102+
animationJob = null
103+
}
104+
direction.value = 1
105+
progress.value = 0
106+
startAnimation()
107+
}
108+
109+
findViewById<Button>(R.id.pauseButton).setOnClickListener {
110+
if (animationJob != null) {
111+
animationJob?.cancel()
112+
animationJob = null
113+
} else {
114+
startAnimation()
115+
}
116+
}
117+
118+
progress.observe(this) {
119+
seekBar.progress = it
120+
percentageTextView.text = "$it%"
121+
updateProgress(it / 100.0)
122+
}
123+
124+
startAnimation()
125+
}
126+
127+
private fun startAnimation() {
128+
stopAnimation() // Stop any existing animation first
129+
130+
// Start a coroutine to animate the polyline progress
131+
animationJob = CoroutineScope(Dispatchers.Main).launch {
132+
while (true) {
133+
val currentProgress = progress.value ?: 0
134+
val currentDirection = direction.value ?: 1
135+
var nextProgress = currentProgress + currentDirection * stepSize
136+
if (nextProgress >= 100) {
137+
nextProgress = 100
138+
direction.value = -1
139+
} else if (nextProgress <= 0) {
140+
nextProgress = 0
141+
direction.value = 1
142+
}
143+
progress.value = nextProgress
144+
delay(50)
145+
}
146+
}
147+
}
148+
149+
private fun stopAnimation() {
150+
animationJob?.cancel()
151+
animationJob = null
152+
}
153+
154+
override fun onPause() {
155+
super.onPause()
156+
animationJob?.cancel()
157+
}
158+
159+
override fun onResume() {
160+
super.onResume()
161+
startAnimation()
162+
}
163+
164+
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
165+
if (fromUser) {
166+
stopAnimation()
167+
this.progress.value = progress
168+
}
169+
}
170+
171+
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
172+
173+
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
174+
175+
private fun updateProgress(percentage: Double) {
176+
if (!isReady) return
177+
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(7f) // Set the width of the progress polyline
187+
.zIndex(1f) // Set the z-index to draw it on top
188+
.geodesic(true) // Be sure to set the geodesic flag!
189+
)
190+
}
191+
192+
val point = SphericalUtil.getPointOnPolyline(polylinePoints, percentage)
193+
if (point != null) {
194+
// Get the next point to calculate the heading
195+
val nextPoint = SphericalUtil.getPointOnPolyline(polylinePoints, percentage + 0.0001)
196+
var heading = nextPoint?.let { SphericalUtil.computeHeading(point, it) }
197+
if (direction.value == -1) {
198+
heading = heading?.plus(180)
199+
}
200+
201+
if (progressMarker == null) {
202+
progressMarker = map.addMarker(
203+
MarkerOptions()
204+
.position(point)
205+
.flat(true)
206+
.draggable(false)
207+
.also {
208+
if (heading != null) {
209+
it.rotation(heading.toFloat())
210+
}
211+
if (planeIcon != null) {
212+
it.icon(planeIcon)
213+
}
214+
}
215+
)
216+
} else {
217+
progressMarker?.position = point
218+
if (heading != null) {
219+
progressMarker?.rotation = heading.toFloat()
220+
}
221+
}
222+
}
223+
}
224+
225+
private fun bitmapDescriptorFromVector(vectorResId: Int, color: Int): BitmapDescriptor? {
226+
return ContextCompat.getDrawable(this, vectorResId)?.run {
227+
setTint(color)
228+
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
229+
val bitmap = createBitmap(intrinsicWidth, intrinsicHeight)
230+
val canvas = Canvas(bitmap)
231+
draw(canvas)
232+
BitmapDescriptorFactory.fromBitmap(bitmap)
233+
}
234+
}
235+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<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">
2+
3+
<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"/>
4+
5+
</vector>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:layout_width="match_parent"
4+
android:layout_height="match_parent"
5+
android:orientation="vertical">
6+
7+
<RelativeLayout
8+
android:layout_width="match_parent"
9+
android:layout_height="wrap_content"
10+
android:layout_marginVertical="24dp"
11+
android:layout_marginHorizontal="16dp">
12+
13+
<SeekBar
14+
android:id="@+id/seekBar"
15+
android:layout_width="match_parent"
16+
android:layout_height="wrap_content"
17+
android:layout_toStartOf="@+id/percentageTextView"
18+
android:layout_centerVertical="true"
19+
android:max="100" />
20+
21+
<TextView
22+
android:id="@+id/percentageTextView"
23+
android:layout_width="wrap_content"
24+
android:layout_height="wrap_content"
25+
android:layout_alignParentEnd="true"
26+
android:layout_centerVertical="true"
27+
android:text="0%%"
28+
android:minWidth="40dp"
29+
android:gravity="center"
30+
/>
31+
</RelativeLayout>
32+
33+
<LinearLayout
34+
android:layout_width="match_parent"
35+
android:layout_height="wrap_content"
36+
android:orientation="horizontal">
37+
38+
<Button
39+
android:id="@+id/resetButton"
40+
android:layout_width="0dp"
41+
android:layout_height="wrap_content"
42+
android:layout_marginHorizontal="16dp"
43+
android:layout_weight="1"
44+
android:text="Restart" />
45+
46+
<Button
47+
android:id="@+id/pauseButton"
48+
android:layout_width="0dp"
49+
android:layout_marginHorizontal="16dp"
50+
android:layout_height="wrap_content"
51+
android:layout_weight="1"
52+
android:text="Pause" />
53+
54+
</LinearLayout>
55+
56+
<fragment
57+
android:id="@+id/map"
58+
class="com.google.android.gms.maps.SupportMapFragment"
59+
android:layout_width="match_parent"
60+
android:layout_height="0dp"
61+
android:layout_weight="1" />
62+
63+
64+
</LinearLayout>

library/src/main/java/com/google/maps/android/SphericalUtil.kt

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,4 +284,59 @@ object SphericalUtil {
284284
val t = tan1 * tan2
285285
return 2 * atan2(t * sin(deltaLng), 1 + t * cos(deltaLng))
286286
}
287+
288+
/**
289+
* Returns the point on a polyline that is a given percentage of the total length of the
290+
* polyline.
291+
*
292+
* @param polyline the polyline
293+
* @param percentage the percentage of the total length of the polyline to find the point for
294+
* @return the `LatLng` of the point on the polyline, or `null` if the polyline is empty or
295+
* the percentage is outside the range [0, 1]
296+
*/
297+
@JvmStatic
298+
fun getPointOnPolyline(polyline: List<LatLng>, percentage: Double): LatLng? {
299+
return getPolylinePrefix(polyline, percentage).lastOrNull()
300+
}
301+
302+
/**
303+
* Returns a new polyline that is a prefix of the original polyline, representing a given
304+
* percentage of the original polyline's length.
305+
*
306+
* Note: The returned polyline should be displayed on the map with `geodesic` set to `true`
307+
* to ensure it follows the same spherical path as the calculation.
308+
*
309+
* @param polyline the original polyline
310+
* @param percentage the percentage of the original polyline's length to include in the prefix
311+
* @return a new polyline representing the prefix, or an empty list if the original polyline
312+
* is empty or the percentage is outside the range [0, 1]
313+
*/
314+
@JvmStatic
315+
fun getPolylinePrefix(polyline: List<LatLng>, percentage: Double): List<LatLng> {
316+
if (polyline.isEmpty() || percentage !in 0.0..1.0) {
317+
return emptyList()
318+
}
319+
when (percentage) {
320+
0.0 -> return listOf(polyline.first())
321+
1.0 -> return polyline.toList() // Return a defensive copy
322+
}
323+
324+
val targetDistance = computeLength(polyline) * percentage
325+
326+
return buildList {
327+
add(polyline.first())
328+
var accumulatedDistance = 0.0
329+
330+
for ((p1, p2) in polyline.zipWithNext()) {
331+
val segmentLength = computeDistanceBetween(p1, p2)
332+
if (accumulatedDistance + segmentLength >= targetDistance) {
333+
val fraction = (targetDistance - accumulatedDistance) / segmentLength
334+
add(interpolate(p1, p2, fraction))
335+
return@buildList // We're done, so exit the builder
336+
}
337+
add(p2)
338+
accumulatedDistance += segmentLength
339+
}
340+
}
341+
}
287342
}

0 commit comments

Comments
 (0)