1 /*
2  * Copyright (C) 2022 The Android Open Source Project
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.android.systemui.media.controls.ui
18 
19 import android.animation.Animator
20 import android.animation.AnimatorListenerAdapter
21 import android.animation.ValueAnimator
22 import android.content.res.ColorStateList
23 import android.graphics.Canvas
24 import android.graphics.ColorFilter
25 import android.graphics.Paint
26 import android.graphics.Path
27 import android.graphics.PixelFormat
28 import android.graphics.drawable.Drawable
29 import android.os.SystemClock
30 import android.util.MathUtils.lerp
31 import android.util.MathUtils.lerpInv
32 import android.util.MathUtils.lerpInvSat
33 import androidx.annotation.VisibleForTesting
34 import com.android.app.animation.Interpolators
35 import com.android.internal.graphics.ColorUtils
36 import kotlin.math.abs
37 import kotlin.math.cos
38 
39 private const val TAG = "Squiggly"
40 
41 private const val TWO_PI = (Math.PI * 2f).toFloat()
42 @VisibleForTesting internal const val DISABLED_ALPHA = 77
43 
44 class SquigglyProgress : Drawable() {
45 
46     private val wavePaint = Paint()
47     private val linePaint = Paint()
48     private val path = Path()
49     private var heightFraction = 0f
50     private var heightAnimator: ValueAnimator? = null
51     private var phaseOffset = 0f
52     private var lastFrameTime = -1L
53 
54     /* distance over which amplitude drops to zero, measured in wavelengths */
55     private val transitionPeriods = 1.5f
56     /* wave endpoint as percentage of bar when play position is zero */
57     private val minWaveEndpoint = 0.2f
58     /* wave endpoint as percentage of bar when play position matches wave endpoint */
59     private val matchedWaveEndpoint = 0.6f
60 
61     // Horizontal length of the sine wave
62     var waveLength = 0f
63     // Height of each peak of the sine wave
64     var lineAmplitude = 0f
65     // Line speed in px per second
66     var phaseSpeed = 0f
67     // Progress stroke width, both for wave and solid line
68     var strokeWidth = 0f
69         set(value) {
70             if (field == value) {
71                 return
72             }
73             field = value
74             wavePaint.strokeWidth = value
75             linePaint.strokeWidth = value
76         }
77 
78     // Enables a transition region where the amplitude
79     // of the wave is reduced linearly across it.
80     var transitionEnabled = true
81         set(value) {
82             field = value
83             invalidateSelf()
84         }
85 
86     init {
87         wavePaint.strokeCap = Paint.Cap.ROUND
88         linePaint.strokeCap = Paint.Cap.ROUND
89         linePaint.style = Paint.Style.STROKE
90         wavePaint.style = Paint.Style.STROKE
91         linePaint.alpha = DISABLED_ALPHA
92     }
93 
94     var animate: Boolean = false
95         set(value) {
96             if (field == value) {
97                 return
98             }
99             field = value
100             if (field) {
101                 lastFrameTime = SystemClock.uptimeMillis()
102             }
103             heightAnimator?.cancel()
104             heightAnimator =
105                 ValueAnimator.ofFloat(heightFraction, if (animate) 1f else 0f).apply {
106                     if (animate) {
107                         startDelay = 60
108                         duration = 800
109                         interpolator = Interpolators.EMPHASIZED_DECELERATE
110                     } else {
111                         duration = 550
112                         interpolator = Interpolators.STANDARD_DECELERATE
113                     }
114                     addUpdateListener {
115                         heightFraction = it.animatedValue as Float
116                         invalidateSelf()
117                     }
118                     addListener(
119                         object : AnimatorListenerAdapter() {
120                             override fun onAnimationEnd(animation: Animator) {
121                                 heightAnimator = null
122                             }
123                         }
124                     )
125                     start()
126                 }
127         }
128 
129     override fun draw(canvas: Canvas) {
130         if (animate) {
131             invalidateSelf()
132             val now = SystemClock.uptimeMillis()
133             phaseOffset += (now - lastFrameTime) / 1000f * phaseSpeed
134             phaseOffset %= waveLength
135             lastFrameTime = now
136         }
137 
138         val progress = level / 10_000f
139         val totalWidth = bounds.width().toFloat()
140         val totalProgressPx = totalWidth * progress
141         val waveProgressPx =
142             totalWidth *
143                 (if (!transitionEnabled || progress > matchedWaveEndpoint) progress
144                 else
145                     lerp(
146                         minWaveEndpoint,
147                         matchedWaveEndpoint,
148                         lerpInv(0f, matchedWaveEndpoint, progress)
149                     ))
150 
151         // Build Wiggly Path
152         val waveStart = -phaseOffset - waveLength / 2f
153         val waveEnd = if (transitionEnabled) totalWidth else waveProgressPx
154 
155         // helper function, computes amplitude for wave segment
156         val computeAmplitude: (Float, Float) -> Float = { x, sign ->
157             if (transitionEnabled) {
158                 val length = transitionPeriods * waveLength
159                 val coeff =
160                     lerpInvSat(waveProgressPx + length / 2f, waveProgressPx - length / 2f, x)
161                 sign * heightFraction * lineAmplitude * coeff
162             } else {
163                 sign * heightFraction * lineAmplitude
164             }
165         }
166 
167         // Reset path object to the start
168         path.rewind()
169         path.moveTo(waveStart, 0f)
170 
171         // Build the wave, incrementing by half the wavelength each time
172         var currentX = waveStart
173         var waveSign = 1f
174         var currentAmp = computeAmplitude(currentX, waveSign)
175         val dist = waveLength / 2f
176         while (currentX < waveEnd) {
177             waveSign = -waveSign
178             val nextX = currentX + dist
179             val midX = currentX + dist / 2
180             val nextAmp = computeAmplitude(nextX, waveSign)
181             path.cubicTo(midX, currentAmp, midX, nextAmp, nextX, nextAmp)
182             currentAmp = nextAmp
183             currentX = nextX
184         }
185 
186         // translate to the start position of the progress bar for all draw commands
187         val clipTop = lineAmplitude + strokeWidth
188         canvas.save()
189         canvas.translate(bounds.left.toFloat(), bounds.centerY().toFloat())
190 
191         // Draw path up to progress position
192         canvas.save()
193         canvas.clipRect(0f, -1f * clipTop, totalProgressPx, clipTop)
194         canvas.drawPath(path, wavePaint)
195         canvas.restore()
196 
197         if (transitionEnabled) {
198             // If there's a smooth transition, we draw the rest of the
199             // path in a different color (using different clip params)
200             canvas.save()
201             canvas.clipRect(totalProgressPx, -1f * clipTop, totalWidth, clipTop)
202             canvas.drawPath(path, linePaint)
203             canvas.restore()
204         } else {
205             // No transition, just draw a flat line to the end of the region.
206             // The discontinuity is hidden by the progress bar thumb shape.
207             canvas.drawLine(totalProgressPx, 0f, totalWidth, 0f, linePaint)
208         }
209 
210         // Draw round line cap at the beginning of the wave
211         val startAmp = cos(abs(waveStart) / waveLength * TWO_PI)
212         canvas.drawPoint(0f, startAmp * lineAmplitude * heightFraction, wavePaint)
213 
214         canvas.restore()
215     }
216 
217     override fun getOpacity(): Int {
218         return PixelFormat.TRANSLUCENT
219     }
220 
221     override fun setColorFilter(colorFilter: ColorFilter?) {
222         wavePaint.colorFilter = colorFilter
223         linePaint.colorFilter = colorFilter
224     }
225 
226     override fun setAlpha(alpha: Int) {
227         updateColors(wavePaint.color, alpha)
228     }
229 
230     override fun getAlpha(): Int {
231         return wavePaint.alpha
232     }
233 
234     override fun setTint(tintColor: Int) {
235         updateColors(tintColor, alpha)
236     }
237 
238     override fun onLevelChange(level: Int): Boolean {
239         return animate
240     }
241 
242     override fun setTintList(tint: ColorStateList?) {
243         if (tint == null) {
244             return
245         }
246         updateColors(tint.defaultColor, alpha)
247     }
248 
249     private fun updateColors(tintColor: Int, alpha: Int) {
250         wavePaint.color = ColorUtils.setAlphaComponent(tintColor, alpha)
251         linePaint.color =
252             ColorUtils.setAlphaComponent(tintColor, (DISABLED_ALPHA * (alpha / 255f)).toInt())
253     }
254 }
255