| /* |
| * Copyright (C) 2022 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.systemui.accessibility.floatingmenu; |
| |
| import static java.util.Objects.requireNonNull; |
| |
| import android.animation.ValueAnimator; |
| import android.graphics.PointF; |
| import android.graphics.Rect; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.util.Log; |
| import android.view.View; |
| import android.view.animation.Animation; |
| import android.view.animation.OvershootInterpolator; |
| import android.view.animation.TranslateAnimation; |
| |
| import androidx.dynamicanimation.animation.DynamicAnimation; |
| import androidx.dynamicanimation.animation.FlingAnimation; |
| import androidx.dynamicanimation.animation.FloatPropertyCompat; |
| import androidx.dynamicanimation.animation.SpringAnimation; |
| import androidx.dynamicanimation.animation.SpringForce; |
| import androidx.recyclerview.widget.RecyclerView; |
| |
| import com.android.internal.annotations.VisibleForTesting; |
| import com.android.internal.util.Preconditions; |
| |
| import java.util.HashMap; |
| |
| /** |
| * Controls the interaction animations of the {@link MenuView}. Also, it will use the relative |
| * coordinate based on the {@link MenuViewLayer} to compute the offset of the {@link MenuView}. |
| */ |
| class MenuAnimationController { |
| private static final String TAG = "MenuAnimationController"; |
| private static final boolean DEBUG = false; |
| private static final float MIN_PERCENT = 0.0f; |
| private static final float MAX_PERCENT = 1.0f; |
| private static final float COMPLETELY_OPAQUE = 1.0f; |
| private static final float COMPLETELY_TRANSPARENT = 0.0f; |
| private static final float SCALE_SHRINK = 0.0f; |
| private static final float SCALE_GROW = 1.0f; |
| private static final float FLING_FRICTION_SCALAR = 1.9f; |
| private static final float DEFAULT_FRICTION = 4.2f; |
| private static final float SPRING_AFTER_FLING_DAMPING_RATIO = 0.85f; |
| private static final float SPRING_STIFFNESS = 700f; |
| private static final float ESCAPE_VELOCITY = 750f; |
| // Make tucked animation by using translation X relative to the view itself. |
| private static final float ANIMATION_TO_X_VALUE = 0.5f; |
| |
| private static final int ANIMATION_START_OFFSET_MS = 600; |
| private static final int ANIMATION_DURATION_MS = 600; |
| private static final int FADE_OUT_DURATION_MS = 1000; |
| private static final int FADE_EFFECT_DURATION_MS = 3000; |
| |
| private final MenuView mMenuView; |
| private final ValueAnimator mFadeOutAnimator; |
| private final Handler mHandler; |
| private boolean mIsFadeEffectEnabled; |
| private DismissAnimationController.DismissCallback mDismissCallback; |
| private Runnable mSpringAnimationsEndAction; |
| |
| // Cache the animations state of {@link DynamicAnimation.TRANSLATION_X} and {@link |
| // DynamicAnimation.TRANSLATION_Y} to be well controlled by the touch handler |
| @VisibleForTesting |
| final HashMap<DynamicAnimation.ViewProperty, DynamicAnimation> mPositionAnimations = |
| new HashMap<>(); |
| |
| MenuAnimationController(MenuView menuView) { |
| mMenuView = menuView; |
| |
| mHandler = createUiHandler(); |
| mFadeOutAnimator = new ValueAnimator(); |
| mFadeOutAnimator.setDuration(FADE_OUT_DURATION_MS); |
| mFadeOutAnimator.addUpdateListener( |
| (animation) -> menuView.setAlpha((float) animation.getAnimatedValue())); |
| } |
| |
| void moveToPosition(PointF position) { |
| moveToPositionX(position.x); |
| moveToPositionY(position.y); |
| } |
| |
| void moveToPositionX(float positionX) { |
| DynamicAnimation.TRANSLATION_X.setValue(mMenuView, positionX); |
| } |
| |
| private void moveToPositionY(float positionY) { |
| DynamicAnimation.TRANSLATION_Y.setValue(mMenuView, positionY); |
| } |
| |
| void moveToPositionYIfNeeded(float positionY) { |
| // If the list view was out of screen bounds, it would allow users to nest scroll inside |
| // and avoid conflicting with outer scroll. |
| final RecyclerView listView = (RecyclerView) mMenuView.getChildAt(/* index= */ 0); |
| if (listView.getOverScrollMode() == View.OVER_SCROLL_NEVER) { |
| moveToPositionY(positionY); |
| } |
| } |
| |
| /** |
| * Sets the action to be called when the all dynamic animations are completed. |
| */ |
| void setSpringAnimationsEndAction(Runnable runnable) { |
| mSpringAnimationsEndAction = runnable; |
| } |
| |
| void setDismissCallback( |
| DismissAnimationController.DismissCallback dismissCallback) { |
| mDismissCallback = dismissCallback; |
| } |
| |
| void moveToTopLeftPosition() { |
| mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); |
| final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); |
| moveAndPersistPosition(new PointF(draggableBounds.left, draggableBounds.top)); |
| } |
| |
| void moveToTopRightPosition() { |
| mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); |
| final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); |
| moveAndPersistPosition(new PointF(draggableBounds.right, draggableBounds.top)); |
| } |
| |
| void moveToBottomLeftPosition() { |
| mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); |
| final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); |
| moveAndPersistPosition(new PointF(draggableBounds.left, draggableBounds.bottom)); |
| } |
| |
| void moveToBottomRightPosition() { |
| mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); |
| final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); |
| moveAndPersistPosition(new PointF(draggableBounds.right, draggableBounds.bottom)); |
| } |
| |
| void moveAndPersistPosition(PointF position) { |
| moveToPosition(position); |
| mMenuView.onBoundsInParentChanged((int) position.x, (int) position.y); |
| constrainPositionAndUpdate(position); |
| } |
| |
| void removeMenu() { |
| Preconditions.checkArgument(mDismissCallback != null, |
| "The dismiss callback should be initialized first."); |
| |
| mDismissCallback.onDismiss(); |
| } |
| |
| void flingMenuThenSpringToEdge(float x, float velocityX, float velocityY) { |
| final boolean shouldMenuFlingLeft = isOnLeftSide() |
| ? velocityX < ESCAPE_VELOCITY |
| : velocityX < -ESCAPE_VELOCITY; |
| |
| final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); |
| final float finalPositionX = shouldMenuFlingLeft |
| ? draggableBounds.left : draggableBounds.right; |
| |
| final float minimumVelocityToReachEdge = |
| (finalPositionX - x) * (FLING_FRICTION_SCALAR * DEFAULT_FRICTION); |
| |
| final float startXVelocity = shouldMenuFlingLeft |
| ? Math.min(minimumVelocityToReachEdge, velocityX) |
| : Math.max(minimumVelocityToReachEdge, velocityX); |
| |
| flingThenSpringMenuWith(DynamicAnimation.TRANSLATION_X, |
| startXVelocity, |
| FLING_FRICTION_SCALAR, |
| new SpringForce() |
| .setStiffness(SPRING_STIFFNESS) |
| .setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO), |
| finalPositionX); |
| |
| flingThenSpringMenuWith(DynamicAnimation.TRANSLATION_Y, |
| velocityY, |
| FLING_FRICTION_SCALAR, |
| new SpringForce() |
| .setStiffness(SPRING_STIFFNESS) |
| .setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO), |
| /* finalPosition= */ null); |
| } |
| |
| private void flingThenSpringMenuWith(DynamicAnimation.ViewProperty property, float velocity, |
| float friction, SpringForce spring, Float finalPosition) { |
| |
| final MenuPositionProperty menuPositionProperty = new MenuPositionProperty(property); |
| final float currentValue = menuPositionProperty.getValue(mMenuView); |
| final Rect bounds = mMenuView.getMenuDraggableBounds(); |
| final float min = |
| property.equals(DynamicAnimation.TRANSLATION_X) |
| ? bounds.left |
| : bounds.top; |
| final float max = |
| property.equals(DynamicAnimation.TRANSLATION_X) |
| ? bounds.right |
| : bounds.bottom; |
| |
| final FlingAnimation flingAnimation = createFlingAnimation(mMenuView, menuPositionProperty); |
| flingAnimation.setFriction(friction) |
| .setStartVelocity(velocity) |
| .setMinValue(Math.min(currentValue, min)) |
| .setMaxValue(Math.max(currentValue, max)) |
| .addEndListener((animation, canceled, endValue, endVelocity) -> { |
| if (canceled) { |
| if (DEBUG) { |
| Log.d(TAG, "The fling animation was canceled."); |
| } |
| |
| return; |
| } |
| |
| final float endPosition = finalPosition != null |
| ? finalPosition |
| : Math.max(min, Math.min(max, endValue)); |
| springMenuWith(property, spring, endVelocity, endPosition); |
| }); |
| |
| cancelAnimation(property); |
| mPositionAnimations.put(property, flingAnimation); |
| flingAnimation.start(); |
| } |
| |
| @VisibleForTesting |
| FlingAnimation createFlingAnimation(MenuView menuView, |
| MenuPositionProperty menuPositionProperty) { |
| return new FlingAnimation(menuView, menuPositionProperty); |
| } |
| |
| @VisibleForTesting |
| void springMenuWith(DynamicAnimation.ViewProperty property, SpringForce spring, |
| float velocity, float finalPosition) { |
| final MenuPositionProperty menuPositionProperty = new MenuPositionProperty(property); |
| final SpringAnimation springAnimation = |
| new SpringAnimation(mMenuView, menuPositionProperty) |
| .setSpring(spring) |
| .addEndListener((animation, canceled, endValue, endVelocity) -> { |
| if (canceled || endValue != finalPosition) { |
| return; |
| } |
| |
| final boolean areAnimationsRunning = |
| mPositionAnimations.values().stream().anyMatch( |
| DynamicAnimation::isRunning); |
| if (!areAnimationsRunning) { |
| onSpringAnimationsEnd(new PointF(mMenuView.getTranslationX(), |
| mMenuView.getTranslationY())); |
| } |
| }) |
| .setStartVelocity(velocity); |
| |
| cancelAnimation(property); |
| mPositionAnimations.put(property, springAnimation); |
| springAnimation.animateToFinalPosition(finalPosition); |
| } |
| |
| /** |
| * Determines whether to hide the menu to the edge of the screen with the given current |
| * translation x of the menu view. It should be used when receiving the action up touch event. |
| * |
| * @param currentXTranslation the current translation x of the menu view. |
| * @return true if the menu would be hidden to the edge, otherwise false. |
| */ |
| boolean maybeMoveToEdgeAndHide(float currentXTranslation) { |
| final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); |
| |
| // If the translation x is zero, it should be at the left of the bound. |
| if (currentXTranslation < draggableBounds.left |
| || currentXTranslation > draggableBounds.right) { |
| constrainPositionAndUpdate( |
| new PointF(mMenuView.getTranslationX(), mMenuView.getTranslationY())); |
| moveToEdgeAndHide(); |
| return true; |
| } |
| |
| fadeOutIfEnabled(); |
| return false; |
| } |
| |
| boolean isOnLeftSide() { |
| return mMenuView.getTranslationX() < mMenuView.getMenuDraggableBounds().centerX(); |
| } |
| |
| boolean isMoveToTucked() { |
| return mMenuView.isMoveToTucked(); |
| } |
| |
| void moveToEdgeAndHide() { |
| mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ true); |
| |
| final PointF position = mMenuView.getMenuPosition(); |
| final float menuHalfWidth = mMenuView.getMenuWidth() / 2.0f; |
| final float endX = isOnLeftSide() |
| ? position.x - menuHalfWidth |
| : position.x + menuHalfWidth; |
| moveToPosition(new PointF(endX, position.y)); |
| |
| // Keep the touch region let users could click extra space to pop up the menu view |
| // from the screen edge |
| mMenuView.onBoundsInParentChanged((int) position.x, (int) position.y); |
| |
| fadeOutIfEnabled(); |
| } |
| |
| void moveOutEdgeAndShow() { |
| mMenuView.updateMenuMoveToTucked(/* isMoveToTucked= */ false); |
| |
| mMenuView.onPositionChanged(); |
| mMenuView.onEdgeChangedIfNeeded(); |
| } |
| |
| void cancelAnimations() { |
| cancelAnimation(DynamicAnimation.TRANSLATION_X); |
| cancelAnimation(DynamicAnimation.TRANSLATION_Y); |
| } |
| |
| private void cancelAnimation(DynamicAnimation.ViewProperty property) { |
| if (!mPositionAnimations.containsKey(property)) { |
| return; |
| } |
| |
| mPositionAnimations.get(property).cancel(); |
| } |
| |
| void onDraggingStart() { |
| mMenuView.onDraggingStart(); |
| } |
| |
| void startShrinkAnimation(Runnable endAction) { |
| mMenuView.animate().cancel(); |
| |
| mMenuView.animate() |
| .scaleX(SCALE_SHRINK) |
| .scaleY(SCALE_SHRINK) |
| .alpha(COMPLETELY_TRANSPARENT) |
| .translationY(mMenuView.getTranslationY()) |
| .withEndAction(endAction).start(); |
| } |
| |
| void startGrowAnimation() { |
| mMenuView.animate().cancel(); |
| |
| mMenuView.animate() |
| .scaleX(SCALE_GROW) |
| .scaleY(SCALE_GROW) |
| .alpha(COMPLETELY_OPAQUE) |
| .translationY(mMenuView.getTranslationY()) |
| .start(); |
| } |
| |
| private void onSpringAnimationsEnd(PointF position) { |
| mMenuView.onBoundsInParentChanged((int) position.x, (int) position.y); |
| constrainPositionAndUpdate(position); |
| |
| fadeOutIfEnabled(); |
| |
| if (mSpringAnimationsEndAction != null) { |
| mSpringAnimationsEndAction.run(); |
| } |
| } |
| |
| private void constrainPositionAndUpdate(PointF position) { |
| final Rect draggableBounds = mMenuView.getMenuDraggableBoundsExcludeIme(); |
| // Have the space gap margin between the top bound and the menu view, so actually the |
| // position y range needs to cut the margin. |
| position.offset(-draggableBounds.left, -draggableBounds.top); |
| |
| final float percentageX = position.x < draggableBounds.centerX() |
| ? MIN_PERCENT : MAX_PERCENT; |
| |
| final float percentageY = position.y < 0 || draggableBounds.height() == 0 |
| ? MIN_PERCENT |
| : Math.min(MAX_PERCENT, position.y / draggableBounds.height()); |
| mMenuView.persistPositionAndUpdateEdge(new Position(percentageX, percentageY)); |
| } |
| |
| void updateOpacityWith(boolean isFadeEffectEnabled, float newOpacityValue) { |
| mIsFadeEffectEnabled = isFadeEffectEnabled; |
| |
| mHandler.removeCallbacksAndMessages(/* token= */ null); |
| mFadeOutAnimator.cancel(); |
| mFadeOutAnimator.setFloatValues(COMPLETELY_OPAQUE, newOpacityValue); |
| mHandler.post(() -> mMenuView.setAlpha( |
| mIsFadeEffectEnabled ? newOpacityValue : COMPLETELY_OPAQUE)); |
| } |
| |
| void fadeInNowIfEnabled() { |
| if (!mIsFadeEffectEnabled) { |
| return; |
| } |
| |
| cancelAndRemoveCallbacksAndMessages(); |
| mMenuView.setAlpha(COMPLETELY_OPAQUE); |
| } |
| |
| void fadeOutIfEnabled() { |
| if (!mIsFadeEffectEnabled) { |
| return; |
| } |
| |
| cancelAndRemoveCallbacksAndMessages(); |
| mHandler.postDelayed(mFadeOutAnimator::start, FADE_EFFECT_DURATION_MS); |
| } |
| |
| private void cancelAndRemoveCallbacksAndMessages() { |
| mFadeOutAnimator.cancel(); |
| mHandler.removeCallbacksAndMessages(/* token= */ null); |
| } |
| |
| void startTuckedAnimationPreview() { |
| fadeInNowIfEnabled(); |
| |
| final float toXValue = isOnLeftSide() |
| ? -ANIMATION_TO_X_VALUE |
| : ANIMATION_TO_X_VALUE; |
| final TranslateAnimation animation = |
| new TranslateAnimation(Animation.RELATIVE_TO_SELF, 0, |
| Animation.RELATIVE_TO_SELF, toXValue, |
| Animation.RELATIVE_TO_SELF, 0, |
| Animation.RELATIVE_TO_SELF, 0); |
| animation.setDuration(ANIMATION_DURATION_MS); |
| animation.setRepeatMode(Animation.REVERSE); |
| animation.setInterpolator(new OvershootInterpolator()); |
| animation.setRepeatCount(Animation.INFINITE); |
| animation.setStartOffset(ANIMATION_START_OFFSET_MS); |
| |
| mMenuView.startAnimation(animation); |
| } |
| |
| private Handler createUiHandler() { |
| return new Handler(requireNonNull(Looper.myLooper(), "looper must not be null")); |
| } |
| |
| static class MenuPositionProperty |
| extends FloatPropertyCompat<MenuView> { |
| private final DynamicAnimation.ViewProperty mProperty; |
| |
| MenuPositionProperty(DynamicAnimation.ViewProperty property) { |
| super(property.toString()); |
| mProperty = property; |
| } |
| |
| @Override |
| public float getValue(MenuView menuView) { |
| return mProperty.getValue(menuView); |
| } |
| |
| @Override |
| public void setValue(MenuView menuView, float value) { |
| mProperty.setValue(menuView, value); |
| } |
| } |
| } |