blob: 28ab83c83a42c272223fbdfa07d1aa26c2cab344 [file] [log] [blame]
/*
* 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.
*/
package com.android.systemui.navigationbar.gestural
import android.content.Context
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Point
import android.os.Handler
import android.os.SystemClock
import android.os.VibrationEffect
import android.util.Log
import android.util.MathUtils.constrain
import android.util.MathUtils.saturate
import android.view.Gravity
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.View
import android.view.ViewConfiguration
import android.view.WindowManager
import android.view.animation.DecelerateInterpolator
import android.view.animation.PathInterpolator
import android.window.BackEvent
import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringForce
import com.android.internal.util.LatencyTracker
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.plugins.NavigationEdgeBackPlugin
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.statusbar.policy.ConfigurationController
import com.android.systemui.util.ViewController
import com.android.wm.shell.back.BackAnimation
import java.io.PrintWriter
import javax.inject.Inject
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sign
private const val TAG = "BackPanelController"
private const val DEBUG = false
private const val ENABLE_FAILSAFE = true
private const val FAILSAFE_DELAY_MS: Long = 350
/**
* The time required between the arrow-appears vibration effect and the back-committed vibration
* effect. If the arrow is flung quickly, the phone only vibrates once. However, if the arrow is
* held on the screen for a long time, it will vibrate a second time when the back gesture is
* committed.
*/
private const val GESTURE_DURATION_FOR_CLICK_MS = 400
/**
* The min duration arrow remains on screen during a fling event.
*/
private const val FLING_MIN_APPEARANCE_DURATION = 235L
/**
* The min duration arrow remains on screen during a fling event.
*/
private const val MIN_FLING_VELOCITY = 3000
/**
* The amount of rubber banding we do for the vertical translation
*/
private const val RUBBER_BAND_AMOUNT = 15
private const val ARROW_APPEAR_STIFFNESS = 600f
private const val ARROW_APPEAR_DAMPING_RATIO = 0.4f
private const val ARROW_DISAPPEAR_STIFFNESS = 1200f
private const val ARROW_DISAPPEAR_DAMPING_RATIO = SpringForce.DAMPING_RATIO_NO_BOUNCY
/**
* The interpolator used to rubber band
*/
private val RUBBER_BAND_INTERPOLATOR = PathInterpolator(1.0f / 5.0f, 1.0f, 1.0f, 1.0f)
private val DECELERATE_INTERPOLATOR = DecelerateInterpolator()
private val DECELERATE_INTERPOLATOR_SLOW = DecelerateInterpolator(0.7f)
class BackPanelController private constructor(
context: Context,
private var backAnimation: BackAnimation?,
private val windowManager: WindowManager,
private val viewConfiguration: ViewConfiguration,
@Main private val mainHandler: Handler,
private val vibratorHelper: VibratorHelper,
private val configurationController: ConfigurationController,
latencyTracker: LatencyTracker
) : ViewController<BackPanel>(BackPanel(context, latencyTracker)), NavigationEdgeBackPlugin {
/**
* Injectable instance to create a new BackPanelController.
*
* Necessary because EdgeBackGestureHandler sometimes needs to create new instances of
* BackPanelController, and we need to match EdgeBackGestureHandler's context.
*/
class Factory @Inject constructor(
private val windowManager: WindowManager,
private val viewConfiguration: ViewConfiguration,
@Main private val mainHandler: Handler,
private val vibratorHelper: VibratorHelper,
private val configurationController: ConfigurationController,
private val latencyTracker: LatencyTracker
) {
/** Construct a [BackPanelController]. */
fun create(context: Context, backAnimation: BackAnimation?): BackPanelController {
val backPanelController = BackPanelController(
context,
backAnimation,
windowManager,
viewConfiguration,
mainHandler,
vibratorHelper,
configurationController,
latencyTracker
)
backPanelController.init()
return backPanelController
}
}
private var params: EdgePanelParams = EdgePanelParams(resources)
private var currentState: GestureState = GestureState.GONE
private var previousState: GestureState = GestureState.GONE
// Phone should only vibrate the first time the arrow is activated
private var hasHapticPlayed = false
// Screen attributes
private lateinit var layoutParams: WindowManager.LayoutParams
private val displaySize = Point()
private lateinit var backCallback: NavigationEdgeBackPlugin.BackCallback
private var previousXTranslation = 0f
private var totalTouchDelta = 0f
private var velocityTracker: VelocityTracker? = null
set(value) {
if (field != value) field?.recycle()
field = value
}
get() {
if (field == null) field = VelocityTracker.obtain()
return field
}
// The x,y position of the first touch event
private var startX = 0f
private var startY = 0f
private var gestureStartTime = 0L
// Whether the current gesture has moved a sufficiently large amount,
// so that we can unambiguously start showing the ENTRY animation
private var hasPassedDragSlop = false
private val failsafeRunnable = Runnable { onFailsafe() }
private enum class GestureState {
/* Arrow is off the screen and invisible */
GONE,
/* Arrow is animating in */
ENTRY,
/* could be entry, neutral, or stretched, releasing will commit back */
ACTIVE,
/* releasing will cancel back */
INACTIVE,
/* like committed, but animation takes longer */
FLUNG,
/* back action currently occurring, arrow soon to be GONE */
COMMITTED,
/* back action currently cancelling, arrow soon to be GONE */
CANCELLED;
/**
* @return true if the current state responds to touch move events in some way (e.g. by
* stretching the back indicator)
*/
fun isInteractive(): Boolean {
return when (this) {
ENTRY, ACTIVE, INACTIVE -> true
GONE, FLUNG, COMMITTED, CANCELLED -> false
}
}
}
/**
* Wrapper around OnAnimationEndListener which runs the given runnable after a delay. The
* runnable is not called if the animation is cancelled
*/
inner class DelayedOnAnimationEndListener internal constructor(
private val handler: Handler,
private val runnable: Runnable,
private val minDuration: Long
) : DynamicAnimation.OnAnimationEndListener {
override fun onAnimationEnd(
animation: DynamicAnimation<*>,
canceled: Boolean,
value: Float,
velocity: Float
) {
animation.removeEndListener(this)
if (!canceled) {
// Total elapsed time of the gesture and the animation
val totalElapsedTime = SystemClock.uptimeMillis() - gestureStartTime
// The delay between finishing this animation and starting the runnable
val delay = max(0, minDuration - totalElapsedTime)
handler.postDelayed(runnable, delay)
}
}
internal fun runNow() {
runnable.run()
}
}
private val setCommittedEndListener =
DelayedOnAnimationEndListener(
mainHandler,
{ updateArrowState(GestureState.COMMITTED) },
minDuration = FLING_MIN_APPEARANCE_DURATION
)
private val setGoneEndListener =
DelayedOnAnimationEndListener(
mainHandler,
{
cancelFailsafe()
updateArrowState(GestureState.GONE)
},
minDuration = 0
)
// Vibration
private var vibrationTime: Long = 0
// Minimum of the screen's width or the predefined threshold
private var fullyStretchedThreshold = 0f
/**
* Used for initialization and configuration changes
*/
private fun updateConfiguration() {
params.update(resources)
updateBackAnimationSwipeThresholds()
mView.updateArrowPaint(params.arrowThickness)
}
private val configurationListener = object : ConfigurationController.ConfigurationListener {
override fun onConfigChanged(newConfig: Configuration?) {
updateConfiguration()
}
override fun onLayoutDirectionChanged(isLayoutRtl: Boolean) {
updateArrowDirection(isLayoutRtl)
}
}
override fun onViewAttached() {
updateConfiguration()
updateArrowDirection(configurationController.isLayoutRtl)
updateArrowState(GestureState.GONE, force = true)
updateRestingArrowDimens(animated = false, currentState)
configurationController.addCallback(configurationListener)
}
/** Update the arrow direction. The arrow should point the same way for both panels. */
private fun updateArrowDirection(isLayoutRtl: Boolean) {
mView.arrowsPointLeft = isLayoutRtl
}
override fun onViewDetached() {
configurationController.removeCallback(configurationListener)
}
override fun onMotionEvent(event: MotionEvent) {
backAnimation?.onBackMotion(
event.x,
event.y,
event.actionMasked,
if (mView.isLeftPanel) BackEvent.EDGE_LEFT else BackEvent.EDGE_RIGHT
)
velocityTracker!!.addMovement(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
resetOnDown()
startX = event.x
startY = event.y
gestureStartTime = SystemClock.uptimeMillis()
}
MotionEvent.ACTION_MOVE -> {
// only go to the ENTRY state after some minimum motion has occurred
if (dragSlopExceeded(event.x, startX)) {
handleMoveEvent(event)
}
}
MotionEvent.ACTION_UP -> {
if (currentState == GestureState.ACTIVE) {
updateArrowState(if (isFlung()) GestureState.FLUNG else GestureState.COMMITTED)
} else if (currentState != GestureState.GONE) { // if invisible, skip animation
updateArrowState(GestureState.CANCELLED)
}
velocityTracker = null
}
MotionEvent.ACTION_CANCEL -> {
// Receiving a CANCEL implies that something else intercepted
// the gesture, i.e., the user did not cancel their gesture.
// Therefore, disappear immediately, with minimum fanfare.
updateArrowState(GestureState.GONE)
velocityTracker = null
}
}
}
/**
* Returns false until the current gesture exceeds the touch slop threshold,
* and returns true thereafter (we reset on the subsequent back gesture).
* The moment it switches from false -> true is important,
* because that's when we switch state, from GONE -> ENTRY.
* @return whether the current gesture has moved past a minimum threshold.
*/
private fun dragSlopExceeded(curX: Float, startX: Float): Boolean {
if (hasPassedDragSlop) return true
if (abs(curX - startX) > viewConfiguration.scaledTouchSlop) {
// Reset the arrow to the side
updateArrowState(GestureState.ENTRY)
windowManager.updateViewLayout(mView, layoutParams)
mView.startTrackingShowBackArrowLatency()
hasPassedDragSlop = true
}
return hasPassedDragSlop
}
private fun updateArrowStateOnMove(yTranslation: Float, xTranslation: Float) {
if (!currentState.isInteractive())
return
when (currentState) {
// Check if we should transition from ENTRY to ACTIVE
GestureState.ENTRY ->
if (xTranslation > params.swipeTriggerThreshold) {
updateArrowState(GestureState.ACTIVE)
}
// Abort if we had continuous motion toward the edge for a while, OR the direction
// in Y is bigger than X * 2
GestureState.ACTIVE ->
if ((totalTouchDelta < 0 && -totalTouchDelta > params.minDeltaForSwitch) ||
(yTranslation > xTranslation * 2)
) {
updateArrowState(GestureState.INACTIVE)
}
// Re-activate if we had continuous motion away from the edge for a while
GestureState.INACTIVE ->
if (totalTouchDelta > 0 && totalTouchDelta > params.minDeltaForSwitch) {
updateArrowState(GestureState.ACTIVE)
}
// By default assume the current direction is kept
else -> {}
}
}
private fun handleMoveEvent(event: MotionEvent) {
if (!currentState.isInteractive())
return
val x = event.x
val y = event.y
val yOffset = y - startY
// How far in the y direction we are from the original touch
val yTranslation = abs(yOffset)
// How far in the x direction we are from the original touch ignoring motion that
// occurs between the screen edge and the touch start.
val xTranslation = max(0f, if (mView.isLeftPanel) x - startX else startX - x)
// Compared to last time, how far we moved in the x direction. If <0, we are moving closer
// to the edge. If >0, we are moving further from the edge
val xDelta = xTranslation - previousXTranslation
previousXTranslation = xTranslation
if (abs(xDelta) > 0) {
if (sign(xDelta) == sign(totalTouchDelta)) {
// Direction has NOT changed, so keep counting the delta
totalTouchDelta += xDelta
} else {
// Direction has changed, so reset the delta
totalTouchDelta = xDelta
}
}
updateArrowStateOnMove(yTranslation, xTranslation)
when (currentState) {
GestureState.ACTIVE ->
stretchActiveBackIndicator(fullScreenStretchProgress(xTranslation))
GestureState.ENTRY ->
stretchEntryBackIndicator(preThresholdStretchProgress(xTranslation))
GestureState.INACTIVE ->
mView.resetStretch()
}
// set y translation
setVerticalTranslation(yOffset)
}
private fun setVerticalTranslation(yOffset: Float) {
val yTranslation = abs(yOffset)
val maxYOffset = (mView.height - params.entryIndicator.backgroundDimens.height) / 2f
val yProgress = saturate(yTranslation / (maxYOffset * RUBBER_BAND_AMOUNT))
mView.animateVertically(
RUBBER_BAND_INTERPOLATOR.getInterpolation(yProgress) * maxYOffset *
sign(yOffset)
)
}
/**
* @return the relative position of the drag from the time after the arrow is activated until
* the arrow is fully stretched (between 0.0 - 1.0f)
*/
private fun fullScreenStretchProgress(xTranslation: Float): Float {
return saturate(
(xTranslation - params.swipeTriggerThreshold) /
(fullyStretchedThreshold - params.swipeTriggerThreshold)
)
}
/**
* Tracks the relative position of the drag from the entry until the threshold where the arrow
* activates (between 0.0 - 1.0f)
*/
private fun preThresholdStretchProgress(xTranslation: Float): Float {
return saturate(xTranslation / params.swipeTriggerThreshold)
}
private fun stretchActiveBackIndicator(progress: Float) {
val rubberBandIterpolation = RUBBER_BAND_INTERPOLATOR.getInterpolation(progress)
mView.setStretch(
horizontalTranslationStretchAmount = rubberBandIterpolation,
arrowStretchAmount = rubberBandIterpolation,
backgroundWidthStretchAmount = DECELERATE_INTERPOLATOR_SLOW.getInterpolation(progress),
params.fullyStretchedIndicator
)
}
private fun stretchEntryBackIndicator(progress: Float) {
mView.setStretch(
horizontalTranslationStretchAmount = 0f,
arrowStretchAmount = RUBBER_BAND_INTERPOLATOR.getInterpolation(progress),
backgroundWidthStretchAmount = DECELERATE_INTERPOLATOR.getInterpolation(progress),
params.preThresholdIndicator
)
}
fun setBackAnimation(backAnimation: BackAnimation?) {
this.backAnimation = backAnimation
updateBackAnimationSwipeThresholds()
}
private fun updateBackAnimationSwipeThresholds() {
backAnimation?.setSwipeThresholds(
params.swipeTriggerThreshold,
fullyStretchedThreshold
)
}
override fun onDestroy() {
cancelFailsafe()
windowManager.removeView(mView)
}
override fun setIsLeftPanel(isLeftPanel: Boolean) {
mView.isLeftPanel = isLeftPanel
layoutParams.gravity = if (isLeftPanel) {
Gravity.LEFT or Gravity.TOP
} else {
Gravity.RIGHT or Gravity.TOP
}
}
override fun setInsets(insetLeft: Int, insetRight: Int) {
}
override fun setBackCallback(callback: NavigationEdgeBackPlugin.BackCallback) {
backCallback = callback
}
override fun setLayoutParams(layoutParams: WindowManager.LayoutParams) {
this.layoutParams = layoutParams
windowManager.addView(mView, layoutParams)
}
private fun isFlung() = velocityTracker!!.run {
computeCurrentVelocity(1000)
abs(xVelocity) > MIN_FLING_VELOCITY
}
private fun playFlingBackAnimation() {
playAnimation(setCommittedEndListener)
}
private fun playCommitBackAnimation() {
// Check if we should vibrate again
if (previousState != GestureState.FLUNG) {
backCallback.triggerBack()
velocityTracker!!.computeCurrentVelocity(1000)
val isSlow = abs(velocityTracker!!.xVelocity) < 500
val hasNotVibratedRecently =
SystemClock.uptimeMillis() - vibrationTime >= GESTURE_DURATION_FOR_CLICK_MS
if (isSlow || hasNotVibratedRecently) {
vibratorHelper.vibrate(VibrationEffect.EFFECT_CLICK)
}
}
playAnimation(setGoneEndListener)
}
private fun playCancelBackAnimation() {
backCallback.cancelBack()
playAnimation(setGoneEndListener)
}
/**
* @return true if the animation is running, false otherwise. Some transitions don't animate
*/
private fun playAnimation(endListener: DelayedOnAnimationEndListener) {
updateRestingArrowDimens(animated = true, currentState)
if (!mView.addEndListener(endListener)) {
scheduleFailsafe()
}
}
private fun resetOnDown() {
hasPassedDragSlop = false
hasHapticPlayed = false
totalTouchDelta = 0f
vibrationTime = 0
cancelFailsafe()
backAnimation?.setTriggerBack(false)
}
private fun updateYPosition(touchY: Float) {
var yPosition = touchY - params.fingerOffset
yPosition = max(yPosition, params.minArrowYPosition.toFloat())
yPosition -= layoutParams.height / 2.0f
layoutParams.y = constrain(yPosition.toInt(), 0, displaySize.y)
}
override fun setDisplaySize(displaySize: Point) {
this.displaySize.set(displaySize.x, displaySize.y)
fullyStretchedThreshold = min(displaySize.x.toFloat(), params.swipeProgressThreshold)
updateBackAnimationSwipeThresholds()
}
/**
* Updates resting arrow and background size not accounting for stretch
*/
private fun updateRestingArrowDimens(animated: Boolean, currentState: GestureState) {
if (animated) {
when (currentState) {
GestureState.ENTRY, GestureState.ACTIVE, GestureState.FLUNG ->
mView.setArrowStiffness(ARROW_APPEAR_STIFFNESS, ARROW_APPEAR_DAMPING_RATIO)
GestureState.CANCELLED -> mView.fadeOut()
else ->
mView.setArrowStiffness(
ARROW_DISAPPEAR_STIFFNESS,
ARROW_DISAPPEAR_DAMPING_RATIO
)
}
}
mView.setRestingDimens(
restingParams = EdgePanelParams.BackIndicatorDimens(
horizontalTranslation = when (currentState) {
GestureState.GONE -> -params.activeIndicator.backgroundDimens.width
// Position the committed arrow slightly further off the screen so we do not
// see part of it bouncing
GestureState.COMMITTED ->
-params.activeIndicator.backgroundDimens.width * 1.5f
GestureState.FLUNG -> params.fullyStretchedIndicator.horizontalTranslation
GestureState.ACTIVE -> params.activeIndicator.horizontalTranslation
GestureState.ENTRY, GestureState.INACTIVE, GestureState.CANCELLED ->
params.entryIndicator.horizontalTranslation
},
arrowDimens = when (currentState) {
GestureState.ACTIVE, GestureState.INACTIVE,
GestureState.COMMITTED, GestureState.FLUNG -> params.activeIndicator.arrowDimens
GestureState.CANCELLED -> params.cancelledArrowDimens
GestureState.GONE, GestureState.ENTRY -> params.entryIndicator.arrowDimens
},
backgroundDimens = when (currentState) {
GestureState.GONE, GestureState.ENTRY -> params.entryIndicator.backgroundDimens
else ->
params.activeIndicator.backgroundDimens.copy(
edgeCornerRadius =
if (currentState == GestureState.INACTIVE ||
currentState == GestureState.CANCELLED
)
params.entryIndicator.backgroundDimens.edgeCornerRadius
else
params.activeIndicator.backgroundDimens.edgeCornerRadius
)
}
),
animate = animated
)
}
/**
* Update arrow state. If state has not changed, this is a no-op.
*
* Transitioning to active/inactive will indicate whether or not releasing touch will trigger
* the back action.
*/
private fun updateArrowState(newState: GestureState, force: Boolean = false) {
if (!force && currentState == newState) return
if (DEBUG) Log.d(TAG, "updateArrowState $currentState -> $newState")
previousState = currentState
currentState = newState
if (currentState == GestureState.GONE) {
mView.cancelAlphaAnimations()
mView.visibility = View.GONE
} else {
mView.visibility = View.VISIBLE
}
when (currentState) {
// Transitioning to GONE never animates since the arrow is (presumably) already off the
// screen
GestureState.GONE -> updateRestingArrowDimens(animated = false, currentState)
GestureState.ENTRY -> {
updateYPosition(startY)
updateRestingArrowDimens(animated = true, currentState)
}
GestureState.ACTIVE -> {
backAnimation?.setTriggerBack(true)
updateRestingArrowDimens(animated = true, currentState)
// Vibrate the first time we transition to ACTIVE
if (!hasHapticPlayed) {
hasHapticPlayed = true
vibrationTime = SystemClock.uptimeMillis()
vibratorHelper.vibrate(VibrationEffect.EFFECT_TICK)
}
}
GestureState.INACTIVE -> {
backAnimation?.setTriggerBack(false)
updateRestingArrowDimens(animated = true, currentState)
}
GestureState.FLUNG -> playFlingBackAnimation()
GestureState.COMMITTED -> playCommitBackAnimation()
GestureState.CANCELLED -> playCancelBackAnimation()
}
}
private fun scheduleFailsafe() {
if (!ENABLE_FAILSAFE) return
cancelFailsafe()
if (DEBUG) Log.d(TAG, "scheduleFailsafe")
mainHandler.postDelayed(failsafeRunnable, FAILSAFE_DELAY_MS)
}
private fun cancelFailsafe() {
if (DEBUG) Log.d(TAG, "cancelFailsafe")
mainHandler.removeCallbacks(failsafeRunnable)
}
private fun onFailsafe() {
if (DEBUG) Log.d(TAG, "onFailsafe")
updateArrowState(GestureState.GONE, force = true)
}
override fun dump(pw: PrintWriter) {
pw.println("$TAG:")
pw.println(" currentState=$currentState")
pw.println(" isLeftPanel=$mView.isLeftPanel")
}
init {
if (DEBUG) mView.drawDebugInfo = { canvas ->
val debugStrings = listOf(
"$currentState",
"startX=$startX",
"startY=$startY",
"xDelta=${"%.1f".format(totalTouchDelta)}",
"xTranslation=${"%.1f".format(previousXTranslation)}",
"pre=${"%.0f".format(preThresholdStretchProgress(previousXTranslation) * 100)}%",
"post=${"%.0f".format(fullScreenStretchProgress(previousXTranslation) * 100)}%"
)
val debugPaint = Paint().apply {
color = Color.WHITE
}
val debugInfoBottom = debugStrings.size * 32f + 4f
canvas.drawRect(
4f,
4f,
canvas.width.toFloat(),
debugStrings.size * 32f + 4f,
debugPaint
)
debugPaint.apply {
color = Color.BLACK
textSize = 32f
}
var offset = 32f
for (debugText in debugStrings) {
canvas.drawText(debugText, 10f, offset, debugPaint)
offset += 32f
}
debugPaint.apply {
color = Color.RED
style = Paint.Style.STROKE
strokeWidth = 4f
}
val canvasWidth = canvas.width.toFloat()
val canvasHeight = canvas.height.toFloat()
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight, debugPaint)
fun drawVerticalLine(x: Float, color: Int) {
debugPaint.color = color
val x = if (mView.isLeftPanel) x else canvasWidth - x
canvas.drawLine(x, debugInfoBottom, x, canvas.height.toFloat(), debugPaint)
}
drawVerticalLine(x = params.swipeTriggerThreshold, color = Color.BLUE)
drawVerticalLine(x = startX, color = Color.GREEN)
drawVerticalLine(x = previousXTranslation, color = Color.DKGRAY)
}
}
}