diff options
| author | 2020-06-11 00:06:11 -0400 | |
|---|---|---|
| committer | 2020-06-11 16:23:00 -0400 | |
| commit | 21d133ed1a5e05c520e3c157c9ad00818864d10e (patch) | |
| tree | b5a01a425749962e50cf99650ed36b19296f1837 | |
| parent | 77d3dcab6fc431782c0aa8784aca32fa6d867496 (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.kt | 125 | ||||
| -rw-r--r-- | packages/SystemUI/tests/src/com/android/systemui/media/SeekBarViewModelTest.kt | 179 |
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 |