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 import com.android.internal.util.Preconditions; 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 ValueAnimator mFadeOutAnimator; 72 private final Handler mHandler; 73 private boolean mIsFadeEffectEnabled; 74 private DismissAnimationController.DismissCallback mDismissCallback; 75 private Runnable mSpringAnimationsEndAction; 76 77 // Cache the animations state of {@link DynamicAnimation.TRANSLATION_X} and {@link 78 // DynamicAnimation.TRANSLATION_Y} to be well controlled by the touch handler 79 @VisibleForTesting 80 final HashMap<DynamicAnimation.ViewProperty, DynamicAnimation> mPositionAnimations = 81 new HashMap<>(); 82 MenuAnimationController(MenuView menuView)83 MenuAnimationController(MenuView menuView) { 84 mMenuView = menuView; 85 86 mHandler = createUiHandler(); 87 mFadeOutAnimator = new ValueAnimator(); 88 mFadeOutAnimator.setDuration(FADE_OUT_DURATION_MS); 89 mFadeOutAnimator.addUpdateListener( 90 (animation) -> menuView.setAlpha((float) animation.getAnimatedValue())); 91 } 92 moveToPosition(PointF position)93 void moveToPosition(PointF position) { 94 moveToPositionX(position.x); 95 moveToPositionY(position.y); 96 } 97 moveToPositionX(float positionX)98 void moveToPositionX(float positionX) { 99 DynamicAnimation.TRANSLATION_X.setValue(mMenuView, positionX); 100 } 101 moveToPositionY(float positionY)102 private void moveToPositionY(float positionY) { 103 DynamicAnimation.TRANSLATION_Y.setValue(mMenuView, positionY); 104 } 105 moveToPositionYIfNeeded(float positionY)106 void moveToPositionYIfNeeded(float positionY) { 107 // If the list view was out of screen bounds, it would allow users to nest scroll inside 108 // and avoid conflicting with outer scroll. 109 final RecyclerView listView = (RecyclerView) mMenuView.getChildAt(/* index= */ 0); 110 if (listView.getOverScrollMode() == View.OVER_SCROLL_NEVER) { 111 moveToPositionY(positionY); 112 } 113 } 114 115 /** 116 * Sets the action to be called when the all dynamic animations are completed. 117 */ setSpringAnimationsEndAction(Runnable runnable)118 void setSpringAnimationsEndAction(Runnable runnable) { 119 mSpringAnimationsEndAction = runnable; 120 } 121 setDismissCallback( DismissAnimationController.DismissCallback dismissCallback)122 void setDismissCallback( 123 DismissAnimationController.DismissCallback dismissCallback) { 124 mDismissCallback = dismissCallback; 125 } 126 moveToTopLeftPosition()127 void moveToTopLeftPosition() { 128 mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); 129 final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); 130 moveAndPersistPosition(new PointF(draggableBounds.left, draggableBounds.top)); 131 } 132 moveToTopRightPosition()133 void moveToTopRightPosition() { 134 mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); 135 final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); 136 moveAndPersistPosition(new PointF(draggableBounds.right, draggableBounds.top)); 137 } 138 moveToBottomLeftPosition()139 void moveToBottomLeftPosition() { 140 mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); 141 final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); 142 moveAndPersistPosition(new PointF(draggableBounds.left, draggableBounds.bottom)); 143 } 144 moveToBottomRightPosition()145 void moveToBottomRightPosition() { 146 mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); 147 final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); 148 moveAndPersistPosition(new PointF(draggableBounds.right, draggableBounds.bottom)); 149 } 150 moveAndPersistPosition(PointF position)151 void moveAndPersistPosition(PointF position) { 152 moveToPosition(position); 153 mMenuView.onBoundsInParentChanged((int) position.x, (int) position.y); 154 constrainPositionAndUpdate(position); 155 } 156 removeMenu()157 void removeMenu() { 158 Preconditions.checkArgument(mDismissCallback != null, 159 "The dismiss callback should be initialized first."); 160 161 mDismissCallback.onDismiss(); 162 } 163 flingMenuThenSpringToEdge(float x, float velocityX, float velocityY)164 void flingMenuThenSpringToEdge(float x, float velocityX, float velocityY) { 165 final boolean shouldMenuFlingLeft = isOnLeftSide() 166 ? velocityX < ESCAPE_VELOCITY 167 : velocityX < -ESCAPE_VELOCITY; 168 169 final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); 170 final float finalPositionX = shouldMenuFlingLeft 171 ? draggableBounds.left : draggableBounds.right; 172 173 final float minimumVelocityToReachEdge = 174 (finalPositionX - x) * (FLING_FRICTION_SCALAR * DEFAULT_FRICTION); 175 176 final float startXVelocity = shouldMenuFlingLeft 177 ? Math.min(minimumVelocityToReachEdge, velocityX) 178 : Math.max(minimumVelocityToReachEdge, velocityX); 179 180 flingThenSpringMenuWith(DynamicAnimation.TRANSLATION_X, 181 startXVelocity, 182 FLING_FRICTION_SCALAR, 183 new SpringForce() 184 .setStiffness(SPRING_STIFFNESS) 185 .setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO), 186 finalPositionX); 187 188 flingThenSpringMenuWith(DynamicAnimation.TRANSLATION_Y, 189 velocityY, 190 FLING_FRICTION_SCALAR, 191 new SpringForce() 192 .setStiffness(SPRING_STIFFNESS) 193 .setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO), 194 /* finalPosition= */ null); 195 } 196 197 private void flingThenSpringMenuWith(DynamicAnimation.ViewProperty property, float velocity, 198 float friction, SpringForce spring, Float finalPosition) { 199 200 final MenuPositionProperty menuPositionProperty = new MenuPositionProperty(property); 201 final float currentValue = menuPositionProperty.getValue(mMenuView); 202 final Rect bounds = mMenuView.getMenuDraggableBounds(); 203 final float min = 204 property.equals(DynamicAnimation.TRANSLATION_X) 205 ? bounds.left 206 : bounds.top; 207 final float max = 208 property.equals(DynamicAnimation.TRANSLATION_X) 209 ? bounds.right 210 : bounds.bottom; 211 212 final FlingAnimation flingAnimation = createFlingAnimation(mMenuView, menuPositionProperty); 213 flingAnimation.setFriction(friction) 214 .setStartVelocity(velocity) 215 .setMinValue(Math.min(currentValue, min)) 216 .setMaxValue(Math.max(currentValue, max)) 217 .addEndListener((animation, canceled, endValue, endVelocity) -> { 218 if (canceled) { 219 if (DEBUG) { 220 Log.d(TAG, "The fling animation was canceled."); 221 } 222 223 return; 224 } 225 226 final float endPosition = finalPosition != null 227 ? finalPosition 228 : Math.max(min, Math.min(max, endValue)); 229 springMenuWith(property, spring, endVelocity, endPosition); 230 }); 231 232 cancelAnimation(property); 233 mPositionAnimations.put(property, flingAnimation); 234 flingAnimation.start(); 235 } 236 237 @VisibleForTesting 238 FlingAnimation createFlingAnimation(MenuView menuView, 239 MenuPositionProperty menuPositionProperty) { 240 return new FlingAnimation(menuView, menuPositionProperty); 241 } 242 243 @VisibleForTesting 244 void springMenuWith(DynamicAnimation.ViewProperty property, SpringForce spring, 245 float velocity, float finalPosition) { 246 final MenuPositionProperty menuPositionProperty = new MenuPositionProperty(property); 247 final SpringAnimation springAnimation = 248 new SpringAnimation(mMenuView, menuPositionProperty) 249 .setSpring(spring) 250 .addEndListener((animation, canceled, endValue, endVelocity) -> { 251 if (canceled || endValue != finalPosition) { 252 return; 253 } 254 255 final boolean areAnimationsRunning = 256 mPositionAnimations.values().stream().anyMatch( 257 DynamicAnimation::isRunning); 258 if (!areAnimationsRunning) { 259 onSpringAnimationsEnd(new PointF(mMenuView.getTranslationX(), 260 mMenuView.getTranslationY())); 261 } 262 }) 263 .setStartVelocity(velocity); 264 265 cancelAnimation(property); 266 mPositionAnimations.put(property, springAnimation); 267 springAnimation.animateToFinalPosition(finalPosition); 268 } 269 270 /** 271 * Determines whether to hide the menu to the edge of the screen with the given current 272 * translation x of the menu view. It should be used when receiving the action up touch event. 273 * 274 * @param currentXTranslation the current translation x of the menu view. 275 * @return true if the menu would be hidden to the edge, otherwise false. 276 */ maybeMoveToEdgeAndHide(float currentXTranslation)277 boolean maybeMoveToEdgeAndHide(float currentXTranslation) { 278 final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); 279 280 // If the translation x is zero, it should be at the left of the bound. 281 if (currentXTranslation < draggableBounds.left 282 || currentXTranslation > draggableBounds.right) { 283 constrainPositionAndUpdate( 284 new PointF(mMenuView.getTranslationX(), mMenuView.getTranslationY())); 285 moveToEdgeAndHide(); 286 return true; 287 } 288 289 fadeOutIfEnabled(); 290 return false; 291 } 292 isOnLeftSide()293 boolean isOnLeftSide() { 294 return mMenuView.getTranslationX() < mMenuView.getMenuDraggableBounds().centerX(); 295 } 296 isMoveToTucked()297 boolean isMoveToTucked() { 298 return mMenuView.isMoveToTucked(); 299 } 300 moveToEdgeAndHide()301 void moveToEdgeAndHide() { 302 mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ true); 303 304 final PointF position = mMenuView.getMenuPosition(); 305 final float menuHalfWidth = mMenuView.getMenuWidth() / 2.0f; 306 final float endX = isOnLeftSide() 307 ? position.x - menuHalfWidth 308 : position.x + menuHalfWidth; 309 moveToPosition(new PointF(endX, position.y)); 310 311 // Keep the touch region let users could click extra space to pop up the menu view 312 // from the screen edge 313 mMenuView.onBoundsInParentChanged((int) position.x, (int) position.y); 314 315 fadeOutIfEnabled(); 316 } 317 moveOutEdgeAndShow()318 void moveOutEdgeAndShow() { 319 mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); 320 321 mMenuView.onPositionChanged(); 322 mMenuView.onEdgeChangedIfNeeded(); 323 } 324 cancelAnimations()325 void cancelAnimations() { 326 cancelAnimation(DynamicAnimation.TRANSLATION_X); 327 cancelAnimation(DynamicAnimation.TRANSLATION_Y); 328 } 329 cancelAnimation(DynamicAnimation.ViewProperty property)330 private void cancelAnimation(DynamicAnimation.ViewProperty property) { 331 if (!mPositionAnimations.containsKey(property)) { 332 return; 333 } 334 335 mPositionAnimations.get(property).cancel(); 336 } 337 onDraggingStart()338 void onDraggingStart() { 339 mMenuView.onDraggingStart(); 340 } 341 startShrinkAnimation(Runnable endAction)342 void startShrinkAnimation(Runnable endAction) { 343 mMenuView.animate().cancel(); 344 345 mMenuView.animate() 346 .scaleX(SCALE_SHRINK) 347 .scaleY(SCALE_SHRINK) 348 .alpha(COMPLETELY_TRANSPARENT) 349 .translationY(mMenuView.getTranslationY()) 350 .withEndAction(endAction).start(); 351 } 352 startGrowAnimation()353 void startGrowAnimation() { 354 mMenuView.animate().cancel(); 355 356 mMenuView.animate() 357 .scaleX(SCALE_GROW) 358 .scaleY(SCALE_GROW) 359 .alpha(COMPLETELY_OPAQUE) 360 .translationY(mMenuView.getTranslationY()) 361 .start(); 362 } 363 onSpringAnimationsEnd(PointF position)364 private void onSpringAnimationsEnd(PointF position) { 365 mMenuView.onBoundsInParentChanged((int) position.x, (int) position.y); 366 constrainPositionAndUpdate(position); 367 368 fadeOutIfEnabled(); 369 370 if (mSpringAnimationsEndAction != null) { 371 mSpringAnimationsEndAction.run(); 372 } 373 } 374 constrainPositionAndUpdate(PointF position)375 private void constrainPositionAndUpdate(PointF position) { 376 final Rect draggableBounds = mMenuView.getMenuDraggableBoundsExcludeIme(); 377 // Have the space gap margin between the top bound and the menu view, so actually the 378 // position y range needs to cut the margin. 379 position.offset(-draggableBounds.left, -draggableBounds.top); 380 381 final float percentageX = position.x < draggableBounds.centerX() 382 ? MIN_PERCENT : MAX_PERCENT; 383 384 final float percentageY = position.y < 0 || draggableBounds.height() == 0 385 ? MIN_PERCENT 386 : Math.min(MAX_PERCENT, position.y / draggableBounds.height()); 387 mMenuView.persistPositionAndUpdateEdge(new Position(percentageX, percentageY)); 388 } 389 390 void updateOpacityWith(boolean isFadeEffectEnabled, float newOpacityValue) { 391 mIsFadeEffectEnabled = isFadeEffectEnabled; 392 393 mHandler.removeCallbacksAndMessages(/* token= */ null); 394 mFadeOutAnimator.cancel(); 395 mFadeOutAnimator.setFloatValues(COMPLETELY_OPAQUE, newOpacityValue); 396 mHandler.post(() -> mMenuView.setAlpha( 397 mIsFadeEffectEnabled ? newOpacityValue : COMPLETELY_OPAQUE)); 398 } 399 400 void fadeInNowIfEnabled() { 401 if (!mIsFadeEffectEnabled) { 402 return; 403 } 404 405 cancelAndRemoveCallbacksAndMessages(); 406 mMenuView.setAlpha(COMPLETELY_OPAQUE); 407 } 408 409 void fadeOutIfEnabled() { 410 if (!mIsFadeEffectEnabled) { 411 return; 412 } 413 414 cancelAndRemoveCallbacksAndMessages(); 415 mHandler.postDelayed(mFadeOutAnimator::start, FADE_EFFECT_DURATION_MS); 416 } 417 418 private void cancelAndRemoveCallbacksAndMessages() { 419 mFadeOutAnimator.cancel(); 420 mHandler.removeCallbacksAndMessages(/* token= */ null); 421 } 422 423 void startTuckedAnimationPreview() { 424 fadeInNowIfEnabled(); 425 426 final float toXValue = isOnLeftSide() 427 ? -ANIMATION_TO_X_VALUE 428 : ANIMATION_TO_X_VALUE; 429 final TranslateAnimation animation = 430 new TranslateAnimation(Animation.RELATIVE_TO_SELF, 0, 431 Animation.RELATIVE_TO_SELF, toXValue, 432 Animation.RELATIVE_TO_SELF, 0, 433 Animation.RELATIVE_TO_SELF, 0); 434 animation.setDuration(ANIMATION_DURATION_MS); 435 animation.setRepeatMode(Animation.REVERSE); 436 animation.setInterpolator(new OvershootInterpolator()); 437 animation.setRepeatCount(Animation.INFINITE); 438 animation.setStartOffset(ANIMATION_START_OFFSET_MS); 439 440 mMenuView.startAnimation(animation); 441 } 442 443 private Handler createUiHandler() { 444 return new Handler(requireNonNull(Looper.myLooper(), "looper must not be null")); 445 } 446 447 static class MenuPositionProperty 448 extends FloatPropertyCompat<MenuView> { 449 private final DynamicAnimation.ViewProperty mProperty; 450 451 MenuPositionProperty(DynamicAnimation.ViewProperty property) { 452 super(property.toString()); 453 mProperty = property; 454 } 455 456 @Override 457 public float getValue(MenuView menuView) { 458 return mProperty.getValue(menuView); 459 } 460 461 @Override 462 public void setValue(MenuView menuView, float value) { 463 mProperty.setValue(menuView, value); 464 } 465 } 466 } 467