| /* |
| * 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.wm.shell.bubbles.animation; |
| |
| import static android.view.View.ALPHA; |
| |
| import static com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_COLLAPSE_ANIMATOR; |
| import static com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_EXPANDED_VIEW_DRAGGING; |
| import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES; |
| import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME; |
| import static com.android.wm.shell.bubbles.BubbleExpandedView.BOTTOM_CLIP_PROPERTY; |
| import static com.android.wm.shell.bubbles.BubbleExpandedView.CONTENT_ALPHA; |
| import static com.android.wm.shell.bubbles.BubbleExpandedView.MANAGE_BUTTON_ALPHA; |
| |
| import android.animation.Animator; |
| import android.animation.AnimatorListenerAdapter; |
| import android.animation.AnimatorSet; |
| import android.animation.ObjectAnimator; |
| import android.animation.ValueAnimator; |
| import android.annotation.SuppressLint; |
| import android.content.Context; |
| import android.util.Log; |
| import android.view.HapticFeedbackConstants; |
| import android.view.ViewConfiguration; |
| |
| import androidx.annotation.Nullable; |
| import androidx.dynamicanimation.animation.DynamicAnimation; |
| import androidx.dynamicanimation.animation.FloatPropertyCompat; |
| import androidx.dynamicanimation.animation.SpringAnimation; |
| import androidx.dynamicanimation.animation.SpringForce; |
| |
| import com.android.wm.shell.animation.FlingAnimationUtils; |
| import com.android.wm.shell.animation.Interpolators; |
| import com.android.wm.shell.bubbles.BubbleExpandedView; |
| import com.android.wm.shell.bubbles.BubblePositioner; |
| |
| import java.util.ArrayList; |
| import java.util.List; |
| |
| /** |
| * Implementation of {@link ExpandedViewAnimationController} that uses a collapse animation to |
| * hide the {@link BubbleExpandedView} |
| */ |
| public class ExpandedViewAnimationControllerImpl implements ExpandedViewAnimationController { |
| |
| private static final String TAG = TAG_WITH_CLASS_NAME ? "ExpandedViewAnimCtrl" : TAG_BUBBLES; |
| |
| private static final float COLLAPSE_THRESHOLD = 0.02f; |
| |
| private static final int COLLAPSE_DURATION_MS = 250; |
| |
| private static final int MANAGE_BUTTON_ANIM_DURATION_MS = 78; |
| |
| private static final int CONTENT_OPACITY_ANIM_DELAY_MS = 93; |
| private static final int CONTENT_OPACITY_ANIM_DURATION_MS = 78; |
| |
| private static final int BACKGROUND_OPACITY_ANIM_DELAY_MS = 172; |
| private static final int BACKGROUND_OPACITY_ANIM_DURATION_MS = 78; |
| |
| /** Animation fraction threshold for content alpha animation when stack collapse should begin */ |
| private static final float STACK_COLLAPSE_THRESHOLD = 0.5f; |
| |
| private static final FloatPropertyCompat<ExpandedViewAnimationControllerImpl> |
| COLLAPSE_HEIGHT_PROPERTY = |
| new FloatPropertyCompat<ExpandedViewAnimationControllerImpl>("CollapseSpring") { |
| @Override |
| public float getValue(ExpandedViewAnimationControllerImpl controller) { |
| return controller.getCollapsedAmount(); |
| } |
| |
| @Override |
| public void setValue(ExpandedViewAnimationControllerImpl controller, |
| float value) { |
| controller.setCollapsedAmount(value); |
| } |
| }; |
| |
| private final int mMinFlingVelocity; |
| private float mSwipeUpVelocity; |
| private float mSwipeDownVelocity; |
| private final BubblePositioner mPositioner; |
| private final FlingAnimationUtils mFlingAnimationUtils; |
| private int mDraggedAmount; |
| private float mCollapsedAmount; |
| @Nullable |
| private BubbleExpandedView mExpandedView; |
| @Nullable |
| private AnimatorSet mCollapseAnimation; |
| private boolean mNotifiedAboutThreshold; |
| private SpringAnimation mBackToExpandedAnimation; |
| @Nullable |
| private ObjectAnimator mBottomClipAnim; |
| |
| public ExpandedViewAnimationControllerImpl(Context context, BubblePositioner positioner) { |
| mFlingAnimationUtils = new FlingAnimationUtils(context.getResources().getDisplayMetrics(), |
| COLLAPSE_DURATION_MS / 1000f); |
| mMinFlingVelocity = ViewConfiguration.get(context).getScaledMinimumFlingVelocity(); |
| mPositioner = positioner; |
| } |
| |
| private static void adjustAnimatorSetDuration(AnimatorSet animatorSet, |
| float durationAdjustment) { |
| for (Animator animator : animatorSet.getChildAnimations()) { |
| animator.setStartDelay((long) (animator.getStartDelay() * durationAdjustment)); |
| animator.setDuration((long) (animator.getDuration() * durationAdjustment)); |
| } |
| } |
| |
| @Override |
| public void setExpandedView(BubbleExpandedView expandedView) { |
| if (mExpandedView != null) { |
| if (DEBUG_COLLAPSE_ANIMATOR) { |
| Log.d(TAG, "updating expandedView, resetting previous"); |
| } |
| if (mCollapseAnimation != null) { |
| mCollapseAnimation.cancel(); |
| } |
| if (mBackToExpandedAnimation != null) { |
| mBackToExpandedAnimation.cancel(); |
| } |
| reset(); |
| } |
| mExpandedView = expandedView; |
| } |
| |
| @Override |
| public void updateDrag(float distance) { |
| if (mExpandedView != null) { |
| mDraggedAmount = OverScroll.dampedScroll(distance, mExpandedView.getContentHeight()); |
| |
| if (DEBUG_COLLAPSE_ANIMATOR && DEBUG_EXPANDED_VIEW_DRAGGING) { |
| Log.d(TAG, "updateDrag: distance=" + distance + " dragged=" + mDraggedAmount); |
| } |
| |
| setCollapsedAmount(mDraggedAmount); |
| |
| if (!mNotifiedAboutThreshold && isPastCollapseThreshold()) { |
| mNotifiedAboutThreshold = true; |
| if (DEBUG_COLLAPSE_ANIMATOR) { |
| Log.d(TAG, "notifying over collapse threshold"); |
| } |
| vibrateIfEnabled(); |
| } |
| } |
| } |
| |
| @Override |
| public void setSwipeVelocity(float velocity) { |
| if (velocity < 0) { |
| mSwipeUpVelocity = Math.abs(velocity); |
| mSwipeDownVelocity = 0; |
| } else { |
| mSwipeUpVelocity = 0; |
| mSwipeDownVelocity = velocity; |
| } |
| } |
| |
| @Override |
| public boolean shouldCollapse() { |
| if (mSwipeDownVelocity > mMinFlingVelocity) { |
| // Swipe velocity is positive and over fling velocity. |
| // This is a swipe down, always reset to expanded state, regardless of dragged amount. |
| if (DEBUG_COLLAPSE_ANIMATOR) { |
| Log.d(TAG, |
| "not collapsing expanded view, swipe down velocity: " + mSwipeDownVelocity |
| + " minV: " + mMinFlingVelocity); |
| } |
| return false; |
| } |
| |
| if (mSwipeUpVelocity > mMinFlingVelocity) { |
| // Swiping up and over fling velocity, collapse the view. |
| if (DEBUG_COLLAPSE_ANIMATOR) { |
| Log.d(TAG, |
| "collapse expanded view, swipe up velocity: " + mSwipeUpVelocity + " minV: " |
| + mMinFlingVelocity); |
| } |
| return true; |
| } |
| |
| if (isPastCollapseThreshold()) { |
| if (DEBUG_COLLAPSE_ANIMATOR) { |
| Log.d(TAG, "collapse expanded view, past threshold, dragged: " + mDraggedAmount); |
| } |
| return true; |
| } |
| |
| if (DEBUG_COLLAPSE_ANIMATOR) { |
| Log.d(TAG, "not collapsing expanded view"); |
| } |
| |
| return false; |
| } |
| |
| @Override |
| public void animateCollapse(Runnable startStackCollapse, Runnable after) { |
| if (DEBUG_COLLAPSE_ANIMATOR) { |
| Log.d(TAG, |
| "expandedView animate collapse swipeVel=" + mSwipeUpVelocity + " minFlingVel=" |
| + mMinFlingVelocity); |
| } |
| if (mExpandedView != null) { |
| // Mark it as animating immediately to avoid updates to the view before animation starts |
| mExpandedView.setAnimating(true); |
| |
| if (mCollapseAnimation != null) { |
| mCollapseAnimation.cancel(); |
| } |
| mCollapseAnimation = createCollapseAnimation(mExpandedView, startStackCollapse, after); |
| |
| if (mSwipeUpVelocity >= mMinFlingVelocity) { |
| int contentHeight = mExpandedView.getContentHeight(); |
| |
| // Use a temp animator to get adjusted duration value for swipe. |
| // This new value will be used to adjust animation times proportionally in the |
| // animator set. If we adjust animator set duration directly, all child animations |
| // will get the same animation time. |
| ValueAnimator tempAnimator = new ValueAnimator(); |
| mFlingAnimationUtils.applyDismissing(tempAnimator, mCollapsedAmount, contentHeight, |
| mSwipeUpVelocity, (contentHeight - mCollapsedAmount)); |
| |
| float durationAdjustment = |
| (float) tempAnimator.getDuration() / COLLAPSE_DURATION_MS; |
| |
| adjustAnimatorSetDuration(mCollapseAnimation, durationAdjustment); |
| mCollapseAnimation.setInterpolator(tempAnimator.getInterpolator()); |
| } |
| mCollapseAnimation.start(); |
| } |
| } |
| |
| @Override |
| public void animateBackToExpanded() { |
| if (DEBUG_COLLAPSE_ANIMATOR) { |
| Log.d(TAG, "expandedView animate back to expanded"); |
| } |
| BubbleExpandedView expandedView = mExpandedView; |
| if (expandedView == null) { |
| return; |
| } |
| |
| expandedView.setAnimating(true); |
| |
| mBackToExpandedAnimation = new SpringAnimation(this, COLLAPSE_HEIGHT_PROPERTY); |
| mBackToExpandedAnimation.setSpring(new SpringForce() |
| .setStiffness(SpringForce.STIFFNESS_LOW) |
| .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY) |
| ); |
| mBackToExpandedAnimation.addEndListener(new OneTimeEndListener() { |
| @Override |
| public void onAnimationEnd(DynamicAnimation animation, boolean canceled, float value, |
| float velocity) { |
| super.onAnimationEnd(animation, canceled, value, velocity); |
| mNotifiedAboutThreshold = false; |
| mBackToExpandedAnimation = null; |
| expandedView.setAnimating(false); |
| } |
| }); |
| mBackToExpandedAnimation.setStartValue(mCollapsedAmount); |
| mBackToExpandedAnimation.animateToFinalPosition(0); |
| } |
| |
| @Override |
| public void animateForImeVisibilityChange(boolean visible) { |
| if (mExpandedView != null) { |
| if (mBottomClipAnim != null) { |
| mBottomClipAnim.cancel(); |
| } |
| int clip = 0; |
| if (visible) { |
| // Clip the expanded view at the top of the IME view |
| clip = mExpandedView.getContentBottomOnScreen() - mPositioner.getImeTop(); |
| // Don't allow negative clip value |
| clip = Math.max(clip, 0); |
| } |
| mBottomClipAnim = ObjectAnimator.ofInt(mExpandedView, BOTTOM_CLIP_PROPERTY, clip); |
| mBottomClipAnim.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| mBottomClipAnim = null; |
| } |
| }); |
| mBottomClipAnim.start(); |
| } |
| } |
| |
| @Override |
| public void reset() { |
| if (DEBUG_COLLAPSE_ANIMATOR) { |
| Log.d(TAG, "reset expandedView collapsed state"); |
| } |
| if (mExpandedView == null) { |
| return; |
| } |
| mExpandedView.setAnimating(false); |
| |
| if (mCollapseAnimation != null) { |
| mCollapseAnimation.cancel(); |
| } |
| if (mBackToExpandedAnimation != null) { |
| mBackToExpandedAnimation.cancel(); |
| } |
| mExpandedView.setContentAlpha(1); |
| mExpandedView.setAlpha(1); |
| mExpandedView.setManageButtonAlpha(1); |
| setCollapsedAmount(0); |
| mExpandedView.setBottomClip(0); |
| mExpandedView.movePointerBy(0, 0); |
| mCollapsedAmount = 0; |
| mDraggedAmount = 0; |
| mSwipeUpVelocity = 0; |
| mSwipeDownVelocity = 0; |
| mNotifiedAboutThreshold = false; |
| } |
| |
| private float getCollapsedAmount() { |
| return mCollapsedAmount; |
| } |
| |
| private void setCollapsedAmount(float collapsed) { |
| if (mCollapsedAmount != collapsed) { |
| float previous = mCollapsedAmount; |
| mCollapsedAmount = collapsed; |
| |
| if (mExpandedView != null) { |
| if (previous == 0) { |
| // View was not collapsed before. Apply z order change |
| mExpandedView.setSurfaceZOrderedOnTop(true); |
| mExpandedView.setAnimating(true); |
| } |
| |
| mExpandedView.setTopClip((int) mCollapsedAmount); |
| // Move up with translationY. Use negative collapsed value |
| mExpandedView.setContentTranslationY(-mCollapsedAmount); |
| mExpandedView.setManageButtonTranslationY(-mCollapsedAmount); |
| |
| if (mCollapsedAmount == 0) { |
| // View is no longer collapsed. Revert z order change |
| mExpandedView.setSurfaceZOrderedOnTop(false); |
| mExpandedView.setAnimating(false); |
| } |
| } |
| } |
| } |
| |
| private boolean isPastCollapseThreshold() { |
| if (mExpandedView != null) { |
| return mDraggedAmount > mExpandedView.getContentHeight() * COLLAPSE_THRESHOLD; |
| } |
| return false; |
| } |
| |
| private AnimatorSet createCollapseAnimation(BubbleExpandedView expandedView, |
| Runnable startStackCollapse, Runnable after) { |
| List<Animator> animatorList = new ArrayList<>(); |
| animatorList.add(createHeightAnimation(expandedView)); |
| animatorList.add(createManageButtonAnimation()); |
| ObjectAnimator contentAlphaAnimation = createContentAlphaAnimation(); |
| final boolean[] notified = {false}; |
| contentAlphaAnimation.addUpdateListener(animation -> { |
| if (!notified[0] && animation.getAnimatedFraction() > STACK_COLLAPSE_THRESHOLD) { |
| notified[0] = true; |
| // Notify bubbles that they can start moving back to the collapsed position |
| startStackCollapse.run(); |
| } |
| }); |
| animatorList.add(contentAlphaAnimation); |
| animatorList.add(createBackgroundAlphaAnimation()); |
| |
| AnimatorSet animatorSet = new AnimatorSet(); |
| animatorSet.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| after.run(); |
| } |
| }); |
| animatorSet.playTogether(animatorList); |
| return animatorSet; |
| } |
| |
| private ValueAnimator createHeightAnimation(BubbleExpandedView expandedView) { |
| ValueAnimator animator = ValueAnimator.ofInt((int) mCollapsedAmount, |
| expandedView.getContentHeight()); |
| animator.setInterpolator(Interpolators.EMPHASIZED_ACCELERATE); |
| animator.setDuration(COLLAPSE_DURATION_MS); |
| animator.addUpdateListener(anim -> setCollapsedAmount((int) anim.getAnimatedValue())); |
| return animator; |
| } |
| |
| private ObjectAnimator createManageButtonAnimation() { |
| ObjectAnimator animator = ObjectAnimator.ofFloat(mExpandedView, MANAGE_BUTTON_ALPHA, 0f); |
| animator.setDuration(MANAGE_BUTTON_ANIM_DURATION_MS); |
| animator.setInterpolator(Interpolators.LINEAR); |
| return animator; |
| } |
| |
| private ObjectAnimator createContentAlphaAnimation() { |
| ObjectAnimator animator = ObjectAnimator.ofFloat(mExpandedView, CONTENT_ALPHA, 0f); |
| animator.setDuration(CONTENT_OPACITY_ANIM_DURATION_MS); |
| animator.setInterpolator(Interpolators.LINEAR); |
| animator.setStartDelay(CONTENT_OPACITY_ANIM_DELAY_MS); |
| return animator; |
| } |
| |
| private ObjectAnimator createBackgroundAlphaAnimation() { |
| ObjectAnimator animator = ObjectAnimator.ofFloat(mExpandedView, ALPHA, 0f); |
| animator.setDuration(BACKGROUND_OPACITY_ANIM_DURATION_MS); |
| animator.setInterpolator(Interpolators.LINEAR); |
| animator.setStartDelay(BACKGROUND_OPACITY_ANIM_DELAY_MS); |
| return animator; |
| } |
| |
| @SuppressLint("MissingPermission") |
| private void vibrateIfEnabled() { |
| if (mExpandedView != null) { |
| mExpandedView.performHapticFeedback(HapticFeedbackConstants.DRAG_CROSSING); |
| } |
| } |
| } |