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