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 package com.android.systemui.navigationbar.gestural 17 18 import android.content.Context 19 import android.content.res.Configuration 20 import android.graphics.Color 21 import android.graphics.Paint 22 import android.graphics.Point 23 import android.os.Handler 24 import android.os.SystemClock 25 import android.os.VibrationEffect 26 import android.util.Log 27 import android.util.MathUtils 28 import android.view.Gravity 29 import android.view.HapticFeedbackConstants 30 import android.view.MotionEvent 31 import android.view.VelocityTracker 32 import android.view.ViewConfiguration 33 import android.view.WindowManager 34 import androidx.annotation.VisibleForTesting 35 import androidx.core.os.postDelayed 36 import androidx.core.view.isVisible 37 import androidx.dynamicanimation.animation.DynamicAnimation 38 import com.android.internal.util.LatencyTracker 39 import com.android.systemui.dagger.qualifiers.Main 40 import com.android.systemui.flags.FeatureFlags 41 import com.android.systemui.flags.Flags.ONE_WAY_HAPTICS_API_MIGRATION 42 import com.android.systemui.plugins.NavigationEdgeBackPlugin 43 import com.android.systemui.statusbar.VibratorHelper 44 import com.android.systemui.statusbar.policy.ConfigurationController 45 import com.android.systemui.util.ViewController 46 import java.io.PrintWriter 47 import javax.inject.Inject 48 import kotlin.math.abs 49 import kotlin.math.max 50 import kotlin.math.min 51 import kotlin.math.sign 52 53 private const val TAG = "BackPanelController" 54 private const val ENABLE_FAILSAFE = true 55 private const val FAILSAFE_DELAY_MS = 350L 56 57 private const val PX_PER_SEC = 1000 58 private const val PX_PER_MS = 1 59 60 internal const val MIN_DURATION_ACTIVE_BEFORE_INACTIVE_ANIMATION = 300L 61 private const val MIN_DURATION_ACTIVE_AFTER_INACTIVE_ANIMATION = 130L 62 private const val MIN_DURATION_CANCELLED_ANIMATION = 200L 63 private const val MIN_DURATION_COMMITTED_ANIMATION = 80L 64 private const val MIN_DURATION_COMMITTED_AFTER_FLING_ANIMATION = 120L 65 private const val MIN_DURATION_INACTIVE_BEFORE_FLUNG_ANIMATION = 50L 66 private const val MIN_DURATION_INACTIVE_BEFORE_ACTIVE_ANIMATION = 160F 67 private const val MIN_DURATION_ENTRY_BEFORE_ACTIVE_ANIMATION = 10F 68 internal const val MAX_DURATION_ENTRY_BEFORE_ACTIVE_ANIMATION = 100F 69 private const val MIN_DURATION_FLING_ANIMATION = 160L 70 71 private const val MIN_DURATION_ENTRY_TO_ACTIVE_CONSIDERED_AS_FLING = 100L 72 private const val MIN_DURATION_INACTIVE_TO_ACTIVE_CONSIDERED_AS_FLING = 400L 73 74 private const val POP_ON_FLING_DELAY = 60L 75 private const val POP_ON_FLING_VELOCITY = 2f 76 private const val POP_ON_COMMITTED_VELOCITY = 3f 77 private const val POP_ON_ENTRY_TO_ACTIVE_VELOCITY = 4.5f 78 private const val POP_ON_INACTIVE_TO_ACTIVE_VELOCITY = 4.7f 79 private const val POP_ON_INACTIVE_VELOCITY = -1.5f 80 81 internal val VIBRATE_ACTIVATED_EFFECT = 82 VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK) 83 84 internal val VIBRATE_DEACTIVATED_EFFECT = 85 VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK) 86 87 private const val DEBUG = false 88 89 class BackPanelController 90 internal constructor( 91 context: Context, 92 private val windowManager: WindowManager, 93 private val viewConfiguration: ViewConfiguration, 94 @Main private val mainHandler: Handler, 95 private val vibratorHelper: VibratorHelper, 96 private val configurationController: ConfigurationController, 97 private val latencyTracker: LatencyTracker, 98 private val featureFlags: FeatureFlags 99 ) : ViewController<BackPanel>(BackPanel(context, latencyTracker)), NavigationEdgeBackPlugin { 100 101 /** 102 * Injectable instance to create a new BackPanelController. 103 * 104 * Necessary because EdgeBackGestureHandler sometimes needs to create new instances of 105 * BackPanelController, and we need to match EdgeBackGestureHandler's context. 106 */ 107 class Factory 108 @Inject 109 constructor( 110 private val windowManager: WindowManager, 111 private val viewConfiguration: ViewConfiguration, 112 @Main private val mainHandler: Handler, 113 private val vibratorHelper: VibratorHelper, 114 private val configurationController: ConfigurationController, 115 private val latencyTracker: LatencyTracker, 116 private val featureFlags: FeatureFlags 117 ) { 118 /** Construct a [BackPanelController]. */ 119 fun create(context: Context): BackPanelController { 120 val backPanelController = 121 BackPanelController( 122 context, 123 windowManager, 124 viewConfiguration, 125 mainHandler, 126 vibratorHelper, 127 configurationController, 128 latencyTracker, 129 featureFlags 130 ) 131 backPanelController.init() 132 return backPanelController 133 } 134 } 135 136 @VisibleForTesting internal var params: EdgePanelParams = EdgePanelParams(resources) 137 @VisibleForTesting internal var currentState: GestureState = GestureState.GONE 138 private var previousState: GestureState = GestureState.GONE 139 140 // Screen attributes 141 private lateinit var layoutParams: WindowManager.LayoutParams 142 private val displaySize = Point() 143 144 private lateinit var backCallback: NavigationEdgeBackPlugin.BackCallback 145 private var previousXTranslationOnActiveOffset = 0f 146 private var previousXTranslation = 0f 147 private var totalTouchDeltaActive = 0f 148 private var totalTouchDeltaInactive = 0f 149 private var touchDeltaStartX = 0f 150 private var velocityTracker: VelocityTracker? = null 151 set(value) { 152 if (field != value) field?.recycle() 153 field = value 154 } 155 get() { 156 if (field == null) field = VelocityTracker.obtain() 157 return field 158 } 159 160 // The x,y position of the first touch event 161 private var startX = 0f 162 private var startY = 0f 163 private var startIsLeft: Boolean? = null 164 165 private var gestureEntryTime = 0L 166 private var gestureInactiveTime = 0L 167 168 private val elapsedTimeSinceInactive 169 get() = SystemClock.uptimeMillis() - gestureInactiveTime 170 private val elapsedTimeSinceEntry 171 get() = SystemClock.uptimeMillis() - gestureEntryTime 172 173 private var pastThresholdWhileEntryOrInactiveTime = 0L 174 private var entryToActiveDelay = 0F 175 private val entryToActiveDelayCalculation = { 176 convertVelocityToAnimationFactor( 177 valueOnFastVelocity = MIN_DURATION_ENTRY_BEFORE_ACTIVE_ANIMATION, 178 valueOnSlowVelocity = MAX_DURATION_ENTRY_BEFORE_ACTIVE_ANIMATION, 179 ) 180 } 181 182 // Whether the current gesture has moved a sufficiently large amount, 183 // so that we can unambiguously start showing the ENTRY animation 184 private var hasPassedDragSlop = false 185 186 // Distance in pixels a drag can be considered for a fling event 187 private var minFlingDistance = 0 188 189 private val failsafeRunnable = Runnable { onFailsafe() } 190 191 internal enum class GestureState { 192 /* Arrow is off the screen and invisible */ 193 GONE, 194 195 /* Arrow is animating in */ 196 ENTRY, 197 198 /* could be entry, neutral, or stretched, releasing will commit back */ 199 ACTIVE, 200 201 /* releasing will cancel back */ 202 INACTIVE, 203 204 /* like committed, but animation takes longer */ 205 FLUNG, 206 207 /* back action currently occurring, arrow soon to be GONE */ 208 COMMITTED, 209 210 /* back action currently cancelling, arrow soon to be GONE */ 211 CANCELLED 212 } 213 214 /** 215 * Wrapper around OnAnimationEndListener which runs the given runnable after a delay. The 216 * runnable is not called if the animation is cancelled 217 */ 218 inner class DelayedOnAnimationEndListener 219 internal constructor( 220 private val handler: Handler, 221 private val runnableDelay: Long, 222 val runnable: Runnable, 223 ) : DynamicAnimation.OnAnimationEndListener { 224 225 override fun onAnimationEnd( 226 animation: DynamicAnimation<*>, 227 canceled: Boolean, 228 value: Float, 229 velocity: Float 230 ) { 231 animation.removeEndListener(this) 232 233 if (!canceled) { 234 // The delay between finishing this animation and starting the runnable 235 val delay = max(0, runnableDelay - elapsedTimeSinceEntry) 236 237 handler.postDelayed(runnable, delay) 238 } 239 } 240 241 internal fun run() = runnable.run() 242 } 243 244 private val onEndSetCommittedStateListener = 245 DelayedOnAnimationEndListener(mainHandler, 0L) { updateArrowState(GestureState.COMMITTED) } 246 247 private val onEndSetGoneStateListener = 248 DelayedOnAnimationEndListener(mainHandler, runnableDelay = 0L) { 249 cancelFailsafe() 250 updateArrowState(GestureState.GONE) 251 } 252 253 private val onAlphaEndSetGoneStateListener = 254 DelayedOnAnimationEndListener(mainHandler, 0L) { 255 updateRestingArrowDimens() 256 if (!mView.addAnimationEndListener(mView.backgroundAlpha, onEndSetGoneStateListener)) { 257 scheduleFailsafe() 258 } 259 } 260 261 // Minimum of the screen's width or the predefined threshold 262 private var fullyStretchedThreshold = 0f 263 264 /** Used for initialization and configuration changes */ 265 private fun updateConfiguration() { 266 params.update(resources) 267 mView.updateArrowPaint(params.arrowThickness) 268 minFlingDistance = viewConfiguration.scaledTouchSlop * 3 269 } 270 271 private val configurationListener = 272 object : ConfigurationController.ConfigurationListener { 273 override fun onConfigChanged(newConfig: Configuration?) { 274 updateConfiguration() 275 } 276 277 override fun onLayoutDirectionChanged(isLayoutRtl: Boolean) { 278 updateArrowDirection(isLayoutRtl) 279 } 280 } 281 282 override fun onViewAttached() { 283 updateConfiguration() 284 updateArrowDirection(configurationController.isLayoutRtl) 285 updateArrowState(GestureState.GONE, force = true) 286 updateRestingArrowDimens() 287 configurationController.addCallback(configurationListener) 288 } 289 290 /** Update the arrow direction. The arrow should point the same way for both panels. */ 291 private fun updateArrowDirection(isLayoutRtl: Boolean) { 292 mView.arrowsPointLeft = isLayoutRtl 293 } 294 295 override fun onViewDetached() { 296 configurationController.removeCallback(configurationListener) 297 } 298 299 override fun onMotionEvent(event: MotionEvent) { 300 velocityTracker!!.addMovement(event) 301 when (event.actionMasked) { 302 MotionEvent.ACTION_DOWN -> { 303 cancelAllPendingAnimations() 304 startX = event.x 305 startY = event.y 306 307 updateArrowState(GestureState.GONE) 308 updateYStartPosition(startY) 309 310 // reset animation properties 311 startIsLeft = mView.isLeftPanel 312 hasPassedDragSlop = false 313 mView.resetStretch() 314 } 315 MotionEvent.ACTION_MOVE -> { 316 if (dragSlopExceeded(event.x, startX)) { 317 handleMoveEvent(event) 318 } 319 } 320 MotionEvent.ACTION_UP -> { 321 when (currentState) { 322 GestureState.ENTRY -> { 323 if ( 324 isFlungAwayFromEdge(endX = event.x) || 325 previousXTranslation > params.staticTriggerThreshold 326 ) { 327 updateArrowState(GestureState.FLUNG) 328 } else { 329 updateArrowState(GestureState.CANCELLED) 330 } 331 } 332 GestureState.INACTIVE -> { 333 if (isFlungAwayFromEdge(endX = event.x)) { 334 // This is called outside of updateArrowState so that 335 // BackAnimationController can immediately evaluate state 336 // instead of after the flung delay 337 backCallback.setTriggerBack(true) 338 mainHandler.postDelayed(MIN_DURATION_INACTIVE_BEFORE_FLUNG_ANIMATION) { 339 updateArrowState(GestureState.FLUNG) 340 } 341 } else { 342 updateArrowState(GestureState.CANCELLED) 343 } 344 } 345 GestureState.ACTIVE -> { 346 if ( 347 previousState == GestureState.ENTRY && 348 elapsedTimeSinceEntry < 349 MIN_DURATION_ENTRY_TO_ACTIVE_CONSIDERED_AS_FLING 350 ) { 351 updateArrowState(GestureState.FLUNG) 352 } else if ( 353 previousState == GestureState.INACTIVE && 354 elapsedTimeSinceInactive < 355 MIN_DURATION_INACTIVE_TO_ACTIVE_CONSIDERED_AS_FLING 356 ) { 357 // A delay is added to allow the background to transition back to ACTIVE 358 // since it was briefly in INACTIVE. Without this delay, setting it 359 // immediately to COMMITTED would result in the committed animation 360 // appearing like it was playing in INACTIVE. 361 mainHandler.postDelayed(MIN_DURATION_ACTIVE_AFTER_INACTIVE_ANIMATION) { 362 updateArrowState(GestureState.COMMITTED) 363 } 364 } else { 365 updateArrowState(GestureState.COMMITTED) 366 } 367 } 368 GestureState.GONE, 369 GestureState.FLUNG, 370 GestureState.COMMITTED, 371 GestureState.CANCELLED -> { 372 updateArrowState(GestureState.CANCELLED) 373 } 374 } 375 velocityTracker = null 376 } 377 MotionEvent.ACTION_CANCEL -> { 378 // Receiving a CANCEL implies that something else intercepted 379 // the gesture, i.e., the user did not cancel their gesture. 380 // Therefore, disappear immediately, with minimum fanfare. 381 updateArrowState(GestureState.GONE) 382 velocityTracker = null 383 } 384 } 385 } 386 387 private fun cancelAllPendingAnimations() { 388 cancelFailsafe() 389 mView.cancelAnimations() 390 mainHandler.removeCallbacks(onEndSetCommittedStateListener.runnable) 391 mainHandler.removeCallbacks(onEndSetGoneStateListener.runnable) 392 mainHandler.removeCallbacks(onAlphaEndSetGoneStateListener.runnable) 393 } 394 395 /** 396 * Returns false until the current gesture exceeds the touch slop threshold, and returns true 397 * thereafter (we reset on the subsequent back gesture). The moment it switches from false -> 398 * true is important, because that's when we switch state, from GONE -> ENTRY. 399 * 400 * @return whether the current gesture has moved past a minimum threshold. 401 */ 402 private fun dragSlopExceeded(curX: Float, startX: Float): Boolean { 403 if (hasPassedDragSlop) return true 404 405 if (abs(curX - startX) > viewConfiguration.scaledEdgeSlop) { 406 // Reset the arrow to the side 407 updateArrowState(GestureState.ENTRY) 408 409 windowManager.updateViewLayout(mView, layoutParams) 410 mView.startTrackingShowBackArrowLatency() 411 412 hasPassedDragSlop = true 413 } 414 return hasPassedDragSlop 415 } 416 417 private fun updateArrowStateOnMove(yTranslation: Float, xTranslation: Float) { 418 val isWithinYActivationThreshold = xTranslation * 2 >= yTranslation 419 val isPastStaticThreshold = xTranslation > params.staticTriggerThreshold 420 when (currentState) { 421 GestureState.ENTRY -> { 422 if ( 423 isPastThresholdToActive( 424 isPastThreshold = isPastStaticThreshold, 425 dynamicDelay = entryToActiveDelayCalculation 426 ) 427 ) { 428 updateArrowState(GestureState.ACTIVE) 429 } 430 } 431 GestureState.INACTIVE -> { 432 val isPastDynamicReactivationThreshold = 433 totalTouchDeltaInactive >= params.reactivationTriggerThreshold 434 435 if ( 436 isPastThresholdToActive( 437 isPastThreshold = 438 isPastStaticThreshold && 439 isPastDynamicReactivationThreshold && 440 isWithinYActivationThreshold, 441 delay = MIN_DURATION_INACTIVE_BEFORE_ACTIVE_ANIMATION 442 ) 443 ) { 444 updateArrowState(GestureState.ACTIVE) 445 } 446 } 447 GestureState.ACTIVE -> { 448 val isPastDynamicDeactivationThreshold = 449 totalTouchDeltaActive <= params.deactivationTriggerThreshold 450 val isMinDurationElapsed = 451 elapsedTimeSinceEntry > MIN_DURATION_ACTIVE_BEFORE_INACTIVE_ANIMATION 452 val isPastAllThresholds = 453 !isWithinYActivationThreshold || isPastDynamicDeactivationThreshold 454 if (isPastAllThresholds && isMinDurationElapsed) { 455 updateArrowState(GestureState.INACTIVE) 456 } 457 } 458 else -> {} 459 } 460 } 461 462 private fun handleMoveEvent(event: MotionEvent) { 463 val x = event.x 464 val y = event.y 465 466 val yOffset = y - startY 467 468 // How far in the y direction we are from the original touch 469 val yTranslation = abs(yOffset) 470 471 // How far in the x direction we are from the original touch ignoring motion that 472 // occurs between the screen edge and the touch start. 473 val xTranslation = max(0f, if (mView.isLeftPanel) x - startX else startX - x) 474 475 // Compared to last time, how far we moved in the x direction. If <0, we are moving closer 476 // to the edge. If >0, we are moving further from the edge 477 val xDelta = xTranslation - previousXTranslation 478 previousXTranslation = xTranslation 479 480 if (abs(xDelta) > 0) { 481 val isInSameDirection = sign(xDelta) == sign(totalTouchDeltaActive) 482 val isInDynamicRange = totalTouchDeltaActive in params.dynamicTriggerThresholdRange 483 val isTouchInContinuousDirection = isInSameDirection || isInDynamicRange 484 485 if (isTouchInContinuousDirection) { 486 // Direction has NOT changed, so keep counting the delta 487 totalTouchDeltaActive += xDelta 488 } else { 489 // Direction has changed, so reset the delta 490 totalTouchDeltaActive = xDelta 491 touchDeltaStartX = x 492 } 493 494 // Add a slop to to prevent small jitters when arrow is at edge in 495 // emitting small values that cause the arrow to poke out slightly 496 val minimumDelta = -viewConfiguration.scaledTouchSlop.toFloat() 497 totalTouchDeltaInactive = 498 totalTouchDeltaInactive.plus(xDelta).coerceAtLeast(minimumDelta) 499 } 500 501 updateArrowStateOnMove(yTranslation, xTranslation) 502 503 val gestureProgress = 504 when (currentState) { 505 GestureState.ACTIVE -> fullScreenProgress(xTranslation) 506 GestureState.ENTRY -> staticThresholdProgress(xTranslation) 507 GestureState.INACTIVE -> reactivationThresholdProgress(totalTouchDeltaInactive) 508 else -> null 509 } 510 511 gestureProgress?.let { 512 when (currentState) { 513 GestureState.ACTIVE -> stretchActiveBackIndicator(gestureProgress) 514 GestureState.ENTRY -> stretchEntryBackIndicator(gestureProgress) 515 GestureState.INACTIVE -> stretchInactiveBackIndicator(gestureProgress) 516 else -> {} 517 } 518 } 519 520 setArrowStrokeAlpha(gestureProgress) 521 setVerticalTranslation(yOffset) 522 } 523 524 private fun setArrowStrokeAlpha(gestureProgress: Float?) { 525 val strokeAlphaProgress = 526 when (currentState) { 527 GestureState.ENTRY -> gestureProgress 528 GestureState.INACTIVE -> gestureProgress 529 GestureState.ACTIVE, 530 GestureState.FLUNG, 531 GestureState.COMMITTED -> 1f 532 GestureState.CANCELLED, 533 GestureState.GONE -> 0f 534 } 535 536 val indicator = 537 when (currentState) { 538 GestureState.ENTRY -> params.entryIndicator 539 GestureState.INACTIVE -> params.preThresholdIndicator 540 GestureState.ACTIVE -> params.activeIndicator 541 else -> params.preThresholdIndicator 542 } 543 544 strokeAlphaProgress?.let { progress -> 545 indicator.arrowDimens.alphaSpring 546 ?.get(progress) 547 ?.takeIf { it.isNewState } 548 ?.let { mView.popArrowAlpha(0f, it.value) } 549 } 550 } 551 552 private fun setVerticalTranslation(yOffset: Float) { 553 val yTranslation = abs(yOffset) 554 val maxYOffset = (mView.height - params.entryIndicator.backgroundDimens.height) / 2f 555 val rubberbandAmount = 15f 556 val yProgress = MathUtils.saturate(yTranslation / (maxYOffset * rubberbandAmount)) 557 val yPosition = 558 params.verticalTranslationInterpolator.getInterpolation(yProgress) * 559 maxYOffset * 560 sign(yOffset) 561 mView.animateVertically(yPosition) 562 } 563 564 /** 565 * Tracks the relative position of the drag from the time after the arrow is activated until the 566 * arrow is fully stretched (between 0.0 - 1.0f) 567 */ 568 private fun fullScreenProgress(xTranslation: Float): Float { 569 val progress = (xTranslation - previousXTranslationOnActiveOffset) / fullyStretchedThreshold 570 return MathUtils.saturate(progress) 571 } 572 573 /** 574 * Tracks the relative position of the drag from the entry until the threshold where the arrow 575 * activates (between 0.0 - 1.0f) 576 */ 577 private fun staticThresholdProgress(xTranslation: Float): Float { 578 return MathUtils.saturate(xTranslation / params.staticTriggerThreshold) 579 } 580 581 private fun reactivationThresholdProgress(totalTouchDelta: Float): Float { 582 return MathUtils.saturate(totalTouchDelta / params.reactivationTriggerThreshold) 583 } 584 585 private fun stretchActiveBackIndicator(progress: Float) { 586 mView.setStretch( 587 horizontalTranslationStretchAmount = 588 params.horizontalTranslationInterpolator.getInterpolation(progress), 589 arrowStretchAmount = params.arrowAngleInterpolator.getInterpolation(progress), 590 backgroundWidthStretchAmount = 591 params.activeWidthInterpolator.getInterpolation(progress), 592 backgroundAlphaStretchAmount = 1f, 593 backgroundHeightStretchAmount = 1f, 594 arrowAlphaStretchAmount = 1f, 595 edgeCornerStretchAmount = 1f, 596 farCornerStretchAmount = 1f, 597 fullyStretchedDimens = params.fullyStretchedIndicator 598 ) 599 } 600 601 private fun stretchEntryBackIndicator(progress: Float) { 602 mView.setStretch( 603 horizontalTranslationStretchAmount = 0f, 604 arrowStretchAmount = params.arrowAngleInterpolator.getInterpolation(progress), 605 backgroundWidthStretchAmount = params.entryWidthInterpolator.getInterpolation(progress), 606 backgroundHeightStretchAmount = params.heightInterpolator.getInterpolation(progress), 607 backgroundAlphaStretchAmount = 1f, 608 arrowAlphaStretchAmount = 609 params.entryIndicator.arrowDimens.alphaInterpolator?.get(progress)?.value ?: 0f, 610 edgeCornerStretchAmount = params.edgeCornerInterpolator.getInterpolation(progress), 611 farCornerStretchAmount = params.farCornerInterpolator.getInterpolation(progress), 612 fullyStretchedDimens = params.preThresholdIndicator 613 ) 614 } 615 616 private var previousPreThresholdWidthInterpolator = params.entryWidthInterpolator 617 private fun preThresholdWidthStretchAmount(progress: Float): Float { 618 val interpolator = run { 619 val isPastSlop = totalTouchDeltaInactive > viewConfiguration.scaledTouchSlop 620 if (isPastSlop) { 621 if (totalTouchDeltaInactive > 0) { 622 params.entryWidthInterpolator 623 } else { 624 params.entryWidthTowardsEdgeInterpolator 625 } 626 } else { 627 previousPreThresholdWidthInterpolator 628 } 629 .also { previousPreThresholdWidthInterpolator = it } 630 } 631 return interpolator.getInterpolation(progress).coerceAtLeast(0f) 632 } 633 634 private fun stretchInactiveBackIndicator(progress: Float) { 635 mView.setStretch( 636 horizontalTranslationStretchAmount = 0f, 637 arrowStretchAmount = params.arrowAngleInterpolator.getInterpolation(progress), 638 backgroundWidthStretchAmount = preThresholdWidthStretchAmount(progress), 639 backgroundHeightStretchAmount = params.heightInterpolator.getInterpolation(progress), 640 backgroundAlphaStretchAmount = 1f, 641 arrowAlphaStretchAmount = 642 params.preThresholdIndicator.arrowDimens.alphaInterpolator?.get(progress)?.value 643 ?: 0f, 644 edgeCornerStretchAmount = params.edgeCornerInterpolator.getInterpolation(progress), 645 farCornerStretchAmount = params.farCornerInterpolator.getInterpolation(progress), 646 fullyStretchedDimens = params.preThresholdIndicator 647 ) 648 } 649 650 override fun onDestroy() { 651 cancelFailsafe() 652 windowManager.removeView(mView) 653 } 654 655 override fun setIsLeftPanel(isLeftPanel: Boolean) { 656 mView.isLeftPanel = isLeftPanel 657 layoutParams.gravity = 658 if (isLeftPanel) { 659 Gravity.LEFT or Gravity.TOP 660 } else { 661 Gravity.RIGHT or Gravity.TOP 662 } 663 } 664 665 override fun setInsets(insetLeft: Int, insetRight: Int) = Unit 666 667 override fun setBackCallback(callback: NavigationEdgeBackPlugin.BackCallback) { 668 backCallback = callback 669 } 670 671 override fun setLayoutParams(layoutParams: WindowManager.LayoutParams) { 672 this.layoutParams = layoutParams 673 windowManager.addView(mView, layoutParams) 674 } 675 676 private fun isFlungAwayFromEdge(endX: Float, startX: Float = touchDeltaStartX): Boolean { 677 val flingDistance = if (mView.isLeftPanel) endX - startX else startX - endX 678 val flingVelocity = 679 velocityTracker?.run { 680 computeCurrentVelocity(PX_PER_SEC) 681 xVelocity.takeIf { mView.isLeftPanel } ?: (xVelocity * -1) 682 } 683 ?: 0f 684 val isPastFlingVelocityThreshold = 685 flingVelocity > viewConfiguration.scaledMinimumFlingVelocity 686 return flingDistance > minFlingDistance && isPastFlingVelocityThreshold 687 } 688 689 private fun isPastThresholdToActive( 690 isPastThreshold: Boolean, 691 delay: Float? = null, 692 dynamicDelay: () -> Float = { delay ?: 0F } 693 ): Boolean { 694 val resetValue = 0L 695 val isPastThresholdForFirstTime = pastThresholdWhileEntryOrInactiveTime == resetValue 696 697 if (!isPastThreshold) { 698 pastThresholdWhileEntryOrInactiveTime = resetValue 699 return false 700 } 701 702 if (isPastThresholdForFirstTime) { 703 pastThresholdWhileEntryOrInactiveTime = SystemClock.uptimeMillis() 704 entryToActiveDelay = dynamicDelay() 705 } 706 val timePastThreshold = SystemClock.uptimeMillis() - pastThresholdWhileEntryOrInactiveTime 707 708 return timePastThreshold > entryToActiveDelay 709 } 710 711 private fun playWithBackgroundWidthAnimation( 712 onEnd: DelayedOnAnimationEndListener, 713 delay: Long = 0L 714 ) { 715 if (delay == 0L) { 716 updateRestingArrowDimens() 717 if (!mView.addAnimationEndListener(mView.backgroundWidth, onEnd)) { 718 scheduleFailsafe() 719 } 720 } else { 721 mainHandler.postDelayed(delay) { playWithBackgroundWidthAnimation(onEnd, delay = 0L) } 722 } 723 } 724 725 private fun updateYStartPosition(touchY: Float) { 726 var yPosition = touchY - params.fingerOffset 727 yPosition = max(yPosition, params.minArrowYPosition.toFloat()) 728 yPosition -= layoutParams.height / 2.0f 729 layoutParams.y = MathUtils.constrain(yPosition.toInt(), 0, displaySize.y) 730 } 731 732 override fun setDisplaySize(displaySize: Point) { 733 this.displaySize.set(displaySize.x, displaySize.y) 734 fullyStretchedThreshold = min(displaySize.x.toFloat(), params.swipeProgressThreshold) 735 } 736 737 /** Updates resting arrow and background size not accounting for stretch */ 738 private fun updateRestingArrowDimens() { 739 when (currentState) { 740 GestureState.GONE, 741 GestureState.ENTRY -> { 742 mView.setSpring( 743 arrowLength = params.entryIndicator.arrowDimens.lengthSpring, 744 arrowHeight = params.entryIndicator.arrowDimens.heightSpring, 745 scale = params.entryIndicator.scaleSpring, 746 verticalTranslation = params.entryIndicator.verticalTranslationSpring, 747 horizontalTranslation = params.entryIndicator.horizontalTranslationSpring, 748 backgroundAlpha = params.entryIndicator.backgroundDimens.alphaSpring, 749 backgroundWidth = params.entryIndicator.backgroundDimens.widthSpring, 750 backgroundHeight = params.entryIndicator.backgroundDimens.heightSpring, 751 backgroundEdgeCornerRadius = 752 params.entryIndicator.backgroundDimens.edgeCornerRadiusSpring, 753 backgroundFarCornerRadius = 754 params.entryIndicator.backgroundDimens.farCornerRadiusSpring, 755 ) 756 } 757 GestureState.INACTIVE -> { 758 mView.setSpring( 759 arrowLength = params.preThresholdIndicator.arrowDimens.lengthSpring, 760 arrowHeight = params.preThresholdIndicator.arrowDimens.heightSpring, 761 horizontalTranslation = 762 params.preThresholdIndicator.horizontalTranslationSpring, 763 scale = params.preThresholdIndicator.scaleSpring, 764 backgroundWidth = params.preThresholdIndicator.backgroundDimens.widthSpring, 765 backgroundHeight = params.preThresholdIndicator.backgroundDimens.heightSpring, 766 backgroundEdgeCornerRadius = 767 params.preThresholdIndicator.backgroundDimens.edgeCornerRadiusSpring, 768 backgroundFarCornerRadius = 769 params.preThresholdIndicator.backgroundDimens.farCornerRadiusSpring, 770 ) 771 } 772 GestureState.ACTIVE -> { 773 mView.setSpring( 774 arrowLength = params.activeIndicator.arrowDimens.lengthSpring, 775 arrowHeight = params.activeIndicator.arrowDimens.heightSpring, 776 scale = params.activeIndicator.scaleSpring, 777 horizontalTranslation = params.activeIndicator.horizontalTranslationSpring, 778 backgroundWidth = params.activeIndicator.backgroundDimens.widthSpring, 779 backgroundHeight = params.activeIndicator.backgroundDimens.heightSpring, 780 backgroundEdgeCornerRadius = 781 params.activeIndicator.backgroundDimens.edgeCornerRadiusSpring, 782 backgroundFarCornerRadius = 783 params.activeIndicator.backgroundDimens.farCornerRadiusSpring, 784 ) 785 } 786 GestureState.FLUNG -> { 787 mView.setSpring( 788 arrowLength = params.flungIndicator.arrowDimens.lengthSpring, 789 arrowHeight = params.flungIndicator.arrowDimens.heightSpring, 790 backgroundWidth = params.flungIndicator.backgroundDimens.widthSpring, 791 backgroundHeight = params.flungIndicator.backgroundDimens.heightSpring, 792 backgroundEdgeCornerRadius = 793 params.flungIndicator.backgroundDimens.edgeCornerRadiusSpring, 794 backgroundFarCornerRadius = 795 params.flungIndicator.backgroundDimens.farCornerRadiusSpring, 796 ) 797 } 798 GestureState.COMMITTED -> { 799 mView.setSpring( 800 arrowLength = params.committedIndicator.arrowDimens.lengthSpring, 801 arrowHeight = params.committedIndicator.arrowDimens.heightSpring, 802 scale = params.committedIndicator.scaleSpring, 803 backgroundAlpha = params.committedIndicator.backgroundDimens.alphaSpring, 804 backgroundWidth = params.committedIndicator.backgroundDimens.widthSpring, 805 backgroundHeight = params.committedIndicator.backgroundDimens.heightSpring, 806 backgroundEdgeCornerRadius = 807 params.committedIndicator.backgroundDimens.edgeCornerRadiusSpring, 808 backgroundFarCornerRadius = 809 params.committedIndicator.backgroundDimens.farCornerRadiusSpring, 810 ) 811 } 812 GestureState.CANCELLED -> { 813 mView.setSpring( 814 backgroundAlpha = params.cancelledIndicator.backgroundDimens.alphaSpring 815 ) 816 } 817 else -> {} 818 } 819 820 mView.setRestingDimens( 821 animate = 822 !(currentState == GestureState.FLUNG || currentState == GestureState.COMMITTED), 823 restingParams = 824 EdgePanelParams.BackIndicatorDimens( 825 scale = 826 when (currentState) { 827 GestureState.ACTIVE, 828 GestureState.FLUNG, -> params.activeIndicator.scale 829 GestureState.COMMITTED -> params.committedIndicator.scale 830 else -> params.preThresholdIndicator.scale 831 }, 832 scalePivotX = 833 when (currentState) { 834 GestureState.GONE, 835 GestureState.ENTRY, 836 GestureState.INACTIVE, 837 GestureState.CANCELLED -> params.preThresholdIndicator.scalePivotX 838 GestureState.ACTIVE -> params.activeIndicator.scalePivotX 839 GestureState.FLUNG, 840 GestureState.COMMITTED -> params.committedIndicator.scalePivotX 841 }, 842 horizontalTranslation = 843 when (currentState) { 844 GestureState.GONE -> { 845 params.activeIndicator.backgroundDimens.width?.times(-1) 846 } 847 GestureState.ENTRY, 848 GestureState.INACTIVE -> params.entryIndicator.horizontalTranslation 849 GestureState.FLUNG -> params.activeIndicator.horizontalTranslation 850 GestureState.ACTIVE -> params.activeIndicator.horizontalTranslation 851 GestureState.CANCELLED -> { 852 params.cancelledIndicator.horizontalTranslation 853 } 854 else -> null 855 }, 856 arrowDimens = 857 when (currentState) { 858 GestureState.GONE, 859 GestureState.ENTRY, 860 GestureState.INACTIVE -> params.entryIndicator.arrowDimens 861 GestureState.ACTIVE -> params.activeIndicator.arrowDimens 862 GestureState.FLUNG -> params.flungIndicator.arrowDimens 863 GestureState.COMMITTED -> params.committedIndicator.arrowDimens 864 GestureState.CANCELLED -> params.cancelledIndicator.arrowDimens 865 }, 866 backgroundDimens = 867 when (currentState) { 868 GestureState.GONE, 869 GestureState.ENTRY, 870 GestureState.INACTIVE -> params.entryIndicator.backgroundDimens 871 GestureState.ACTIVE -> params.activeIndicator.backgroundDimens 872 GestureState.FLUNG -> params.activeIndicator.backgroundDimens 873 GestureState.COMMITTED -> params.committedIndicator.backgroundDimens 874 GestureState.CANCELLED -> params.cancelledIndicator.backgroundDimens 875 } 876 ) 877 ) 878 } 879 880 /** 881 * Update arrow state. If state has not changed, this is a no-op. 882 * 883 * Transitioning to active/inactive will indicate whether or not releasing touch will trigger 884 * the back action. 885 */ 886 private fun updateArrowState(newState: GestureState, force: Boolean = false) { 887 if (!force && currentState == newState) return 888 889 previousState = currentState 890 currentState = newState 891 892 when (currentState) { 893 GestureState.CANCELLED -> { 894 backCallback.cancelBack() 895 } 896 GestureState.FLUNG, 897 GestureState.COMMITTED -> { 898 // When flung, trigger back immediately but don't fire again 899 // once state resolves to committed. 900 if (previousState != GestureState.FLUNG) backCallback.triggerBack() 901 } 902 GestureState.ENTRY, 903 GestureState.INACTIVE -> { 904 backCallback.setTriggerBack(false) 905 } 906 GestureState.ACTIVE -> { 907 backCallback.setTriggerBack(true) 908 } 909 GestureState.GONE -> {} 910 } 911 912 when (currentState) { 913 // Transitioning to GONE never animates since the arrow is (presumably) already off the 914 // screen 915 GestureState.GONE -> { 916 updateRestingArrowDimens() 917 mView.isVisible = false 918 } 919 GestureState.ENTRY -> { 920 mView.isVisible = true 921 922 updateRestingArrowDimens() 923 gestureEntryTime = SystemClock.uptimeMillis() 924 } 925 GestureState.ACTIVE -> { 926 previousXTranslationOnActiveOffset = previousXTranslation 927 updateRestingArrowDimens() 928 performActivatedHapticFeedback() 929 val popVelocity = 930 if (previousState == GestureState.INACTIVE) { 931 POP_ON_INACTIVE_TO_ACTIVE_VELOCITY 932 } else { 933 POP_ON_ENTRY_TO_ACTIVE_VELOCITY 934 } 935 mView.popOffEdge(popVelocity) 936 } 937 GestureState.INACTIVE -> { 938 gestureInactiveTime = SystemClock.uptimeMillis() 939 940 // Typically entering INACTIVE means 941 // totalTouchDelta <= deactivationSwipeTriggerThreshold 942 // but because we can also independently enter this state 943 // if touch Y >> touch X, we force it to deactivationSwipeTriggerThreshold 944 // so that gesture progress in this state is consistent regardless of entry 945 totalTouchDeltaInactive = params.deactivationTriggerThreshold 946 947 mView.popOffEdge(POP_ON_INACTIVE_VELOCITY) 948 949 performDeactivatedHapticFeedback() 950 updateRestingArrowDimens() 951 } 952 GestureState.FLUNG -> { 953 // Typically a vibration is only played while transitioning to ACTIVE. However there 954 // are instances where a fling to trigger back occurs while not in that state. 955 // (e.g. A fling is detected before crossing the trigger threshold.) 956 if (previousState != GestureState.ACTIVE) { 957 performActivatedHapticFeedback() 958 } 959 mainHandler.postDelayed(POP_ON_FLING_DELAY) { 960 mView.popScale(POP_ON_FLING_VELOCITY) 961 } 962 mainHandler.postDelayed( 963 onEndSetCommittedStateListener.runnable, 964 MIN_DURATION_FLING_ANIMATION 965 ) 966 updateRestingArrowDimens() 967 } 968 GestureState.COMMITTED -> { 969 // In most cases, animating between states is handled via `updateRestingArrowDimens` 970 // which plays an animation immediately upon state change. Some animations however 971 // occur after a delay upon state change and these animations may be independent 972 // or non-sequential from the state change animation. `postDelayed` is used to 973 // manually play these kinds of animations in parallel. 974 if (previousState == GestureState.FLUNG) { 975 updateRestingArrowDimens() 976 mainHandler.postDelayed( 977 onEndSetGoneStateListener.runnable, 978 MIN_DURATION_COMMITTED_AFTER_FLING_ANIMATION 979 ) 980 } else { 981 mView.popScale(POP_ON_COMMITTED_VELOCITY) 982 mainHandler.postDelayed( 983 onAlphaEndSetGoneStateListener.runnable, 984 MIN_DURATION_COMMITTED_ANIMATION 985 ) 986 } 987 } 988 GestureState.CANCELLED -> { 989 val delay = max(0, MIN_DURATION_CANCELLED_ANIMATION - elapsedTimeSinceEntry) 990 playWithBackgroundWidthAnimation(onEndSetGoneStateListener, delay) 991 992 val springForceOnCancelled = 993 params.cancelledIndicator.arrowDimens.alphaSpring?.get(0f)?.value 994 mView.popArrowAlpha(0f, springForceOnCancelled) 995 if (!featureFlags.isEnabled(ONE_WAY_HAPTICS_API_MIGRATION)) 996 mainHandler.postDelayed(10L) { vibratorHelper.cancel() } 997 } 998 } 999 } 1000 1001 private fun performDeactivatedHapticFeedback() { 1002 if (featureFlags.isEnabled(ONE_WAY_HAPTICS_API_MIGRATION)) { 1003 vibratorHelper.performHapticFeedback( 1004 mView, 1005 HapticFeedbackConstants.GESTURE_THRESHOLD_DEACTIVATE 1006 ) 1007 } else { 1008 vibratorHelper.vibrate(VIBRATE_DEACTIVATED_EFFECT) 1009 } 1010 } 1011 1012 private fun performActivatedHapticFeedback() { 1013 if (featureFlags.isEnabled(ONE_WAY_HAPTICS_API_MIGRATION)) { 1014 vibratorHelper.performHapticFeedback( 1015 mView, 1016 HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE 1017 ) 1018 } else { 1019 vibratorHelper.cancel() 1020 mainHandler.postDelayed(10L) { 1021 vibratorHelper.vibrate(VIBRATE_ACTIVATED_EFFECT) 1022 } 1023 } 1024 } 1025 1026 private fun convertVelocityToAnimationFactor( 1027 valueOnFastVelocity: Float, 1028 valueOnSlowVelocity: Float, 1029 fastVelocityBound: Float = 1f, 1030 slowVelocityBound: Float = 0.5f, 1031 ): Float { 1032 val factor = 1033 velocityTracker?.run { 1034 computeCurrentVelocity(PX_PER_MS) 1035 MathUtils.smoothStep(slowVelocityBound, fastVelocityBound, abs(xVelocity)) 1036 } 1037 ?: valueOnFastVelocity 1038 1039 return MathUtils.lerp(valueOnFastVelocity, valueOnSlowVelocity, 1 - factor) 1040 } 1041 1042 private fun scheduleFailsafe() { 1043 if (!ENABLE_FAILSAFE) return 1044 cancelFailsafe() 1045 if (DEBUG) Log.d(TAG, "scheduleFailsafe") 1046 mainHandler.postDelayed(failsafeRunnable, FAILSAFE_DELAY_MS) 1047 } 1048 1049 private fun cancelFailsafe() { 1050 if (DEBUG) Log.d(TAG, "cancelFailsafe") 1051 mainHandler.removeCallbacks(failsafeRunnable) 1052 } 1053 1054 private fun onFailsafe() { 1055 if (DEBUG) Log.d(TAG, "onFailsafe") 1056 updateArrowState(GestureState.GONE, force = true) 1057 } 1058 1059 override fun dump(pw: PrintWriter) { 1060 pw.println("$TAG:") 1061 pw.println(" currentState=$currentState") 1062 pw.println(" isLeftPanel=$mView.isLeftPanel") 1063 } 1064 1065 init { 1066 if (DEBUG) 1067 mView.drawDebugInfo = { canvas -> 1068 val preProgress = staticThresholdProgress(previousXTranslation) * 100 1069 val postProgress = fullScreenProgress(previousXTranslation) * 100 1070 val debugStrings = 1071 listOf( 1072 "$currentState", 1073 "startX=$startX", 1074 "startY=$startY", 1075 "xDelta=${"%.1f".format(totalTouchDeltaActive)}", 1076 "xTranslation=${"%.1f".format(previousXTranslation)}", 1077 "pre=${"%.0f".format(preProgress)}%", 1078 "post=${"%.0f".format(postProgress)}%" 1079 ) 1080 val debugPaint = Paint().apply { color = Color.WHITE } 1081 val debugInfoBottom = debugStrings.size * 32f + 4f 1082 canvas.drawRect( 1083 4f, 1084 4f, 1085 canvas.width.toFloat(), 1086 debugStrings.size * 32f + 4f, 1087 debugPaint 1088 ) 1089 debugPaint.apply { 1090 color = Color.BLACK 1091 textSize = 32f 1092 } 1093 var offset = 32f 1094 for (debugText in debugStrings) { 1095 canvas.drawText(debugText, 10f, offset, debugPaint) 1096 offset += 32f 1097 } 1098 debugPaint.apply { 1099 color = Color.RED 1100 style = Paint.Style.STROKE 1101 strokeWidth = 4f 1102 } 1103 val canvasWidth = canvas.width.toFloat() 1104 val canvasHeight = canvas.height.toFloat() 1105 canvas.drawRect(0f, 0f, canvasWidth, canvasHeight, debugPaint) 1106 1107 fun drawVerticalLine(x: Float, color: Int) { 1108 debugPaint.color = color 1109 val x = if (mView.isLeftPanel) x else canvasWidth - x 1110 canvas.drawLine(x, debugInfoBottom, x, canvas.height.toFloat(), debugPaint) 1111 } 1112 1113 drawVerticalLine(x = params.staticTriggerThreshold, color = Color.BLUE) 1114 drawVerticalLine(x = params.deactivationTriggerThreshold, color = Color.BLUE) 1115 drawVerticalLine(x = startX, color = Color.GREEN) 1116 drawVerticalLine(x = previousXTranslation, color = Color.DKGRAY) 1117 } 1118 } 1119 } 1120 1121 /** 1122 * In addition to a typical step function which returns one or two values based on a threshold, 1123 * `Step` also gracefully handles quick changes in input near the threshold value that would 1124 * typically result in the output rapidly changing. 1125 * 1126 * In the context of Back arrow, the arrow's stroke opacity should always appear transparent or 1127 * opaque. Using a typical Step function, this would resulting in a flickering appearance as the 1128 * output would change rapidly. `Step` addresses this by moving the threshold after it is crossed so 1129 * it cannot be easily crossed again with small changes in touch events. 1130 */ 1131 class Step<T>( 1132 private val threshold: Float, 1133 private val factor: Float = 1.1f, 1134 private val postThreshold: T, 1135 private val preThreshold: T 1136 ) { 1137 1138 data class Value<T>(val value: T, val isNewState: Boolean) 1139 1140 private val lowerFactor = 2 - factor 1141 1142 private lateinit var startValue: Value<T> 1143 private lateinit var previousValue: Value<T> 1144 private var hasCrossedUpperBoundAtLeastOnce = false 1145 private var progress: Float = 0f 1146 1147 init { 1148 reset() 1149 } 1150 1151 fun reset() { 1152 hasCrossedUpperBoundAtLeastOnce = false 1153 progress = 0f 1154 startValue = Value(preThreshold, false) 1155 previousValue = startValue 1156 } 1157 1158 fun get(progress: Float): Value<T> { 1159 this.progress = progress 1160 1161 val hasCrossedUpperBound = progress > threshold * factor 1162 val hasCrossedLowerBound = progress > threshold * lowerFactor 1163 1164 return when { 1165 hasCrossedUpperBound && !hasCrossedUpperBoundAtLeastOnce -> { 1166 hasCrossedUpperBoundAtLeastOnce = true 1167 Value(postThreshold, true) 1168 } 1169 hasCrossedLowerBound -> previousValue.copy(isNewState = false) 1170 hasCrossedUpperBoundAtLeastOnce -> { 1171 hasCrossedUpperBoundAtLeastOnce = false 1172 Value(preThreshold, true) 1173 } 1174 else -> startValue 1175 }.also { previousValue = it } 1176 } 1177 } 1178