Skip to content

Commit 38c49d9

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

3 files changed

Lines changed: 123 additions & 138 deletions

File tree

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/java/com/google/maps/android/utils/demo/PolylineProgressDemoActivity.kt

Lines changed: 118 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -2,146 +2,142 @@ package com.google.maps.android.utils.demo
22

33
import android.graphics.Canvas
44
import android.graphics.Color
5-
import android.os.Bundle
6-
import android.widget.Button
5+
import android.view.ViewGroup
76
import android.widget.SeekBar
8-
import android.widget.TextView
9-
import androidx.appcompat.app.AppCompatActivity
107
import androidx.core.content.ContextCompat
8+
import androidx.core.graphics.createBitmap
9+
import androidx.core.graphics.toColorInt
1110
import androidx.lifecycle.MutableLiveData
11+
import androidx.lifecycle.lifecycleScope
1212
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
1613
import com.google.android.gms.maps.model.BitmapDescriptor
1714
import com.google.android.gms.maps.model.BitmapDescriptorFactory
1815
import com.google.android.gms.maps.model.LatLng
16+
import com.google.android.gms.maps.model.LatLngBounds
1917
import com.google.android.gms.maps.model.Marker
2018
import com.google.android.gms.maps.model.MarkerOptions
2119
import com.google.android.gms.maps.model.Polyline
2220
import com.google.android.gms.maps.model.PolylineOptions
2321
import com.google.maps.android.SphericalUtil
24-
import kotlinx.coroutines.CoroutineScope
25-
import kotlinx.coroutines.Dispatchers
22+
import com.google.maps.android.utils.demo.databinding.ActivityPolylineProgressDemoBinding
2623
import kotlinx.coroutines.Job
2724
import kotlinx.coroutines.delay
2825
import kotlinx.coroutines.launch
29-
import androidx.core.graphics.createBitmap
30-
import androidx.core.graphics.toColorInt
3126

