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