summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Robert Snoeberger <snoeberger@google.com> 2020-06-11 00:06:11 -0400
committer Robert Snoeberger <snoeberger@google.com> 2020-06-11 16:23:00 -0400
commit21d133ed1a5e05c520e3c157c9ad00818864d10e (patch)
treeb5a01a425749962e50cf99650ed36b19296f1837
parent77d3dcab6fc431782c0aa8784aca32fa6d867496 (diff)
Reject flings on seek bar
This should help reduce accidentials seek. A gesture that includes a fling will be rejected. The progress will be returned to the place where it was located at the start of the gesture. Fixes: 155673905 Test: atest tests/src/com/android/systemui/media/SeekBarViewModelTest.kt Test: manual - fling seek bar. It should bounce back. Change-Id: Id0846d671c17d7362d45afec3e77676e764d96b3
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt125
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/media/SeekBarViewModelTest.kt179
2 files changed, 257 insertions, 47 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt
index 63f3d44568b3..1dca3f1297b1 100644
--- a/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt
@@ -23,6 +23,7 @@ import android.os.SystemClock
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
+import android.view.ViewConfiguration
import android.widget.SeekBar
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
@@ -31,10 +32,10 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.LiveData
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.util.concurrency.RepeatableExecutor
-import java.util.concurrent.Executor
import javax.inject.Inject
private const val POSITION_UPDATE_INTERVAL_MILLIS = 100L
+private const val MIN_FLING_VELOCITY_SCALE_FACTOR = 10
private fun PlaybackState.isInMotion(): Boolean {
return this.state == PlaybackState.STATE_PLAYING ||
@@ -105,6 +106,9 @@ class SeekBarViewModel @Inject constructor(@Background private val bgExecutor: R
}
private var cancel: Runnable? = null
+ /** Indicates if the seek interaction is considered a false guesture. */
+ private var isFalseSeek = false
+
/** Listening state (QS open or closed) is used to control polling of progress. */
var listening = true
set(value) = bgExecutor.execute {
@@ -114,16 +118,61 @@ class SeekBarViewModel @Inject constructor(@Background private val bgExecutor: R
}
}
+ /** Set to true when the user is touching the seek bar to change the position. */
+ private var scrubbing = false
+ set(value) {
+ if (field != value) {
+ field = value
+ checkIfPollingNeeded()
+ }
+ }
+
+ /**
+ * Event indicating that the user has started interacting with the seek bar.
+ */
+ @AnyThread
+ fun onSeekStarting() = bgExecutor.execute {
+ scrubbing = true
+ isFalseSeek = false
+ }
+
+ /**
+ * Event indicating that the user has moved the seek bar but hasn't yet finished the gesture.
+ * @param position Current location in the track.
+ */
+ @AnyThread
+ fun onSeekProgress(position: Long) = bgExecutor.execute {
+ if (scrubbing) {
+ _data = _data.copy(elapsedTime = position.toInt())
+ }
+ }
+
+ /**
+ * Event indicating that the seek interaction is a false gesture and it should be ignored.
+ */
+ @AnyThread
+ fun onSeekFalse() = bgExecutor.execute {
+ if (scrubbing) {
+ isFalseSeek = true
+ }
+ }
+
/**
* Handle request to change the current position in the media track.
* @param position Place to seek to in the track.
*/
- @WorkerThread
- fun onSeek(position: Long) {
- controller?.transportControls?.seekTo(position)
- // Invalidate the cached playbackState to avoid the thumb jumping back to the previous
- // position.
- playbackState = null
+ @AnyThread
+ fun onSeek(position: Long) = bgExecutor.execute {
+ if (isFalseSeek) {
+ scrubbing = false
+ checkPlaybackPosition()
+ } else {
+ controller?.transportControls?.seekTo(position)
+ // Invalidate the cached playbackState to avoid the thumb jumping back to the previous
+ // position.
+ playbackState = null
+ scrubbing = false
+ }
}
/**
@@ -181,7 +230,7 @@ class SeekBarViewModel @Inject constructor(@Background private val bgExecutor: R
@WorkerThread
private fun checkIfPollingNeeded() {
- val needed = listening && playbackState?.isInMotion() ?: false
+ val needed = listening && !scrubbing && playbackState?.isInMotion() ?: false
if (needed) {
if (cancel == null) {
cancel = bgExecutor.executeRepeatedly(this::checkPlaybackPosition, 0L,
@@ -196,33 +245,30 @@ class SeekBarViewModel @Inject constructor(@Background private val bgExecutor: R
/** Gets a listener to attach to the seek bar to handle seeking. */
val seekBarListener: SeekBar.OnSeekBarChangeListener
get() {
- return SeekBarChangeListener(this, bgExecutor)
+ return SeekBarChangeListener(this)
}
/** Attach touch handlers to the seek bar view. */
fun attachTouchHandlers(bar: SeekBar) {
bar.setOnSeekBarChangeListener(seekBarListener)
- bar.setOnTouchListener(SeekBarTouchListener(bar))
+ bar.setOnTouchListener(SeekBarTouchListener(this, bar))
}
private class SeekBarChangeListener(
- val viewModel: SeekBarViewModel,
- val bgExecutor: Executor
+ val viewModel: SeekBarViewModel
) : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
if (fromUser) {
- bgExecutor.execute {
- viewModel.onSeek(progress.toLong())
- }
+ viewModel.onSeekProgress(progress.toLong())
}
}
+
override fun onStartTrackingTouch(bar: SeekBar) {
+ viewModel.onSeekStarting()
}
+
override fun onStopTrackingTouch(bar: SeekBar) {
- val pos = bar.progress.toLong()
- bgExecutor.execute {
- viewModel.onSeek(pos)
- }
+ viewModel.onSeek(bar.progress.toLong())
}
}
@@ -233,14 +279,18 @@ class SeekBarViewModel @Inject constructor(@Background private val bgExecutor: R
* they intend to scroll the carousel.
*/
private class SeekBarTouchListener(
+ private val viewModel: SeekBarViewModel,
private val bar: SeekBar
) : View.OnTouchListener, GestureDetector.OnGestureListener {
// Gesture detector helps decide which touch events to intercept.
private val detector = GestureDetectorCompat(bar.context, this)
- // Defines a tap target around the thumb at the beginning of a gesture.
- private var onDownTargetBoxMinX: Int = -1
- private var onDownTargetBoxMaxX: Int = -1
+ // Velocity threshold used to decide when a fling is considered a false gesture.
+ private val flingVelocity: Int = ViewConfiguration.get(bar.context).run {
+ getScaledMinimumFlingVelocity() * MIN_FLING_VELOCITY_SCALE_FACTOR
+ }
+ // Indicates if the gesture should go to the seek bar or if it should be intercepted.
+ private var shouldGoToSeekBar = false
/**
* Decide which touch events to intercept before they reach the seek bar.
@@ -261,7 +311,7 @@ class SeekBarViewModel @Inject constructor(@Background private val bgExecutor: R
if (view != bar) {
return false
}
- val shouldGoToSeekBar = detector.onTouchEvent(event)
+ detector.onTouchEvent(event)
return !shouldGoToSeekBar
}
@@ -295,16 +345,16 @@ class SeekBarViewModel @Inject constructor(@Background private val bgExecutor: R
// Set the min, max boundaries of the thumb box.
// I'm cheating by using the height of the seek bar as the width of the box.
val halfHeight: Int = bar.height / 2
- onDownTargetBoxMinX = (Math.round(thumbX) - halfHeight).toInt()
- onDownTargetBoxMaxX = (Math.round(thumbX) + halfHeight).toInt()
+ val targetBoxMinX = (Math.round(thumbX) - halfHeight).toInt()
+ val targetBoxMaxX = (Math.round(thumbX) + halfHeight).toInt()
// If the x position of the down event is within the box, then request that the parent
// not intercept the event.
val x = Math.round(event.x)
- val accept = x >= onDownTargetBoxMinX && x <= onDownTargetBoxMaxX
- if (accept) {
+ shouldGoToSeekBar = x >= targetBoxMinX && x <= targetBoxMaxX
+ if (shouldGoToSeekBar) {
bar.parent?.requestDisallowInterceptTouchEvent(true)
}
- return accept
+ return shouldGoToSeekBar
}
/**
@@ -312,7 +362,10 @@ class SeekBarViewModel @Inject constructor(@Background private val bgExecutor: R
*
* This enables the user to single tap anywhere on the seek bar to seek to that position.
*/
- override fun onSingleTapUp(event: MotionEvent) = true
+ override fun onSingleTapUp(event: MotionEvent): Boolean {
+ shouldGoToSeekBar = true
+ return true
+ }
/**
* Handle scroll events when the down event is on the thumb.
@@ -325,17 +378,13 @@ class SeekBarViewModel @Inject constructor(@Background private val bgExecutor: R
distanceX: Float,
distanceY: Float
): Boolean {
- val x = Math.round(eventStart.x)
- return x >= onDownTargetBoxMinX && x <= onDownTargetBoxMaxX
+ return shouldGoToSeekBar
}
/**
* Handle fling events when the down event is on the thumb.
*
- * TODO: Ignore entire gesture when it includes a fling.
- * If a user is flinging, then they are probably trying to page the carousel. It would be
- * better to ignore the entire gesture when it includes a fling. This could be achieved by
- * reseting the seek bar position to where it was when the gesture started.
+ * Gestures that include a fling are considered a false gesture on the seek bar.
*/
override fun onFling(
eventStart: MotionEvent,
@@ -343,8 +392,10 @@ class SeekBarViewModel @Inject constructor(@Background private val bgExecutor: R
velocityX: Float,
velocityY: Float
): Boolean {
- val x = Math.round(eventStart.x)
- return x >= onDownTargetBoxMinX && x <= onDownTargetBoxMaxX
+ if (Math.abs(velocityX) > flingVelocity || Math.abs(velocityY) > flingVelocity) {
+ viewModel.onSeekFalse()
+ }
+ return shouldGoToSeekBar
}
override fun onShowPress(event: MotionEvent) {}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarViewModelTest.kt
index 24e9bd837d5d..c8ef9fbf06e5 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarViewModelTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarViewModelTest.kt
@@ -40,6 +40,7 @@ import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Mock
import org.mockito.Mockito.any
+import org.mockito.Mockito.eq
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.times
@@ -204,7 +205,7 @@ public class SeekBarViewModelTest : SysuiTestCase() {
@Test
fun updateElapsedTime() {
- // GIVEN that the PlaybackState contins the current position
+ // GIVEN that the PlaybackState contains the current position
val position = 200L
val state = PlaybackState.Builder().run {
setState(PlaybackState.STATE_PLAYING, position, 1f)
@@ -246,7 +247,7 @@ public class SeekBarViewModelTest : SysuiTestCase() {
}
@Test
- fun handleSeek() {
+ fun onSeek() {
whenever(mockController.getTransportControls()).thenReturn(mockTransport)
viewModel.updateController(mockController)
// WHEN user input is dispatched
@@ -258,19 +259,73 @@ public class SeekBarViewModelTest : SysuiTestCase() {
}
@Test
- fun handleProgressChangedUser() {
+ fun onSeekWithFalse() {
whenever(mockController.getTransportControls()).thenReturn(mockTransport)
viewModel.updateController(mockController)
+ // WHEN a false is received during the seek gesture
+ val pos = 42L
+ with(viewModel) {
+ onSeekStarting()
+ onSeekFalse()
+ onSeek(pos)
+ }
+ fakeExecutor.runAllReady()
+ // THEN the seek is rejected and the transport never receives seekTo
+ verify(mockTransport, never()).seekTo(pos)
+ }
+
+ @Test
+ fun onSeekProgress() {
+ val pos = 42L
+ with(viewModel) {
+ onSeekStarting()
+ onSeekProgress(pos)
+ }
+ fakeExecutor.runAllReady()
+ // THEN then elapsed time should be updated
+ assertThat(viewModel.progress.value!!.elapsedTime).isEqualTo(pos)
+ }
+
+ @Test
+ fun onSeekProgressWithSeekStarting() {
+ val pos = 42L
+ with(viewModel) {
+ onSeekProgress(pos)
+ }
+ fakeExecutor.runAllReady()
+ // THEN then elapsed time should not be updated
+ assertThat(viewModel.progress.value!!.elapsedTime).isNull()
+ }
+
+ @Test
+ fun onProgressChangedFromUser() {
// WHEN user starts dragging the seek bar
val pos = 42
- viewModel.seekBarListener.onProgressChanged(SeekBar(context), pos, true)
+ val bar = SeekBar(context)
+ with(viewModel.seekBarListener) {
+ onStartTrackingTouch(bar)
+ onProgressChanged(bar, pos, true)
+ }
fakeExecutor.runAllReady()
- // THEN transport controls should be used
- verify(mockTransport).seekTo(pos.toLong())
+ // THEN then elapsed time should be updated
+ assertThat(viewModel.progress.value!!.elapsedTime).isEqualTo(pos)
+ }
+
+ @Test
+ fun onProgressChangedFromUserWithoutStartTrackingTouch() {
+ // WHEN user starts dragging the seek bar
+ val pos = 42
+ val bar = SeekBar(context)
+ with(viewModel.seekBarListener) {
+ onProgressChanged(bar, pos, true)
+ }
+ fakeExecutor.runAllReady()
+ // THEN then elapsed time should not be updated
+ assertThat(viewModel.progress.value!!.elapsedTime).isNull()
}
@Test
- fun handleProgressChangedOther() {
+ fun onProgressChangedNotFromUser() {
whenever(mockController.getTransportControls()).thenReturn(mockTransport)
viewModel.updateController(mockController)
// WHEN user starts dragging the seek bar
@@ -282,7 +337,7 @@ public class SeekBarViewModelTest : SysuiTestCase() {
}
@Test
- fun handleStartTrackingTouch() {
+ fun onStartTrackingTouch() {
whenever(mockController.getTransportControls()).thenReturn(mockTransport)
viewModel.updateController(mockController)
// WHEN user starts dragging the seek bar
@@ -297,7 +352,7 @@ public class SeekBarViewModelTest : SysuiTestCase() {
}
@Test
- fun handleStopTrackingTouch() {
+ fun onStopTrackingTouch() {
whenever(mockController.getTransportControls()).thenReturn(mockTransport)
viewModel.updateController(mockController)
// WHEN user ends drag
@@ -312,6 +367,26 @@ public class SeekBarViewModelTest : SysuiTestCase() {
}
@Test
+ fun onStopTrackingTouchAfterProgress() {
+ whenever(mockController.getTransportControls()).thenReturn(mockTransport)
+ viewModel.updateController(mockController)
+ // WHEN user starts dragging the seek bar
+ val pos = 42
+ val progPos = 84
+ val bar = SeekBar(context).apply {
+ progress = pos
+ }
+ with(viewModel.seekBarListener) {
+ onStartTrackingTouch(bar)
+ onProgressChanged(bar, progPos, true)
+ onStopTrackingTouch(bar)
+ }
+ fakeExecutor.runAllReady()
+ // THEN then elapsed time should be updated
+ verify(mockTransport).seekTo(eq(pos.toLong()))
+ }
+
+ @Test
fun queuePollTaskWhenPlaying() {
// GIVEN that the track is playing
val state = PlaybackState.Builder().run {
@@ -369,7 +444,7 @@ public class SeekBarViewModelTest : SysuiTestCase() {
}
// AND the playback state is playing
val state = PlaybackState.Builder().run {
- setState(PlaybackState.STATE_STOPPED, 200L, 1f)
+ setState(PlaybackState.STATE_PLAYING, 200L, 1f)
build()
}
whenever(mockController.getPlaybackState()).thenReturn(state)
@@ -398,6 +473,90 @@ public class SeekBarViewModelTest : SysuiTestCase() {
}
@Test
+ fun noQueuePollTaskWhenSeeking() {
+ // GIVEN listening
+ viewModel.listening = true
+ // AND the playback state is playing
+ val state = PlaybackState.Builder().run {
+ setState(PlaybackState.STATE_PLAYING, 200L, 1f)
+ build()
+ }
+ whenever(mockController.getPlaybackState()).thenReturn(state)
+ viewModel.updateController(mockController)
+ with(fakeExecutor) {
+ advanceClockToNext()
+ runAllReady()
+ }
+ // WHEN seek starts
+ viewModel.onSeekStarting()
+ with(fakeExecutor) {
+ advanceClockToNext()
+ runAllReady()
+ }
+ // THEN an update task is not queued because we don't want it fighting with the user when
+ // they are trying to move the thumb.
+ assertThat(fakeExecutor.numPending()).isEqualTo(0)
+ }
+
+ @Test
+ fun queuePollTaskWhenDoneSeekingWithFalse() {
+ // GIVEN listening
+ viewModel.listening = true
+ // AND the playback state is playing
+ val state = PlaybackState.Builder().run {
+ setState(PlaybackState.STATE_PLAYING, 200L, 1f)
+ build()
+ }
+ whenever(mockController.getPlaybackState()).thenReturn(state)
+ viewModel.updateController(mockController)
+ with(fakeExecutor) {
+ advanceClockToNext()
+ runAllReady()
+ }
+ // WHEN seek finishes after a false
+ with(viewModel) {
+ onSeekStarting()
+ onSeekFalse()
+ onSeek(42L)
+ }
+ with(fakeExecutor) {
+ advanceClockToNext()
+ runAllReady()
+ }
+ // THEN an update task is queued because the gesture was ignored and progress was restored.
+ assertThat(fakeExecutor.numPending()).isEqualTo(1)
+ }
+
+ @Test
+ fun noQueuePollTaskWhenDoneSeeking() {
+ // GIVEN listening
+ viewModel.listening = true
+ // AND the playback state is playing
+ val state = PlaybackState.Builder().run {
+ setState(PlaybackState.STATE_PLAYING, 200L, 1f)
+ build()
+ }
+ whenever(mockController.getPlaybackState()).thenReturn(state)
+ viewModel.updateController(mockController)
+ with(fakeExecutor) {
+ advanceClockToNext()
+ runAllReady()
+ }
+ // WHEN seek finishes after a false
+ with(viewModel) {
+ onSeekStarting()
+ onSeek(42L)
+ }
+ with(fakeExecutor) {
+ advanceClockToNext()
+ runAllReady()
+ }
+ // THEN no update task is queued because we are waiting for an updated playback state to be
+ // returned in response to the seek.
+ assertThat(fakeExecutor.numPending()).isEqualTo(0)
+ }
+
+ @Test
fun startListeningQueuesPollTask() {
// GIVEN not listening
viewModel.listening = false