32-
class PolylineProgressDemoActivity : AppCompatActivity(), OnMapReadyCallback, SeekBar.OnSeekBarChangeListener {
27+
/**
28+
* This demo showcases how to animate a marker along a geodesic polyline, illustrating
29+
* key features of the Android Maps Utils library and modern Android development practices.
30+
*/
31+
class PolylineProgressDemoActivity : BaseDemoActivity(), SeekBar.OnSeekBarChangeListener {
32+
33+
companion object {
34+
private const val POLYLINE_WIDTH = 15f
35+
private const val PROGRESS_POLYLINE_WIDTH = 7f
36+
private const val ANIMATION_STEP_SIZE = 1
37+
private const val ANIMATION_DELAY_MS = 75L
38+
}
3339

34-
private var planeIcon: BitmapDescriptor? = null
35-
private lateinit var map: GoogleMap
40+
private lateinit var binding: ActivityPolylineProgressDemoBinding
3641
private lateinit var originalPolyline: Polyline
3742
private var progressPolyline: Polyline? = null
3843
private var progressMarker: Marker? = null
3944

40-
private lateinit var seekBar: SeekBar
41-
private lateinit var percentageTextView: TextView
45+
private val planeIcon: BitmapDescriptor by lazy {
46+
bitmapDescriptorFromVector(R.drawable.baseline_airplanemode_active_24, "#FFD700".toColorInt())
47+
}
4248

43-
private var isReady = false
44-
private var animationJob: Job? = null
49+
private data class AnimationState(val progress: Int, val direction: Int)
4550

46-
private val progress = MutableLiveData<Int>()
47-
private val direction = MutableLiveData<Int>()
48-
private val stepSize = 1
51+
private val animationState = MutableLiveData<AnimationState>()
52+
private var animationJob: Job? = null
4953

5054
private val polylinePoints = listOf(
5155
LatLng(40.7128, -74.0060), // New York
56+
LatLng(47.6062, -122.3321), // Seattle
57+
LatLng(39.7392, -104.9903), // Denver
58+
LatLng(37.7749, -122.4194), // San Francisco
5259
LatLng(34.0522, -118.2437), // Los Angeles
5360
LatLng(41.8781, -87.6298), // Chicago
5461
LatLng(29.7604, -95.3698), // Houston
5562
LatLng(39.9526, -75.1652) // Philadelphia
5663
)
5764

58-
override fun onCreate(savedInstanceState: Bundle?) {
59-
super.onCreate(savedInstanceState)
60-
isReady = false
61-
setContentView(R.layout.activity_polyline_progress_demo)
65+
override fun getLayoutId(): Int = R.layout.activity_polyline_progress_demo
6266

63-
val mapFragment = supportFragmentManager.findFragmentById(R.id.map) as SupportMapFragment
64-
mapFragment.getMapAsync(this)
67+
/**
68+
* This is where the demo begins. It is called from the base activity's `onMapReady` callback.
69+
*/
70+
override fun startDemo(isRestore: Boolean) {
71+
// The layout is already inflated by the base class. We can now bind to it.
72+
val rootView = (findViewById<ViewGroup>(android.R.id.content)).getChildAt(0)
73+
binding = ActivityPolylineProgressDemoBinding.bind(rootView)
6574

66-
progress.value = 0
67-
direction.value = 1
75+
setupMap()
76+
setupUI()
77+
// Set the initial state. The observer in setupUI will handle the first UI update.
78+
animationState.value = AnimationState(progress = 0, direction = 1)
79+
startAnimation()
6880
}
6981

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-
82+
private fun setupMap() {
7683
originalPolyline = map.addPolyline(
7784
PolylineOptions()
7885
.addAll(polylinePoints)
7986
.color(Color.GRAY)
80-
.width(15f) // Set the width of the base polyline
81-
.geodesic(true) // Be sure to set the geodesic flag!
87+
.width(POLYLINE_WIDTH)
88+
.geodesic(true) // A geodesic polyline follows the curvature of the Earth.
8289
)
8390

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+
val bounds = LatLngBounds.builder().apply {
92+
polylinePoints.forEach { include(it) }
93+
}.build()
9194
map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 100))
95+
}
9296

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
97+
private fun setupUI() {
98+
binding.seekBar.setOnSeekBarChangeListener(this)
99+
binding.resetButton.setOnClickListener {
100+
stopAnimation()
101+
animationState.value = AnimationState(progress = 0, direction = 1)
106102
startAnimation()
107103
}
108-
109-
findViewById<Button>(R.id.pauseButton).setOnClickListener {
110-
if (animationJob != null) {
111-
animationJob?.cancel()
112-
animationJob = null
104+
binding.pauseButton.setOnClickListener {
105+
if (animationJob?.isActive == true) {
106+
stopAnimation()
113107
} else {
114108
startAnimation()
115109
}
116110
}
117111

118-
progress.observe(this) {
119-
seekBar.progress = it
120-
percentageTextView.text = "$it%"
121-
updateProgress(it / 100.0)
112+
animationState.observe(this) { state ->
113+
binding.seekBar.progress = state.progress
114+
binding.percentageTextView.text = getString(R.string.percentage_format, state.progress)
115+
updateProgressOnMap(state.progress / 100.0, state.direction)
122116
}
123-
124-
startAnimation()
125117
}
126118

127119
private fun startAnimation() {
128-
stopAnimation() // Stop any existing animation first
120+
stopAnimation()
121+
val currentState = animationState.value ?: return
129122

130-
// Start a coroutine to animate the polyline progress
131-
animationJob = CoroutineScope(Dispatchers.Main).launch {
123+
animationJob = lifecycleScope.launch {
124+
var progress = currentState.progress
125+
var direction = currentState.direction
132126
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
127+
progress = when {
128+
progress > 100 -> {
129+
direction = -1
130+
100
131+
}
132+
progress < 0 -> {
133+
direction = 1
134+
0
135+
}
136+
else -> progress + direction * ANIMATION_STEP_SIZE
142137
}
143-
progress.value = nextProgress
144-
delay(50)
138+
139+
animationState.postValue(AnimationState(progress, direction))
140+
delay(ANIMATION_DELAY_MS)
145141
}
146142
}
147143
}
@@ -151,30 +147,18 @@ class PolylineProgressDemoActivity : AppCompatActivity(), OnMapReadyCallback, Se
151147
animationJob = null
152148
}
153149

154-
override fun onPause() {
155-
super.onPause()
156-
animationJob?.cancel()
157-
}
158-
159-
override fun onResume() {
160-
super.onResume()
161-
startAnimation()
162-
}
163-
164150
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
165151
if (fromUser) {
166152
stopAnimation()
167-
this.progress.value = progress
153+
animationState.value = AnimationState(progress, animationState.value?.direction ?: 1)
168154
}
169155
}
170156

171-
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
157+
override fun onStartTrackingTouch(seekBar: SeekBar?) { /* No-op */ }
172158

173-
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
174-
175-
private fun updateProgress(percentage: Double) {
176-
if (!isReady) return
159+
override fun onStopTrackingTouch(seekBar: SeekBar?) { /* No-op */ }
177160

161+
private fun updateProgressOnMap(percentage: Double, direction: Int) {
178162
progressPolyline?.remove()
179163

180164
val prefix = SphericalUtil.getPolylinePrefix(polylinePoints, percentage)
@@ -183,53 +167,49 @@ class PolylineProgressDemoActivity : AppCompatActivity(), OnMapReadyCallback, Se
183167
PolylineOptions()
184168
.addAll(prefix)
185169
.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!
170+
.width(PROGRESS_POLYLINE_WIDTH)
171+
.zIndex(1f)
172+
.geodesic(true)
189173
)
190174
}
191175

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-
}
176+
SphericalUtil.getPointOnPolyline(polylinePoints, percentage)?.let { point ->
177+
updateMarker(point, percentage, direction)
178+
}
179+
}
200180

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-
}
181+
private fun updateMarker(point: LatLng, percentage: Double, direction: Int) {
182+
val heading = SphericalUtil.getPointOnPolyline(polylinePoints, percentage + 0.0001)
183+
?.let { SphericalUtil.computeHeading(point, it) }
184+
?.let { if (direction == -1) it + 180 else it } // Adjust for reverse direction.
185+
186+
if (progressMarker == null) {
187+
progressMarker = map.addMarker(
188+
MarkerOptions()
189+
.position(point)
190+
.flat(true)
191+
.draggable(false)
192+
.icon(planeIcon)
193+
.apply { heading?.let { rotation(it.toFloat()) } }
194+
)
195+
} else {
196+
progressMarker?.also {
197+
it.position = point
198+
heading?.let { newHeading -> it.rotation = newHeading.toFloat() }
221199
}
222200
}
223201
}
224202

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-
}
203+
private fun bitmapDescriptorFromVector(vectorResId: Int, color: Int): BitmapDescriptor {
204+
val vectorDrawable = ContextCompat.getDrawable(this, vectorResId)!!
205+
vectorDrawable.setTint(color)
206+
val bitmap = createBitmap(
207+
vectorDrawable.intrinsicWidth,
208+
vectorDrawable.intrinsicHeight
209+
)
210+
val canvas = Canvas(bitmap)
211+
vectorDrawable.setBounds(0, 0, canvas.width, canvas.height)
212+
vectorDrawable.draw(canvas)
213+
return BitmapDescriptorFactory.fromBitmap(bitmap)
234214
}
235215
}

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">Invalid or missing Google Maps API key</string>
37+
<string name="percentage_format">%1$d%%</string>
3738
</resources>

0 commit comments

Comments
 (0)