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