Implement seekbar animation

Test: manual
Test: atest SeekBarObserverTest
Test: atest SquigglyProgressTest
Bug: 220873625
Change-Id: I161fe0eeb30bde425c1fbad5d5fdad924e628f2d
This commit is contained in:
Lucas Dupin
2022-03-06 18:19:43 -08:00
parent 89489bb5a8
commit dddf4fca6d
8 changed files with 391 additions and 17 deletions

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2022 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<com.android.systemui.media.SquigglyProgress />

View File

@@ -979,6 +979,10 @@
<dimen name="qs_media_session_disabled_seekbar_vertical_padding">16dp</dimen>
<dimen name="qs_media_session_height_expanded">184dp</dimen>
<dimen name="qs_media_session_height_collapsed">128dp</dimen>
<dimen name="qs_media_seekbar_progress_wavelength">20dp</dimen>
<dimen name="qs_media_seekbar_progress_amplitude">1.5dp</dimen>
<dimen name="qs_media_seekbar_progress_phase">8dp</dimen>
<dimen name="qs_media_seekbar_progress_stroke_width">2dp</dimen>
<!-- Size of Smartspace media recommendations cards in the QSPanel carousel -->
<dimen name="qs_aa_media_rec_album_size_collapsed">72dp</dimen>

View File

@@ -590,6 +590,7 @@
<style name="MediaPlayer.ProgressBar" parent="@android:style/Widget.ProgressBar.Horizontal">
<item name="android:thumbTint">?android:attr/textColorPrimary</item>
<item name="android:progressDrawable">@drawable/media_squiggly_progress</item>
<item name="android:progressTint">?android:attr/textColorPrimary</item>
<item name="android:progressBackgroundTint">?android:attr/textColorTertiary</item>
<item name="android:clickable">true</item>

View File

