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