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