@@ -50,25 +50,46 @@ class SeekBarObserver(
.getDimensionPixelSize(R.dimen.qs_media_disabled_seekbar_vertical_padding)
}
init {
val seekBarProgressWavelength = holder.seekBar.context.resources
.getDimensionPixelSize(R.dimen.qs_media_seekbar_progress_wavelength).toFloat()
val seekBarProgressAmplitude = holder.seekBar.context.resources
.getDimensionPixelSize(R.dimen.qs_media_seekbar_progress_amplitude).toFloat()
val seekBarProgressPhase = holder.seekBar.context.resources
.getDimensionPixelSize(R.dimen.qs_media_seekbar_progress_phase).toFloat()
val seekBarProgressStrokeWidth = holder.seekBar.context.resources
.getDimensionPixelSize(R.dimen.qs_media_seekbar_progress_stroke_width).toFloat()
val progressDrawable = holder.seekBar.progressDrawable as? SquigglyProgress
progressDrawable?.let {
it.waveLength = seekBarProgressWavelength
it.lineAmplitude = seekBarProgressAmplitude
it.phaseSpeed = seekBarProgressPhase
it.strokeWidth = seekBarProgressStrokeWidth
}
}
/** Updates seek bar views when the data model changes. */
@UiThread
override fun onChanged(data: SeekBarViewModel.Progress) {
val progressDrawable = holder.seekBar.progressDrawable as? SquigglyProgress
if (!data.enabled) {
if (holder.seekBar.maxHeight != seekBarDisabledHeight) {
holder.seekBar.maxHeight = seekBarDisabledHeight
setVerticalPadding(seekBarDisabledVerticalPadding)
}
holder.seekBar.setEnabled(false)
holder.seekBar.getThumb().setAlpha(0)
holder.seekBar.setProgress(0)
holder.elapsedTimeView?.setText("")
holder.totalTimeView?.setText("")
holder.seekBar.isEnabled = false
progressDrawable?.animate = false
holder.seekBar.thumb.alpha = 0
holder.seekBar.progress = 0
holder.elapsedTimeView?.text = ""
holder.totalTimeView?.text = ""
holder.seekBar.contentDescription = ""
return
}
holder.seekBar.getThumb().setAlpha(if (data.seekAvailable) 255 else 0)
holder.seekBar.setEnabled(data.seekAvailable)
holder.seekBar.thumb.alpha = if (data.seekAvailable) 255 else 0
holder.seekBar.isEnabled = data.seekAvailable
progressDrawable?.animate = data.playing
if (holder.seekBar.maxHeight != seekBarEnabledMaxHeight) {
holder.seekBar.maxHeight = seekBarEnabledMaxHeight

View File

@@ -31,6 +31,7 @@ import androidx.core.view.GestureDetectorCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.statusbar.NotificationMediaManager
import com.android.systemui.util.concurrency.RepeatableExecutor
import javax.inject.Inject
@@ -73,7 +74,7 @@ private fun PlaybackState.computePosition(duration: Long): Long {
class SeekBarViewModel @Inject constructor(
@Background private val bgExecutor: RepeatableExecutor
) {
private var _data = Progress(false, false, null, 0)
private var _data = Progress(false, false, false, null, 0)
set(value) {
field = value
_progress.postValue(value)
@@ -192,10 +193,12 @@ class SeekBarViewModel @Inject constructor(
val seekAvailable = ((playbackState?.actions ?: 0L) and PlaybackState.ACTION_SEEK_TO) != 0L
val position = playbackState?.position?.toInt()
val duration = mediaMetadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.toInt() ?: 0
val playing = NotificationMediaManager
.isPlayingState(playbackState?.state ?: PlaybackState.STATE_NONE)
val enabled = if (playbackState == null ||
playbackState?.getState() == PlaybackState.STATE_NONE ||
(duration <= 0)) false else true
_data = Progress(enabled, seekAvailable, position, duration)
_data = Progress(enabled, seekAvailable, playing, position, duration)
checkIfPollingNeeded()
}
@@ -412,6 +415,7 @@ class SeekBarViewModel @Inject constructor(
data class Progress(
val enabled: Boolean,
val seekAvailable: Boolean,
val playing: Boolean,
val elapsedTime: Int?,
val duration: Int
)

View File

@@ -0,0 +1,184 @@
package com.android.systemui.media
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
import android.os.SystemClock
import androidx.annotation.VisibleForTesting
import com.android.systemui.animation.Interpolators
import kotlin.math.abs
import kotlin.math.cos
private const val TAG = "Squiggly"
private const val TWO_PI = (Math.PI * 2f).toFloat()
@VisibleForTesting
internal const val DISABLED_ALPHA = 77
class SquigglyProgress : Drawable() {
private val wavePaint = Paint()
private val linePaint = Paint()
private val path = Path()
private var heightFraction = 0f
private var heightAnimator: ValueAnimator? = null
private var phaseOffset = 0f
private var lastFrameTime = -1L
// Horizontal length of the sine wave
var waveLength = 0f
// Height of each peak of the sine wave
var lineAmplitude = 0f
// Line speed in px per second
var phaseSpeed = 0f
// Progress stroke width, both for wave and solid line
var strokeWidth = 0f
set(value) {
if (field == value) {
return
}
field = value
wavePaint.strokeWidth = value
linePaint.strokeWidth = value
}
init {
wavePaint.strokeCap = Paint.Cap.ROUND
linePaint.strokeCap = Paint.Cap.ROUND
linePaint.style = Paint.Style.STROKE
wavePaint.style = Paint.Style.STROKE
linePaint.alpha = DISABLED_ALPHA
}
var animate: Boolean = false
set(value) {
if (field == value) {
return
}
field = value
if (field) {
lastFrameTime = SystemClock.uptimeMillis()
}
heightAnimator?.cancel()
heightAnimator = ValueAnimator.ofFloat(heightFraction, if (animate) 1f else 0f).apply {
if (animate) {
startDelay = 60
duration = 800
interpolator = Interpolators.EMPHASIZED_DECELERATE
} else {
duration = 550
interpolator = Interpolators.STANDARD_DECELERATE
}
addUpdateListener {
heightFraction = it.animatedValue as Float
invalidateSelf()
}
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
heightAnimator = null
}
})
start()
}
}
override fun draw(canvas: Canvas) {
if (animate) {
invalidateSelf()
val now = SystemClock.uptimeMillis()
phaseOffset -= (now - lastFrameTime) / 1000f * phaseSpeed
phaseOffset %= waveLength
lastFrameTime = now
}
val totalProgressPx = (bounds.width() * (level / 10_000f))
canvas.save()
canvas.translate(bounds.left.toFloat(), bounds.centerY().toFloat())
// Clip drawing, so we stop at the thumb
canvas.clipRect(
0f,
-lineAmplitude - strokeWidth,
totalProgressPx,
lineAmplitude + strokeWidth)
// The squiggly line
val start = phaseOffset
var currentX = start
var waveSign = 1f
path.rewind()
path.moveTo(start, lineAmplitude * heightFraction)
while (currentX < totalProgressPx) {
val nextX = currentX + waveLength / 2f
val nextWaveSign = waveSign * -1
path.cubicTo(
currentX + waveLength / 4f, lineAmplitude * waveSign * heightFraction,
nextX - waveLength / 4f, lineAmplitude * nextWaveSign * heightFraction,
nextX, lineAmplitude * nextWaveSign * heightFraction)
currentX = nextX
waveSign = nextWaveSign
}
wavePaint.style = Paint.Style.STROKE
canvas.drawPath(path, wavePaint)
canvas.restore()
// Draw round line cap at the beginning of the wave
val startAmp = cos(abs(phaseOffset) / waveLength * TWO_PI)
val p = Paint()
p.color = Color.WHITE
canvas.drawPoint(
bounds.left.toFloat(),
bounds.centerY() + startAmp * lineAmplitude * heightFraction,
wavePaint)
// Draw continuous line, to the right of the thumb
canvas.drawLine(
bounds.left.toFloat() + totalProgressPx,
bounds.centerY().toFloat(),
bounds.width().toFloat(),
bounds.centerY().toFloat(),
linePaint)
}
override fun getOpacity(): Int {
return PixelFormat.TRANSLUCENT
}
override fun setColorFilter(colorFilter: ColorFilter?) {
wavePaint.colorFilter = colorFilter
linePaint.colorFilter = colorFilter
}
override fun setAlpha(alpha: Int) {
wavePaint.alpha = alpha
linePaint.alpha = (DISABLED_ALPHA * (alpha / 255f)).toInt()
}
override fun getAlpha(): Int {
return wavePaint.alpha
}
override fun setTint(tintColor: Int) {
wavePaint.color = tintColor
linePaint.color = tintColor
}
override fun onLevelChange(level: Int): Boolean {
return animate
}
override fun setTintList(tint: ColorStateList?) {
if (tint == null) {
return
}
wavePaint.color = tint.defaultColor
linePaint.color = tint.defaultColor
}
}

View File

@@ -26,29 +26,33 @@ import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnit
import org.mockito.Mockito.`when` as whenever
@SmallTest
@RunWith(AndroidTestingRunner::class)
@TestableLooper.RunWithLooper
public class SeekBarObserverTest : SysuiTestCase() {
class SeekBarObserverTest : SysuiTestCase() {
private val disabledHeight = 1
private val enabledHeight = 2
private lateinit var observer: SeekBarObserver
@Mock private lateinit var mockHolder: PlayerViewHolder
@Mock private lateinit var mockSquigglyProgress: SquigglyProgress
private lateinit var seekBarView: SeekBar
private lateinit var elapsedTimeView: TextView
private lateinit var totalTimeView: TextView
@JvmField @Rule val mockitoRule = MockitoJUnit.rule()
@Before
fun setUp() {
mockHolder = mock(PlayerViewHolder::class.java)
context.orCreateTestableResources
.addOverride(R.dimen.qs_media_enabled_seekbar_height, enabledHeight)
@@ -56,6 +60,7 @@ public class SeekBarObserverTest : SysuiTestCase() {
.addOverride(R.dimen.qs_media_disabled_seekbar_height, disabledHeight)
seekBarView = SeekBar(context)
seekBarView.progressDrawable = mockSquigglyProgress
elapsedTimeView = TextView(context)
totalTimeView = TextView(context)
whenever(mockHolder.seekBar).thenReturn(seekBarView)
@@ -69,7 +74,7 @@ public class SeekBarObserverTest : SysuiTestCase() {
fun seekBarGone() {
// WHEN seek bar is disabled
val isEnabled = false
val data = SeekBarViewModel.Progress(isEnabled, false, null, 0)
val data = SeekBarViewModel.Progress(isEnabled, false, false, null, 0)
observer.onChanged(data)
// THEN seek bar shows just a thin line with no text
assertThat(seekBarView.isEnabled()).isFalse()
@@ -84,7 +89,7 @@ public class SeekBarObserverTest : SysuiTestCase() {
fun seekBarVisible() {
// WHEN seek bar is enabled
val isEnabled = true
val data = SeekBarViewModel.Progress(isEnabled, true, 3000, 12000)
val data = SeekBarViewModel.Progress(isEnabled, true, false, 3000, 12000)
observer.onChanged(data)
// THEN seek bar is visible and thick
assertThat(seekBarView.getVisibility()).isEqualTo(View.VISIBLE)
@@ -96,7 +101,7 @@ public class SeekBarObserverTest : SysuiTestCase() {
@Test
fun seekBarProgress() {
// WHEN part of the track has been played
val data = SeekBarViewModel.Progress(true, true, 3000, 120000)
val data = SeekBarViewModel.Progress(true, true, true, 3000, 120000)
observer.onChanged(data)
// THEN seek bar shows the progress
assertThat(seekBarView.progress).isEqualTo(3000)
@@ -112,7 +117,7 @@ public class SeekBarObserverTest : SysuiTestCase() {
fun seekBarDisabledWhenSeekNotAvailable() {
// WHEN seek is not available
val isSeekAvailable = false
val data = SeekBarViewModel.Progress(true, isSeekAvailable, 3000, 120000)
val data = SeekBarViewModel.Progress(true, isSeekAvailable, false, 3000, 120000)
observer.onChanged(data)
// THEN seek bar is not enabled
assertThat(seekBarView.isEnabled()).isFalse()
@@ -122,9 +127,29 @@ public class SeekBarObserverTest : SysuiTestCase() {
fun seekBarEnabledWhenSeekNotAvailable() {
// WHEN seek is available
val isSeekAvailable = true
val data = SeekBarViewModel.Progress(true, isSeekAvailable, 3000, 120000)
val data = SeekBarViewModel.Progress(true, isSeekAvailable, false, 3000, 120000)
observer.onChanged(data)
// THEN seek bar is not enabled
assertThat(seekBarView.isEnabled()).isTrue()
}
@Test
fun seekBarPlaying() {
// WHEN playing
val isPlaying = true
val data = SeekBarViewModel.Progress(true, true, isPlaying, 3000, 120000)
observer.onChanged(data)
// THEN progress drawable is animating
verify(mockSquigglyProgress).animate = true
}
@Test
fun seekBarNotPlaying() {
// WHEN not playing
val isPlaying = false
val data = SeekBarViewModel.Progress(true, true, isPlaying, 3000, 120000)
observer.onChanged(data)
// THEN progress drawable is not animating
verify(mockSquigglyProgress).animate = false
}
}

View File

@@ -0,0 +1,118 @@
package com.android.systemui.media
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LightingColorFilter
import android.graphics.Paint
import android.graphics.Rect
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.util.mockito.any
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Captor
import org.mockito.Mock
import org.mockito.Mockito.anyFloat
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnit
@SmallTest
@RunWith(AndroidTestingRunner::class)
@TestableLooper.RunWithLooper
class SquigglyProgressTest : SysuiTestCase() {
private val colorFilter = LightingColorFilter(Color.RED, Color.BLUE)
private val strokeWidth = 5f
private val alpha = 128
private val tint = Color.GREEN
lateinit var squigglyProgress: SquigglyProgress
@Mock lateinit var canvas: Canvas
@Captor lateinit var wavePaintCaptor: ArgumentCaptor<Paint>
@Captor lateinit var linePaintCaptor: ArgumentCaptor<Paint>
@JvmField @Rule val mockitoRule = MockitoJUnit.rule()
@Before
fun setup() {
squigglyProgress = SquigglyProgress()
squigglyProgress.waveLength = 30f
squigglyProgress.lineAmplitude = 10f
squigglyProgress.phaseSpeed = 8f
squigglyProgress.strokeWidth = strokeWidth
squigglyProgress.bounds = Rect(0, 0, 300, 30)
}
@Test
fun testDrawPathAndLine() {
squigglyProgress.draw(canvas)
verify(canvas).drawPath(any(), wavePaintCaptor.capture())
verify(canvas).drawLine(anyFloat(), anyFloat(), anyFloat(), anyFloat(),
linePaintCaptor.capture())
}
@Test
fun testOnLevelChanged() {
assertThat(squigglyProgress.setLevel(5)).isFalse()
squigglyProgress.animate = true
assertThat(squigglyProgress.setLevel(4)).isTrue()
}
@Test
fun testStrokeWidth() {
squigglyProgress.draw(canvas)
verify(canvas).drawPath(any(), wavePaintCaptor.capture())
verify(canvas).drawLine(anyFloat(), anyFloat(), anyFloat(), anyFloat(),
linePaintCaptor.capture())
assertThat(wavePaintCaptor.value.strokeWidth).isEqualTo(strokeWidth)
assertThat(linePaintCaptor.value.strokeWidth).isEqualTo(strokeWidth)
}
@Test
fun testAlpha() {
squigglyProgress.alpha = alpha
squigglyProgress.draw(canvas)
verify(canvas).drawPath(any(), wavePaintCaptor.capture())
verify(canvas).drawLine(anyFloat(), anyFloat(), anyFloat(), anyFloat(),
linePaintCaptor.capture())
assertThat(squigglyProgress.alpha).isEqualTo(alpha)
assertThat(wavePaintCaptor.value.alpha).isEqualTo(alpha)
assertThat(linePaintCaptor.value.alpha).isEqualTo((alpha / 255f * DISABLED_ALPHA).toInt())
}
@Test
fun testColorFilter() {
squigglyProgress.colorFilter = colorFilter
squigglyProgress.draw(canvas)
verify(canvas).drawPath(any(), wavePaintCaptor.capture())
verify(canvas).drawLine(anyFloat(), anyFloat(), anyFloat(), anyFloat(),
linePaintCaptor.capture())
assertThat(wavePaintCaptor.value.colorFilter).isEqualTo(colorFilter)
assertThat(linePaintCaptor.value.colorFilter).isEqualTo(colorFilter)
}
@Test
fun testTint() {
squigglyProgress.setTint(tint)
squigglyProgress.draw(canvas)
verify(canvas).drawPath(any(), wavePaintCaptor.capture())
verify(canvas).drawLine(anyFloat(), anyFloat(), anyFloat(), anyFloat(),
linePaintCaptor.capture())
assertThat(wavePaintCaptor.value.color).isEqualTo(tint)
assertThat(linePaintCaptor.value.color).isEqualTo(tint)
}
}