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 17 package com.android.systemui.accessibility.floatingmenu; 18 19 import static java.util.Objects.requireNonNull; 20 21 import android.animation.ValueAnimator; 22 import android.graphics.PointF; 23 import android.graphics.Rect; 24 import android.os.Handler; 25 import android.os.Looper; 26 import android.util.Log; 27 import android.view.DisplayCutout; 28 import android.view.View; 29 import android.view.animation.Animation; 30 import android.view.animation.OvershootInterpolator; 31 import android.view.animation.TranslateAnimation; 32 33 import androidx.dynamicanimation.animation.DynamicAnimation; 34 import androidx.dynamicanimation.animation.FlingAnimation; 35 import androidx.dynamicanimation.animation.FloatPropertyCompat; 36 import androidx.dynamicanimation.animation.SpringAnimation; 37 import androidx.dynamicanimation.animation.SpringForce; 38 import androidx.recyclerview.widget.RecyclerView; 39 40 import com.android.internal.annotations.VisibleForTesting; 41 42 import java.util.HashMap; 43 44 /** 45 * Controls the interaction animations of the {@link MenuView}. Also, it will use the relative 46 * coordinate based on the {@link MenuViewLayer} to compute the offset of the {@link MenuView}. 47 */ 48 class MenuAnimationController { 49 private static final String TAG = "MenuAnimationController"; 50 private static final boolean DEBUG = false; 51 private static final float MIN_PERCENT = 0.0f; 52 private static final float MAX_PERCENT = 1.0f; 53 private static final float COMPLETELY_OPAQUE = 1.0f; 54 private static final float COMPLETELY_TRANSPARENT = 0.0f; 55 private static final float SCALE_SHRINK = 0.0f; 56 private static final float SCALE_GROW = 1.0f; 57 private static final float FLING_FRICTION_SCALAR = 1.9f; 58 private static final float DEFAULT_FRICTION = 4.2f; 59 private static final float SPRING_AFTER_FLING_DAMPING_RATIO = 0.85f; 60 private static final float SPRING_STIFFNESS = 700f; 61 private static final float ESCAPE_VELOCITY = 750f; 62 // Make tucked animation by using translation X relative to the view itself. 63 private static final float ANIMATION_TO_X_VALUE = 0.5f; 64 65 private static final int ANIMATION_START_OFFSET_MS = 600; 66 private static final int ANIMATION_DURATION_MS = 600; 67 private static final int FADE_OUT_DURATION_MS = 1000; 68 private static final int FADE_EFFECT_DURATION_MS = 3000; 69 70 private final MenuView mMenuView; 71 private final MenuViewAppearance mMenuViewAppearance; 72 private final ValueAnimator mFadeOutAnimator; 73 private final Handler mHandler; 74 private boolean mIsFadeEffectEnabled; 75 private Runnable mSpringAnimationsEndAction; 76 private PointF mAnimationEndPosition = new PointF(); 77 78 // Cache the animations state of {@link DynamicAnimation.TRANSLATION_X} and {@link 79 // DynamicAnimation.TRANSLATION_Y} to be well controlled by the touch handler 80 @VisibleForTesting 81 final HashMap<DynamicAnimation.ViewProperty, DynamicAnimation> mPositionAnimations = 82 new HashMap<>(); 83 84 @VisibleForTesting 85 final RadiiAnimator mRadiiAnimator; 86 MenuAnimationController(MenuView menuView, MenuViewAppearance menuViewAppearance)87 MenuAnimationController(MenuView menuView, MenuViewAppearance menuViewAppearance) { 88 mMenuView = menuView; 89 mMenuViewAppearance = menuViewAppearance; 90 91 mHandler = createUiHandler(); 92 mFadeOutAnimator = new ValueAnimator(); 93 mFadeOutAnimator.setDuration(FADE_OUT_DURATION_MS); 94 mFadeOutAnimator.addUpdateListener( 95 (animation) -> menuView.setAlpha((float) animation.getAnimatedValue())); 96 mRadiiAnimator = new RadiiAnimator(mMenuViewAppearance.getMenuRadii(), 97 new IRadiiAnimationListener() { 98 @Override 99 public void onRadiiAnimationUpdate(float[] radii) { 100 mMenuView.setRadii(radii); 101 } 102 103 @Override 104 public void onRadiiAnimationStart() {} 105 106 @Override 107 public void onRadiiAnimationStop() {} 108 }); 109 mAnimationEndPosition = mMenuView.getMenuPosition(); 110 } 111 moveToPosition(PointF position)112 void moveToPosition(PointF position) { 113 moveToPosition(position, /* animateMovement = */ false); 114 mAnimationEndPosition = position; 115 } 116 117 /* Moves position without updating underlying percentage position. Can be animated. */ moveToPosition(PointF position, boolean animateMovement)118 void moveToPosition(PointF position, boolean animateMovement) { 119 moveToPositionX(position.x, animateMovement); 120 moveToPositionY(position.y, animateMovement); 121 } 122 moveToPositionX(float positionX)123 void moveToPositionX(float positionX) { 124 moveToPositionX(positionX, /* animateMovement = */ false); 125 } 126 moveToPositionX(float positionX, boolean animateMovement)127 void moveToPositionX(float positionX, boolean animateMovement) { 128 if (animateMovement) { 129 springMenuWith(DynamicAnimation.TRANSLATION_X, 130 createSpringForce(), 131 /* velocity = */ 0, 132 positionX, /* writeToPosition = */ false); 133 } else { 134 DynamicAnimation.TRANSLATION_X.setValue(mMenuView, positionX); 135 } 136 mAnimationEndPosition.x = positionX; 137 } 138 moveToPositionY(float positionY)139 void moveToPositionY(float positionY) { 140 moveToPositionY(positionY, /* animateMovement = */ false); 141 } 142 moveToPositionY(float positionY, boolean animateMovement)143 void moveToPositionY(float positionY, boolean animateMovement) { 144 if (animateMovement) { 145 springMenuWith(DynamicAnimation.TRANSLATION_Y, 146 createSpringForce(), 147 /* velocity = */ 0, 148 positionY, /* writeToPosition = */ false); 149 } else { 150 DynamicAnimation.TRANSLATION_Y.setValue(mMenuView, positionY); 151 } 152 mAnimationEndPosition.y = positionY; 153 } 154 moveToPositionYIfNeeded(float positionY)155 void moveToPositionYIfNeeded(float positionY) { 156 // If the list view was out of screen bounds, it would allow users to nest scroll inside 157 // and avoid conflicting with outer scroll. 158 final RecyclerView listView = (RecyclerView) mMenuView.getChildAt(/* index= */ 0); 159 if (listView.getOverScrollMode() == View.OVER_SCROLL_NEVER) { 160 moveToPositionY(positionY); 161 } 162 } 163 164 /** 165 * Sets the action to be called when the all dynamic animations are completed. 166 */ setSpringAnimationsEndAction(Runnable runnable)167 void setSpringAnimationsEndAction(Runnable runnable) { 168 mSpringAnimationsEndAction = runnable; 169 } 170 moveToTopLeftPosition()171 void moveToTopLeftPosition() { 172 mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); 173 final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); 174 moveAndPersistPosition(new PointF(draggableBounds.left, draggableBounds.top)); 175 } 176 moveToTopRightPosition()177 void moveToTopRightPosition() { 178 mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); 179 final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); 180 moveAndPersistPosition(new PointF(draggableBounds.right, draggableBounds.top)); 181 } 182 moveToBottomLeftPosition()183 void moveToBottomLeftPosition() { 184 mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); 185 final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); 186 moveAndPersistPosition(new PointF(draggableBounds.left, draggableBounds.bottom)); 187 } 188 moveToBottomRightPosition()189 void moveToBottomRightPosition() { 190 mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); 191 final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); 192 moveAndPersistPosition(new PointF(draggableBounds.right, draggableBounds.bottom)); 193 } 194 moveAndPersistPosition(PointF position)195 void moveAndPersistPosition(PointF position) { 196 moveToPosition(position); 197 mMenuView.onBoundsInParentChanged((int) position.x, (int) position.y); 198 constrainPositionAndUpdate(position, /* writeToPosition = */ true); 199 } 200 flingMenuThenSpringToEdge(PointF position, float velocityX, float velocityY)201 void flingMenuThenSpringToEdge(PointF position, float velocityX, float velocityY) { 202 final boolean shouldMenuFlingLeft = isOnLeftSide() 203 ? velocityX < ESCAPE_VELOCITY 204 : velocityX < -ESCAPE_VELOCITY; 205 206 final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); 207 final float finalPositionX = shouldMenuFlingLeft 208 ? draggableBounds.left : draggableBounds.right; 209 final DisplayCutout displayCutout = mMenuViewAppearance.getDisplayCutout(); 210 final float finalPositionY = 211 (displayCutout == null) ? position.y 212 : mMenuViewAppearance.avoidVerticalDisplayCutout( 213 position.y, draggableBounds, 214 shouldMenuFlingLeft 215 ? displayCutout.getBoundingRectLeft() 216 : displayCutout.getBoundingRectRight() 217 ); 218 final float minimumVelocityToReachEdge = 219 (finalPositionX - position.x) * (FLING_FRICTION_SCALAR * DEFAULT_FRICTION); 220 221 final float startXVelocity = shouldMenuFlingLeft 222 ? Math.min(minimumVelocityToReachEdge, velocityX) 223 : Math.max(minimumVelocityToReachEdge, velocityX); 224 225 flingThenSpringMenuWith(DynamicAnimation.TRANSLATION_X, 226 startXVelocity, 227 FLING_FRICTION_SCALAR, 228 createSpringForce(), 229 finalPositionX); 230 231 if (com.android.systemui.Flags.floatingMenuDisplayCutoutSupport()) { 232 flingThenSpringMenuWith(DynamicAnimation.TRANSLATION_Y, 233 velocityY, 234 FLING_FRICTION_SCALAR, 235 createSpringForce(), 236 (finalPositionY != position.y) ? finalPositionY : null); 237 } else { 238 flingThenSpringMenuWith(DynamicAnimation.TRANSLATION_Y, 239 velocityY, 240 FLING_FRICTION_SCALAR, 241 createSpringForce(), 242 /* finalPosition= */ null); 243 } 244 } 245 246 private void flingThenSpringMenuWith(DynamicAnimation.ViewProperty property, float velocity, 247 float friction, SpringForce spring, Float finalPosition) { 248 249 final MenuPositionProperty menuPositionProperty = new MenuPositionProperty(property); 250 final float currentValue = menuPositionProperty.getValue(mMenuView); 251 final Rect bounds = mMenuView.getMenuDraggableBounds(); 252 final float min = 253 property.equals(DynamicAnimation.TRANSLATION_X) 254 ? bounds.left 255 : bounds.top; 256 final float max = 257 property.equals(DynamicAnimation.TRANSLATION_X) 258 ? bounds.right 259 : bounds.bottom; 260 261 final FlingAnimation flingAnimation = createFlingAnimation(mMenuView, menuPositionProperty); 262 flingAnimation.setFriction(friction) 263 .setStartVelocity(velocity) 264 .setMinValue(Math.min(currentValue, min)) 265 .setMaxValue(Math.max(currentValue, max)) 266 .addEndListener((animation, canceled, endValue, endVelocity) -> { 267 if (canceled) { 268 if (DEBUG) { 269 Log.d(TAG, "The fling animation was canceled."); 270 } 271 272 return; 273 } 274 275 final float endPosition = finalPosition != null 276 ? finalPosition 277 : Math.max(min, Math.min(max, endValue)); 278 springMenuWith(property, spring, endVelocity, endPosition, 279 /* writeToPosition = */ true); 280 }); 281 282 cancelAnimation(property); 283 mPositionAnimations.put(property, flingAnimation); 284 if (finalPosition != null) { 285 setAnimationEndPosition(property, finalPosition); 286 } 287 flingAnimation.start(); 288 } 289 290 @VisibleForTesting 291 FlingAnimation createFlingAnimation(MenuView menuView, 292 MenuPositionProperty menuPositionProperty) { 293 return new FlingAnimation(menuView, menuPositionProperty); 294 } 295 296 @VisibleForTesting 297 void springMenuWith(DynamicAnimation.ViewProperty property, SpringForce spring, 298 float velocity, float finalPosition, boolean writeToPosition) { 299 final MenuPositionProperty menuPositionProperty = new MenuPositionProperty(property); 300 final SpringAnimation springAnimation = 301 new SpringAnimation(mMenuView, menuPositionProperty) 302 .setSpring(spring) 303 .addEndListener((animation, canceled, endValue, endVelocity) -> { 304 if (canceled || endValue != finalPosition) { 305 return; 306 } 307 308 final boolean areAnimationsRunning = 309 mPositionAnimations.values().stream().anyMatch( 310 DynamicAnimation::isRunning); 311 if (!areAnimationsRunning) { 312 onSpringAnimationsEnd(new PointF(mMenuView.getTranslationX(), 313 mMenuView.getTranslationY()), writeToPosition); 314 } 315 }) 316 .setStartVelocity(velocity); 317 318 cancelAnimation(property); 319 mPositionAnimations.put(property, springAnimation); 320 setAnimationEndPosition(property, finalPosition); 321 springAnimation.animateToFinalPosition(finalPosition); 322 } 323 324 /** 325 * Determines whether to hide the menu to the edge of the screen with the given current 326 * translation x of the menu view. It should be used when receiving the action up touch event. 327 * 328 * @param currentXTranslation the current translation x of the menu view. 329 * @return true if the menu would be hidden to the edge, otherwise false. 330 */ maybeMoveToEdgeAndHide(float currentXTranslation)331 boolean maybeMoveToEdgeAndHide(float currentXTranslation) { 332 final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); 333 334 // If the translation x is zero, it should be at the left of the bound. 335 if (currentXTranslation < draggableBounds.left 336 || currentXTranslation > draggableBounds.right) { 337 constrainPositionAndUpdate( 338 new PointF(mMenuView.getTranslationX(), mMenuView.getTranslationY()), 339 /* writeToPosition = */ true); 340 mMenuView.onPositionChanged(true); 341 moveToEdgeAndHide(); 342 return true; 343 } 344 return false; 345 } 346 isOnLeftSide()347 boolean isOnLeftSide() { 348 return mMenuView.getTranslationX() < mMenuView.getMenuDraggableBounds().centerX(); 349 } 350 isMoveToTucked()351 boolean isMoveToTucked() { 352 return mMenuView.isMoveToTucked(); 353 } 354 getTuckedMenuPosition()355 PointF getTuckedMenuPosition() { 356 final PointF position = mMenuView.getMenuPosition(); 357 final float menuHalfWidth = mMenuView.getMenuWidth() / 2.0f; 358 final float endX = isOnLeftSide() 359 ? position.x - menuHalfWidth 360 : position.x + menuHalfWidth; 361 return new PointF(endX, position.y); 362 } 363 moveToEdgeAndHide()364 void moveToEdgeAndHide() { 365 mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ true); 366 final PointF position = mMenuView.getMenuPosition(); 367 final PointF tuckedPosition = getTuckedMenuPosition(); 368 flingThenSpringMenuWith(DynamicAnimation.TRANSLATION_X, 369 Math.signum(tuckedPosition.x - position.x) * ESCAPE_VELOCITY, 370 FLING_FRICTION_SCALAR, 371 createDefaultSpringForce(), 372 tuckedPosition.x); 373 374 // Keep the touch region let users could click extra space to pop up the menu view 375 // from the screen edge 376 mMenuView.onBoundsInParentChanged((int) position.x, (int) position.y); 377 378 fadeOutIfEnabled(); 379 } 380 moveOutEdgeAndShow()381 void moveOutEdgeAndShow() { 382 mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); 383 384 PointF position = mMenuView.getMenuPosition(); 385 springMenuWith(DynamicAnimation.TRANSLATION_X, 386 createDefaultSpringForce(), 387 0, 388 position.x, 389 true 390 ); 391 springMenuWith(DynamicAnimation.TRANSLATION_Y, 392 createDefaultSpringForce(), 393 0, 394 position.y, 395 true 396 ); 397 398 mMenuView.onEdgeChangedIfNeeded(); 399 } 400 cancelAnimations()401 void cancelAnimations() { 402 cancelAnimation(DynamicAnimation.TRANSLATION_X); 403 cancelAnimation(DynamicAnimation.TRANSLATION_Y); 404 } 405 cancelAnimation(DynamicAnimation.ViewProperty property)406 private void cancelAnimation(DynamicAnimation.ViewProperty property) { 407 if (!mPositionAnimations.containsKey(property)) { 408 return; 409 } 410 411 mPositionAnimations.get(property).cancel(); 412 } 413 setAnimationEndPosition( DynamicAnimation.ViewProperty property, Float endPosition)414 private void setAnimationEndPosition( 415 DynamicAnimation.ViewProperty property, Float endPosition) { 416 if (property.equals(DynamicAnimation.TRANSLATION_X)) { 417 mAnimationEndPosition.x = endPosition; 418 } 419 if (property.equals(DynamicAnimation.TRANSLATION_Y)) { 420 mAnimationEndPosition.y = endPosition; 421 } 422 } 423 skipAnimations()424 void skipAnimations() { 425 cancelAnimations(); 426 moveToPosition(mAnimationEndPosition, false); 427 } 428 429 @VisibleForTesting getAnimation(DynamicAnimation.ViewProperty property)430 DynamicAnimation getAnimation(DynamicAnimation.ViewProperty property) { 431 return mPositionAnimations.getOrDefault(property, null); 432 } 433 onDraggingStart()434 void onDraggingStart() { 435 mMenuView.onDraggingStart(); 436 } 437 startShrinkAnimation(Runnable endAction)438 void startShrinkAnimation(Runnable endAction) { 439 mMenuView.animate().cancel(); 440 441 mMenuView.animate() 442 .scaleX(SCALE_SHRINK) 443 .scaleY(SCALE_SHRINK) 444 .alpha(COMPLETELY_TRANSPARENT) 445 .translationY(mMenuView.getTranslationY()) 446 .withEndAction(endAction).start(); 447 } 448 startGrowAnimation()449 void startGrowAnimation() { 450 mMenuView.animate().cancel(); 451 452 mMenuView.animate() 453 .scaleX(SCALE_GROW) 454 .scaleY(SCALE_GROW) 455 .alpha(COMPLETELY_OPAQUE) 456 .translationY(mMenuView.getTranslationY()) 457 .start(); 458 } 459 startRadiiAnimation(float[] endRadii)460 void startRadiiAnimation(float[] endRadii) { 461 mRadiiAnimator.startAnimation(endRadii); 462 } 463 onSpringAnimationsEnd(PointF position, boolean writeToPosition)464 private void onSpringAnimationsEnd(PointF position, boolean writeToPosition) { 465 mMenuView.onBoundsInParentChanged((int) position.x, (int) position.y); 466 constrainPositionAndUpdate(position, writeToPosition); 467 468 if (mSpringAnimationsEndAction != null) { 469 mSpringAnimationsEndAction.run(); 470 } 471 } 472 constrainPositionAndUpdate(PointF position, boolean writeToPosition)473 private void constrainPositionAndUpdate(PointF position, boolean writeToPosition) { 474 final Rect draggableBounds = mMenuView.getMenuDraggableBoundsExcludeIme(); 475 // Have the space gap margin between the top bound and the menu view, so actually the 476 // position y range needs to cut the margin. 477 position.offset(-draggableBounds.left, -draggableBounds.top); 478 479 final float percentageX = position.x < draggableBounds.centerX() 480 ? MIN_PERCENT : MAX_PERCENT; 481 482 final float percentageY = position.y < 0 || draggableBounds.height() == 0 483 ? MIN_PERCENT 484 : Math.min(MAX_PERCENT, position.y / draggableBounds.height()); 485 486 if (!writeToPosition) { 487 mMenuView.onEdgeChangedIfNeeded(); 488 } else { 489 mMenuView.persistPositionAndUpdateEdge(new Position(percentageX, percentageY)); 490 } 491 } 492 493 void updateOpacityWith(boolean isFadeEffectEnabled, float newOpacityValue) { 494 mIsFadeEffectEnabled = isFadeEffectEnabled; 495 496 mHandler.removeCallbacksAndMessages(/* token= */ null); 497 mFadeOutAnimator.cancel(); 498 mFadeOutAnimator.setFloatValues(COMPLETELY_OPAQUE, newOpacityValue); 499 mHandler.post(() -> mMenuView.setAlpha( 500 mIsFadeEffectEnabled ? newOpacityValue : COMPLETELY_OPAQUE)); 501 } 502 503 void fadeInNowIfEnabled() { 504 if (!mIsFadeEffectEnabled) { 505 return; 506 } 507 508 cancelAndRemoveCallbacksAndMessages(); 509 mMenuView.setAlpha(COMPLETELY_OPAQUE); 510 } 511 512 void fadeOutIfEnabled() { 513 if (!mIsFadeEffectEnabled) { 514 return; 515 } 516 517 cancelAndRemoveCallbacksAndMessages(); 518 mHandler.postDelayed(mFadeOutAnimator::start, FADE_EFFECT_DURATION_MS); 519 } 520 521 private void cancelAndRemoveCallbacksAndMessages() { 522 mFadeOutAnimator.cancel(); 523 mHandler.removeCallbacksAndMessages(/* token= */ null); 524 } 525 526 void startTuckedAnimationPreview() { 527 fadeInNowIfEnabled(); 528 529 final float toXValue = isOnLeftSide() 530 ? -ANIMATION_TO_X_VALUE 531 : ANIMATION_TO_X_VALUE; 532 final TranslateAnimation animation = 533 new TranslateAnimation(Animation.RELATIVE_TO_SELF, 0, 534 Animation.RELATIVE_TO_SELF, toXValue, 535 Animation.RELATIVE_TO_SELF, 0, 536 Animation.RELATIVE_TO_SELF, 0); 537 animation.setDuration(ANIMATION_DURATION_MS); 538 animation.setRepeatMode(Animation.REVERSE); 539 animation.setInterpolator(new OvershootInterpolator()); 540 animation.setRepeatCount(Animation.INFINITE); 541 animation.setStartOffset(ANIMATION_START_OFFSET_MS); 542 543 mMenuView.startAnimation(animation); 544 } 545 546 private Handler createUiHandler() { 547 return new Handler(requireNonNull(Looper.myLooper(), "looper must not be null")); 548 } 549 550 private static SpringForce createDefaultSpringForce() { 551 return new SpringForce() 552 .setStiffness(SPRING_STIFFNESS) 553 .setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO); 554 } 555 556 static class MenuPositionProperty 557 extends FloatPropertyCompat<MenuView> { 558 private final DynamicAnimation.ViewProperty mProperty; 559 560 MenuPositionProperty(DynamicAnimation.ViewProperty property) { 561 super(property.toString()); 562 mProperty = property; 563 } 564 565 @Override 566 public float getValue(MenuView menuView) { 567 return mProperty.getValue(menuView); 568 } 569 570 @Override 571 public void setValue(MenuView menuView, float value) { 572 mProperty.setValue(menuView, value); 573 } 574 } 575 576 @VisibleForTesting 577 static SpringForce createSpringForce() { 578 return new SpringForce() 579 .setStiffness(SPRING_STIFFNESS) 580 .setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO); 581 } 582 } 583