blob: b378469c4c980c286d14c240760c35dac1cc50ed [file] [log] [blame]
/*
* Copyright (C) 2019 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.bubbles.animation;
import android.content.ContentResolver;
import android.content.res.Resources;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.RectF;
import android.provider.Settings;
import android.util.Log;
import android.view.View;
import android.view.WindowInsets;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
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 com.android.systemui.R;
import com.android.systemui.util.FloatingContentCoordinator;
import com.android.systemui.util.animation.PhysicsAnimator;
import com.android.systemui.util.magnetictarget.MagnetizedObject;
import com.google.android.collect.Sets;
import java.io.FileDescriptor;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Set;
import java.util.function.IntSupplier;
/**
* Animation controller for bubbles when they're in their stacked state. Stacked bubbles sit atop
* each other with a slight offset to the left or right (depending on which side of the screen they
* are on). Bubbles 'follow' each other when dragged, and can be flung to the left or right sides of
* the screen.
*/
public class StackAnimationController extends
PhysicsAnimationLayout.PhysicsAnimationController {
private static final String TAG = "Bubbs.StackCtrl";
/** Scale factor to use initially for new bubbles being animated in. */
private static final float ANIMATE_IN_STARTING_SCALE = 1.15f;
/** Translation factor (multiplied by stack offset) to use for bubbles being animated in/out. */
private static final int ANIMATE_TRANSLATION_FACTOR = 4;
/** Values to use for animating bubbles in. */
private static final float ANIMATE_IN_STIFFNESS = 1000f;
private static final int ANIMATE_IN_START_DELAY = 25;
/**
* Values to use for the default {@link SpringForce} provided to the physics animation layout.
*/
public static final int DEFAULT_STIFFNESS = 12000;
public static final float IME_ANIMATION_STIFFNESS = SpringForce.STIFFNESS_LOW;
private static final int FLING_FOLLOW_STIFFNESS = 20000;
public static final float DEFAULT_BOUNCINESS = 0.9f;
private final PhysicsAnimator.SpringConfig mAnimateOutSpringConfig =
new PhysicsAnimator.SpringConfig(
ANIMATE_IN_STIFFNESS, SpringForce.DAMPING_RATIO_NO_BOUNCY);
/**
* Friction applied to fling animations. Since the stack must land on one of the sides of the
* screen, we want less friction horizontally so that the stack has a better chance of making it
* to the side without needing a spring.
*/
private static final float FLING_FRICTION = 2.2f;
/**
* Values to use for the stack spring animation used to spring the stack to its final position
* after a fling.
*/
private static final int SPRING_AFTER_FLING_STIFFNESS = 750;
private static final float SPRING_AFTER_FLING_DAMPING_RATIO = 0.85f;
/** Sentinel value for unset position value. */
private static final float UNSET = -Float.MIN_VALUE;
/**
* Minimum fling velocity required to trigger moving the stack from one side of the screen to
* the other.
*/
private static final float ESCAPE_VELOCITY = 750f;
/** Velocity required to dismiss the stack without dragging it into the dismiss target. */
private static final float FLING_TO_DISMISS_MIN_VELOCITY = 4000f;
/**
* The canonical position of the stack. This is typically the position of the first bubble, but
* we need to keep track of it separately from the first bubble's translation in case there are
* no bubbles, or the first bubble was just added and being animated to its new position.
*/
private PointF mStackPosition = new PointF(-1, -1);
/**
* MagnetizedObject instance for the stack, which is used by the touch handler for the magnetic
* dismiss target.
*/
private MagnetizedObject<StackAnimationController> mMagnetizedStack;
/**
* The area that Bubbles will occupy after all animations end. This is used to move other
* floating content out of the way proactively.
*/
private Rect mAnimatingToBounds = new Rect();
/** Whether or not the stack's start position has been set. */
private boolean mStackMovedToStartPosition = false;
/**
* The stack's most recent position along the edge of the screen. This is saved when the last
* bubble is removed, so that the stack can be restored in its previous position.
*/
private PointF mRestingStackPosition;
/** The height of the most recently visible IME. */
private float mImeHeight = 0f;
/**
* The Y position of the stack before the IME became visible, or {@link Float#MIN_VALUE} if the
* IME is not visible or the user moved the stack since the IME became visible.
*/
private float mPreImeY = UNSET;
/**
* Animations on the stack position itself, which would have been started in
* {@link #flingThenSpringFirstBubbleWithStackFollowing}. These animations dispatch to
* {@link #moveFirstBubbleWithStackFollowing} to move the entire stack (with 'following' effect)
* to a legal position on the side of the screen.
*/
private HashMap<DynamicAnimation.ViewProperty, DynamicAnimation> mStackPositionAnimations =
new HashMap<>();
/**
* Whether the current motion of the stack is due to a fling animation (vs. being dragged
* manually).
*/
private boolean mIsMovingFromFlinging = false;
/**
* Whether the first bubble is springing towards the touch point, rather than using the default
* behavior of moving directly to the touch point with the rest of the stack following it.
*
* This happens when the user's finger exits the dismiss area while the stack is magnetized to
* the center. Since the touch point differs from the stack location, we need to animate the
* stack back to the touch point to avoid a jarring instant location change from the center of
* the target to the touch point just outside the target bounds.
*
* This is reset once the spring animations end, since that means the first bubble has
* successfully 'caught up' to the touch.
*/
private boolean mFirstBubbleSpringingToTouch = false;
/**
* Whether to spring the stack to the next touch event coordinates. This is used to animate the
* stack (including the first bubble) out of the magnetic dismiss target to the touch location.
* Once it 'catches up' and the animation ends, we'll revert to moving the first bubble directly
* and only animating the following bubbles.
*/
private boolean mSpringToTouchOnNextMotionEvent = false;
/** Horizontal offset of bubbles in the stack. */
private float mStackOffset;
/** Diameter of the bubble icon. */
private int mBubbleBitmapSize;
/** Width of the bubble (icon and padding). */
private int mBubbleSize;
/**
* The amount of space to add between the bubbles and certain UI elements, such as the top of
* the screen or the IME. This does not apply to the left/right sides of the screen since the
* stack goes offscreen intentionally.
*/
private int mBubblePaddingTop;
/** How far offscreen the stack rests. */
private int mBubbleOffscreen;
/** How far down the screen the stack starts, when there is no pre-existing location. */
private int mStackStartingVerticalOffset;
/** Height of the status bar. */
private float mStatusBarHeight;
/** FloatingContentCoordinator instance for resolving floating content conflicts. */
private FloatingContentCoordinator mFloatingContentCoordinator;
/**
* FloatingContent instance that returns the stack's location on the screen, and moves it when
* requested.
*/
private final FloatingContentCoordinator.FloatingContent mStackFloatingContent =
new FloatingContentCoordinator.FloatingContent() {
private final Rect mFloatingBoundsOnScreen = new Rect();
@Override
public void moveToBounds(@NonNull Rect bounds) {
springStack(bounds.left, bounds.top, SpringForce.STIFFNESS_LOW);
}
@NonNull
@Override
public Rect getAllowedFloatingBoundsRegion() {
final Rect floatingBounds = getFloatingBoundsOnScreen();
final Rect allowableStackArea = new Rect();
getAllowableStackPositionRegion().roundOut(allowableStackArea);
allowableStackArea.right += floatingBounds.width();
allowableStackArea.bottom += floatingBounds.height();
return allowableStackArea;
}
@NonNull
@Override
public Rect getFloatingBoundsOnScreen() {
if (!mAnimatingToBounds.isEmpty()) {
return mAnimatingToBounds;
}
if (mLayout.getChildCount() > 0) {
// Calculate the bounds using stack position + bubble size so that we don't need to
// wait for the bubble views to lay out.
mFloatingBoundsOnScreen.set(
(int) mStackPosition.x,
(int) mStackPosition.y,
(int) mStackPosition.x + mBubbleSize,
(int) mStackPosition.y + mBubbleSize + mBubblePaddingTop);
} else {
mFloatingBoundsOnScreen.setEmpty();
}
return mFloatingBoundsOnScreen;
}
};
/** Returns the number of 'real' bubbles (excluding the overflow bubble). */
private IntSupplier mBubbleCountSupplier;
/**
* Callback to run whenever any bubble is animated out. The BubbleStackView will check if the
* end of this animation means we have no bubbles left, and notify the BubbleController.
*/
private Runnable mOnBubbleAnimatedOutAction;
public StackAnimationController(
FloatingContentCoordinator floatingContentCoordinator,
IntSupplier bubbleCountSupplier,
Runnable onBubbleAnimatedOutAction) {
mFloatingContentCoordinator = floatingContentCoordinator;
mBubbleCountSupplier = bubbleCountSupplier;
mOnBubbleAnimatedOutAction = onBubbleAnimatedOutAction;
}
/**
* Instantly move the first bubble to the given point, and animate the rest of the stack behind
* it with the 'following' effect.
*/
public void moveFirstBubbleWithStackFollowing(float x, float y) {
// If we're moving the bubble around, we're not animating to any bounds.
mAnimatingToBounds.setEmpty();
// If we manually move the bubbles with the IME open, clear the return point since we don't
// want the stack to snap away from the new position.
mPreImeY = UNSET;
moveFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_X, x);
moveFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_Y, y);
// This method is called when the stack is being dragged manually, so we're clearly no
// longer flinging.
mIsMovingFromFlinging = false;
}
/**
* The position of the stack - typically the position of the first bubble; if no bubbles have
* been added yet, it will be where the first bubble will go when added.
*/
public PointF getStackPosition() {
return mStackPosition;
}
/** Whether the stack is on the left side of the screen. */
public boolean isStackOnLeftSide() {
if (mLayout == null || !isStackPositionSet()) {
return true; // Default to left, which is where it starts by default.
}
float stackCenter = mStackPosition.x + mBubbleBitmapSize / 2;
float screenCenter = mLayout.getWidth() / 2;
return stackCenter < screenCenter;
}
/**
* Fling stack to given corner, within allowable screen bounds.
* Note that we need new SpringForce instances per animation despite identical configs because
* SpringAnimation uses SpringForce's internal (changing) velocity while the animation runs.
*/
public void springStack(
float destinationX, float destinationY, float stiffness) {
notifyFloatingCoordinatorStackAnimatingTo(destinationX, destinationY);
springFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_X,
new SpringForce()
.setStiffness(stiffness)
.setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO),
0 /* startXVelocity */,
destinationX);
springFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_Y,
new SpringForce()
.setStiffness(stiffness)
.setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO),
0 /* startYVelocity */,
destinationY);
}
/**
* Springs the stack to the specified x/y coordinates, with the stiffness used for springs after
* flings.
*/
public void springStackAfterFling(float destinationX, float destinationY) {
springStack(destinationX, destinationY, SPRING_AFTER_FLING_STIFFNESS);
}
/**
* Flings the stack starting with the given velocities, springing it to the nearest edge
* afterward.
*
* @return The X value that the stack will end up at after the fling/spring.
*/
public float flingStackThenSpringToEdge(float x, float velX, float velY) {
final boolean stackOnLeftSide = x - mBubbleBitmapSize / 2 < mLayout.getWidth() / 2;
final boolean stackShouldFlingLeft = stackOnLeftSide
? velX < ESCAPE_VELOCITY
: velX < -ESCAPE_VELOCITY;
final RectF stackBounds = getAllowableStackPositionRegion();
// Target X translation (either the left or right side of the screen).
final float destinationRelativeX = stackShouldFlingLeft
? stackBounds.left : stackBounds.right;
// If all bubbles were removed during a drag event, just return the X we would have animated
// to if there were still bubbles.
if (mLayout == null || mLayout.getChildCount() == 0) {
return destinationRelativeX;
}
final ContentResolver contentResolver = mLayout.getContext().getContentResolver();
final float stiffness = Settings.Secure.getFloat(contentResolver, "bubble_stiffness",
SPRING_AFTER_FLING_STIFFNESS /* default */);
final float dampingRatio = Settings.Secure.getFloat(contentResolver, "bubble_damping",
SPRING_AFTER_FLING_DAMPING_RATIO);
final float friction = Settings.Secure.getFloat(contentResolver, "bubble_friction",
FLING_FRICTION);
// Minimum velocity required for the stack to make it to the targeted side of the screen,
// taking friction into account (4.2f is the number that friction scalars are multiplied by
// in DynamicAnimation.DragForce). This is an estimate - it could possibly be slightly off,
// but the SpringAnimation at the end will ensure that it reaches the destination X
// regardless.
final float minimumVelocityToReachEdge =
(destinationRelativeX - x) * (friction * 4.2f);
final float estimatedY = PhysicsAnimator.estimateFlingEndValue(
mStackPosition.y, velY,
new PhysicsAnimator.FlingConfig(
friction, stackBounds.top, stackBounds.bottom));
notifyFloatingCoordinatorStackAnimatingTo(destinationRelativeX, estimatedY);
// Use the touch event's velocity if it's sufficient, otherwise use the minimum velocity so
// that it'll make it all the way to the side of the screen.
final float startXVelocity = stackShouldFlingLeft
? Math.min(minimumVelocityToReachEdge, velX)
: Math.max(minimumVelocityToReachEdge, velX);
flingThenSpringFirstBubbleWithStackFollowing(
DynamicAnimation.TRANSLATION_X,
startXVelocity,
friction,
new SpringForce()
.setStiffness(stiffness)
.setDampingRatio(dampingRatio),
destinationRelativeX);
flingThenSpringFirstBubbleWithStackFollowing(
DynamicAnimation.TRANSLATION_Y,
velY,
friction,
new SpringForce()
.setStiffness(stiffness)
.setDampingRatio(dampingRatio),
/* destination */ null);
// If we're flinging now, there's no more touch event to catch up to.
mFirstBubbleSpringingToTouch = false;
mIsMovingFromFlinging = true;
return destinationRelativeX;
}
/**
* Where the stack would be if it were snapped to the nearest horizontal edge (left or right).
*/
public PointF getStackPositionAlongNearestHorizontalEdge() {
final PointF stackPos = getStackPosition();
final boolean onLeft = mLayout.isFirstChildXLeftOfCenter(stackPos.x);
final RectF bounds = getAllowableStackPositionRegion();
stackPos.x = onLeft ? bounds.left : bounds.right;
return stackPos;
}
/**
* Moves the stack in response to rotation. We keep it in the most similar position by keeping
* it on the same side, and positioning it the same percentage of the way down the screen
* (taking status bar/nav bar into account by using the allowable region's height).
*/
public void moveStackToSimilarPositionAfterRotation(boolean wasOnLeft, float verticalPercent) {
final RectF allowablePos = getAllowableStackPositionRegion();
final float allowableRegionHeight = allowablePos.bottom - allowablePos.top;
final float x = wasOnLeft ? allowablePos.left : allowablePos.right;
final float y = (allowableRegionHeight * verticalPercent) + allowablePos.top;
setStackPosition(new PointF(x, y));
}
/** Description of current animation controller state. */
public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
pw.println("StackAnimationController state:");
pw.print(" isActive: "); pw.println(isActiveController());
pw.print(" restingStackPos: ");
pw.println(mRestingStackPosition != null ? mRestingStackPosition.toString() : "null");
pw.print(" currentStackPos: "); pw.println(mStackPosition.toString());
pw.print(" isMovingFromFlinging: "); pw.println(mIsMovingFromFlinging);
pw.print(" withinDismiss: "); pw.println(isStackStuckToTarget());
pw.print(" firstBubbleSpringing: "); pw.println(mFirstBubbleSpringingToTouch);
}
/**
* Flings the first bubble along the given property's axis, using the provided configuration
* values. When the animation ends - either by hitting the min/max, or by friction sufficiently
* reducing momentum - a SpringAnimation takes over to snap the bubble to the given final
* position.
*/
protected void flingThenSpringFirstBubbleWithStackFollowing(
DynamicAnimation.ViewProperty property,
float vel,
float friction,
SpringForce spring,
Float finalPosition) {
if (!isActiveController()) {
return;
}
Log.d(TAG, String.format("Flinging %s.",
PhysicsAnimationLayout.getReadablePropertyName(property)));
StackPositionProperty firstBubbleProperty = new StackPositionProperty(property);
final float currentValue = firstBubbleProperty.getValue(this);
final RectF bounds = getAllowableStackPositionRegion();
final float min =
property.equals(DynamicAnimation.TRANSLATION_X)
? bounds.left
: bounds.top;
final float max =
property.equals(DynamicAnimation.TRANSLATION_X)
? bounds.right
: bounds.bottom;
FlingAnimation flingAnimation = new FlingAnimation(this, firstBubbleProperty);
flingAnimation.setFriction(friction)
.setStartVelocity(vel)
// If the bubble's property value starts beyond the desired min/max, use that value
// instead so that the animation won't immediately end. If, for example, the user
// drags the bubbles into the navigation bar, but then flings them upward, we want
// the fling to occur despite temporarily having a value outside of the min/max. If
// the bubbles are out of bounds and flung even farther out of bounds, the fling
// animation will halt immediately and the SpringAnimation will take over, springing
// it in reverse to the (legal) final position.
.setMinValue(Math.min(currentValue, min))
.setMaxValue(Math.max(currentValue, max))
.addEndListener((animation, canceled, endValue, endVelocity) -> {
if (!canceled) {
mRestingStackPosition.set(mStackPosition);
springFirstBubbleWithStackFollowing(property, spring, endVelocity,
finalPosition != null
? finalPosition
: Math.max(min, Math.min(max, endValue)));
}
});
cancelStackPositionAnimation(property);
mStackPositionAnimations.put(property, flingAnimation);
flingAnimation.start();
}
/**
* Cancel any stack position animations that were started by calling
* @link #flingThenSpringFirstBubbleWithStackFollowing}, and remove any corresponding end
* listeners.
*/
public void cancelStackPositionAnimations() {
cancelStackPositionAnimation(DynamicAnimation.TRANSLATION_X);
cancelStackPositionAnimation(DynamicAnimation.TRANSLATION_Y);
removeEndActionForProperty(DynamicAnimation.TRANSLATION_X);
removeEndActionForProperty(DynamicAnimation.TRANSLATION_Y);
}
/** Save the current IME height so that we know where the stack bounds should be. */
public void setImeHeight(int imeHeight) {
mImeHeight = imeHeight;
}
/**
* Animates the stack either away from the newly visible IME, or back to its original position
* due to the IME going away.
*
* @return The destination Y value of the stack due to the IME movement (or the current position
* of the stack if it's not moving).
*/
public float animateForImeVisibility(boolean imeVisible) {
final float maxBubbleY = getAllowableStackPositionRegion().bottom;
float destinationY = UNSET;
if (imeVisible) {
// Stack is lower than it should be and overlaps the now-visible IME.
if (mStackPosition.y > maxBubbleY && mPreImeY == UNSET) {
mPreImeY = mStackPosition.y;
destinationY = maxBubbleY;
}
} else {
if (mPreImeY != UNSET) {
destinationY = mPreImeY;
mPreImeY = UNSET;
}
}
if (destinationY != UNSET) {
springFirstBubbleWithStackFollowing(
DynamicAnimation.TRANSLATION_Y,
getSpringForce(DynamicAnimation.TRANSLATION_Y, /* view */ null)
.setStiffness(IME_ANIMATION_STIFFNESS),
/* startVel */ 0f,
destinationY);
notifyFloatingCoordinatorStackAnimatingTo(mStackPosition.x, destinationY);
}
return destinationY != UNSET ? destinationY : mStackPosition.y;
}
/**
* Notifies the floating coordinator that we're moving, and sets {@link #mAnimatingToBounds} so
* we return these bounds from
* {@link FloatingContentCoordinator.FloatingContent#getFloatingBoundsOnScreen()}.
*/
private void notifyFloatingCoordinatorStackAnimatingTo(float x, float y) {
final Rect floatingBounds = mStackFloatingContent.getFloatingBoundsOnScreen();
floatingBounds.offsetTo((int) x, (int) y);
mAnimatingToBounds = floatingBounds;
mFloatingContentCoordinator.onContentMoved(mStackFloatingContent);
}
/**
* Returns the region that the stack position must stay within. This goes slightly off the left
* and right sides of the screen, below the status bar/cutout and above the navigation bar.
* While the stack position is not allowed to rest outside of these bounds, it can temporarily
* be animated or dragged beyond them.
*/
public RectF getAllowableStackPositionRegion() {
final WindowInsets insets = mLayout.getRootWindowInsets();
final RectF allowableRegion = new RectF();
if (insets != null) {
allowableRegion.left =
-mBubbleOffscreen
+ Math.max(
insets.getSystemWindowInsetLeft(),
insets.getDisplayCutout() != null
? insets.getDisplayCutout().getSafeInsetLeft()
: 0);
allowableRegion.right =
mLayout.getWidth()
- mBubbleSize
+ mBubbleOffscreen
- Math.max(
insets.getSystemWindowInsetRight(),
insets.getDisplayCutout() != null
? insets.getDisplayCutout().getSafeInsetRight()
: 0);
allowableRegion.top =
mBubblePaddingTop
+ Math.max(
mStatusBarHeight,
insets.getDisplayCutout() != null
? insets.getDisplayCutout().getSafeInsetTop()
: 0);
allowableRegion.bottom =
mLayout.getHeight()
- mBubbleSize
- mBubblePaddingTop
- (mImeHeight != UNSET ? mImeHeight + mBubblePaddingTop : 0f)
- Math.max(
insets.getStableInsetBottom(),
insets.getDisplayCutout() != null
? insets.getDisplayCutout().getSafeInsetBottom()
: 0);
}
return allowableRegion;
}
/** Moves the stack in response to a touch event. */
public void moveStackFromTouch(float x, float y) {
// Begin the spring-to-touch catch up animation if needed.
if (mSpringToTouchOnNextMotionEvent) {
springStack(x, y, DEFAULT_STIFFNESS);
mSpringToTouchOnNextMotionEvent = false;
mFirstBubbleSpringingToTouch = true;
} else if (mFirstBubbleSpringingToTouch) {
final SpringAnimation springToTouchX =
(SpringAnimation) mStackPositionAnimations.get(
DynamicAnimation.TRANSLATION_X);
final SpringAnimation springToTouchY =
(SpringAnimation) mStackPositionAnimations.get(
DynamicAnimation.TRANSLATION_Y);
// If either animation is still running, we haven't caught up. Update the animations.
if (springToTouchX.isRunning() || springToTouchY.isRunning()) {
springToTouchX.animateToFinalPosition(x);
springToTouchY.animateToFinalPosition(y);
} else {
// If the animations have finished, the stack is now at the touch point. We can
// resume moving the bubble directly.
mFirstBubbleSpringingToTouch = false;
}
}
if (!mFirstBubbleSpringingToTouch && !isStackStuckToTarget()) {
moveFirstBubbleWithStackFollowing(x, y);
}
}
/** Notify the controller that the stack has been unstuck from the dismiss target. */
public void onUnstuckFromTarget() {
mSpringToTouchOnNextMotionEvent = true;
}
/**
* 'Implode' the stack by shrinking the bubbles, fading them out, and translating them down.
*/
public void animateStackDismissal(float translationYBy, Runnable after) {
animationsForChildrenFromIndex(0, (index, animation) ->
animation
.scaleX(0f)
.scaleY(0f)
.alpha(0f)
.translationY(
mLayout.getChildAt(index).getTranslationY() + translationYBy)
.withStiffness(SpringForce.STIFFNESS_HIGH))
.startAll(after);
}
/**
* Springs the first bubble to the given final position, with the rest of the stack 'following'.
*/
protected void springFirstBubbleWithStackFollowing(
DynamicAnimation.ViewProperty property, SpringForce spring,
float vel, float finalPosition, @Nullable Runnable... after) {
if (mLayout.getChildCount() == 0 || !isActiveController()) {
return;
}
Log.d(TAG, String.format("Springing %s to final position %f.",
PhysicsAnimationLayout.getReadablePropertyName(property),
finalPosition));
// Whether we're springing towards the touch location, rather than to a position on the
// sides of the screen.
final boolean isSpringingTowardsTouch = mSpringToTouchOnNextMotionEvent;
StackPositionProperty firstBubbleProperty = new StackPositionProperty(property);
SpringAnimation springAnimation =
new SpringAnimation(this, firstBubbleProperty)
.setSpring(spring)
.addEndListener((dynamicAnimation, b, v, v1) -> {
if (!isSpringingTowardsTouch) {
// If we're springing towards the touch position, don't save the
// resting position - the touch location is not a valid resting
// position. We'll set this when the stack springs to the left or
// right side of the screen after the touch gesture ends.
mRestingStackPosition.set(mStackPosition);
}
if (after != null) {
for (Runnable callback : after) {
callback.run();
}
}
})
.setStartVelocity(vel);
cancelStackPositionAnimation(property);
mStackPositionAnimations.put(property, springAnimation);
springAnimation.animateToFinalPosition(finalPosition);
}
@Override
Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
return Sets.newHashSet(
DynamicAnimation.TRANSLATION_X, // For positioning.
DynamicAnimation.TRANSLATION_Y,
DynamicAnimation.ALPHA, // For fading in new bubbles.
DynamicAnimation.SCALE_X, // For 'popping in' new bubbles.
DynamicAnimation.SCALE_Y);
}
@Override
int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) {
if (property.equals(DynamicAnimation.TRANSLATION_X)
|| property.equals(DynamicAnimation.TRANSLATION_Y)) {
return index + 1;
} else {
return NONE;
}
}
@Override
float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) {
if (property.equals(DynamicAnimation.TRANSLATION_X)) {
// If we're in the dismiss target, have the bubbles pile on top of each other with no
// offset.
if (isStackStuckToTarget()) {
return 0f;
} else {
// Offset to the left if we're on the left, or the right otherwise.
return mLayout.isFirstChildXLeftOfCenter(mStackPosition.x)
? -mStackOffset : mStackOffset;
}
} else {
return 0f;
}
}
@Override
SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
final ContentResolver contentResolver = mLayout.getContext().getContentResolver();
final float stiffness = Settings.Secure.getFloat(contentResolver, "bubble_stiffness",
mIsMovingFromFlinging ? FLING_FOLLOW_STIFFNESS : DEFAULT_STIFFNESS /* default */);
final float dampingRatio = Settings.Secure.getFloat(contentResolver, "bubble_damping",
DEFAULT_BOUNCINESS);
return new SpringForce()
.setDampingRatio(dampingRatio)
.setStiffness(stiffness);
}
@Override
void onChildAdded(View child, int index) {
// Don't animate additions within the dismiss target.
if (isStackStuckToTarget()) {
return;
}
if (getBubbleCount() == 1) {
// If this is the first child added, position the stack in its starting position.
moveStackToStartPosition();
} else if (isStackPositionSet() && mLayout.indexOfChild(child) == 0) {
// Otherwise, animate the bubble in if it's the newest bubble. If we're adding a bubble
// to the back of the stack, it'll be largely invisible so don't bother animating it in.
animateInBubble(child, index);
}
}
@Override
void onChildRemoved(View child, int index, Runnable finishRemoval) {
PhysicsAnimator.getInstance(child)
.spring(DynamicAnimation.ALPHA, 0f)
.spring(DynamicAnimation.SCALE_X, 0f, mAnimateOutSpringConfig)
.spring(DynamicAnimation.SCALE_Y, 0f, mAnimateOutSpringConfig)
.withEndActions(finishRemoval, mOnBubbleAnimatedOutAction)
.start();
// If there are other bubbles, pull them into the correct position.
if (getBubbleCount() > 0) {
animationForChildAtIndex(0).translationX(mStackPosition.x).start();
} else {
// When all children are removed ensure stack position is sane
setStackPosition(mRestingStackPosition == null
? getDefaultStartPosition()
: mRestingStackPosition);
// Remove the stack from the coordinator since we don't have any bubbles and aren't
// visible.
mFloatingContentCoordinator.onContentRemoved(mStackFloatingContent);
}
}
@Override
void onChildReordered(View child, int oldIndex, int newIndex) {
if (isStackPositionSet()) {
setStackPosition(mStackPosition);
}
}
@Override
void onActiveControllerForLayout(PhysicsAnimationLayout layout) {
Resources res = layout.getResources();
mStackOffset = res.getDimensionPixelSize(R.dimen.bubble_stack_offset);
mBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
mBubbleBitmapSize = res.getDimensionPixelSize(R.dimen.bubble_bitmap_size);
mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top);
mBubbleOffscreen = res.getDimensionPixelSize(R.dimen.bubble_stack_offscreen);
mStackStartingVerticalOffset =
res.getDimensionPixelSize(R.dimen.bubble_stack_starting_offset_y);
mStatusBarHeight =
res.getDimensionPixelSize(com.android.internal.R.dimen.status_bar_height);
}
/**
* Update effective screen width based on current orientation.
* @param orientation Landscape or portrait.
*/
public void updateResources(int orientation) {
if (mLayout != null) {
Resources res = mLayout.getContext().getResources();
mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top);
mStatusBarHeight = res.getDimensionPixelSize(
com.android.internal.R.dimen.status_bar_height);
}
}
private boolean isStackStuckToTarget() {
return mMagnetizedStack != null && mMagnetizedStack.getObjectStuckToTarget();
}
/** Moves the stack, without any animation, to the starting position. */
private void moveStackToStartPosition() {
// Post to ensure that the layout's width and height have been calculated.
mLayout.setVisibility(View.INVISIBLE);
mLayout.post(() -> {
setStackPosition(mRestingStackPosition == null
? getDefaultStartPosition()
: mRestingStackPosition);
mStackMovedToStartPosition = true;
mLayout.setVisibility(View.VISIBLE);
// Animate in the top bubble now that we're visible.
if (mLayout.getChildCount() > 0) {
// Add the stack to the floating content coordinator now that we have a bubble and
// are visible.
mFloatingContentCoordinator.onContentAdded(mStackFloatingContent);
animateInBubble(mLayout.getChildAt(0), 0 /* index */);
}
});
}
/**
* Moves the first bubble instantly to the given X or Y translation, and instructs subsequent
* bubbles to animate 'following' to the new location.
*/
private void moveFirstBubbleWithStackFollowing(
DynamicAnimation.ViewProperty property, float value) {
// Update the canonical stack position.
if (property.equals(DynamicAnimation.TRANSLATION_X)) {
mStackPosition.x = value;
} else if (property.equals(DynamicAnimation.TRANSLATION_Y)) {
mStackPosition.y = value;
}
if (mLayout.getChildCount() > 0) {
property.setValue(mLayout.getChildAt(0), value);
if (mLayout.getChildCount() > 1) {
animationForChildAtIndex(1)
.property(property, value + getOffsetForChainedPropertyAnimation(property))
.start();
}
}
}
/** Moves the stack to a position instantly, with no animation. */
public void setStackPosition(PointF pos) {
Log.d(TAG, String.format("Setting position to (%f, %f).", pos.x, pos.y));
mStackPosition.set(pos.x, pos.y);
if (mRestingStackPosition == null) {
mRestingStackPosition = new PointF();
}
mRestingStackPosition.set(mStackPosition);
// If we're not the active controller, we don't want to physically move the bubble views.
if (isActiveController()) {
// Cancel animations that could be moving the views.
mLayout.cancelAllAnimationsOfProperties(
DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
cancelStackPositionAnimations();
// Since we're not using the chained animations, apply the offsets manually.
final float xOffset = getOffsetForChainedPropertyAnimation(
DynamicAnimation.TRANSLATION_X);
final float yOffset = getOffsetForChainedPropertyAnimation(
DynamicAnimation.TRANSLATION_Y);
for (int i = 0; i < mLayout.getChildCount(); i++) {
mLayout.getChildAt(i).setTranslationX(pos.x + (i * xOffset));
mLayout.getChildAt(i).setTranslationY(pos.y + (i * yOffset));
}
}
}
/** Returns the default stack position, which is on the top left. */
public PointF getDefaultStartPosition() {
boolean isRtl = mLayout != null
&& mLayout.getResources().getConfiguration().getLayoutDirection()
== View.LAYOUT_DIRECTION_RTL;
return new PointF(isRtl
? getAllowableStackPositionRegion().right
: getAllowableStackPositionRegion().left,
getAllowableStackPositionRegion().top + mStackStartingVerticalOffset);
}
private boolean isStackPositionSet() {
return mStackMovedToStartPosition;
}
/** Animates in the given bubble. */
private void animateInBubble(View child, int index) {
if (!isActiveController()) {
return;
}
final float xOffset =
getOffsetForChainedPropertyAnimation(DynamicAnimation.TRANSLATION_X);
// Position the new bubble in the correct position, scaled down completely.
child.setTranslationX(mStackPosition.x + xOffset * index);
child.setTranslationY(mStackPosition.y);
child.setScaleX(0f);
child.setScaleY(0f);
// Push the subsequent views out of the way, if there are subsequent views.
if (index + 1 < mLayout.getChildCount()) {
animationForChildAtIndex(index + 1)
.translationX(mStackPosition.x + xOffset * (index + 1))
.withStiffness(SpringForce.STIFFNESS_LOW)
.start();
}
// Scale in the new bubble, slightly delayed.
animationForChild(child)
.scaleX(1f)
.scaleY(1f)
.withStiffness(ANIMATE_IN_STIFFNESS)
.withStartDelay(mLayout.getChildCount() > 1 ? ANIMATE_IN_START_DELAY : 0)
.start();
}
/**
* Cancels any outstanding first bubble property animations that are running. This does not
* affect the SpringAnimations controlling the individual bubbles' 'following' effect - it only
* cancels animations started from {@link #springFirstBubbleWithStackFollowing} and
* {@link #flingThenSpringFirstBubbleWithStackFollowing}.
*/
private void cancelStackPositionAnimation(DynamicAnimation.ViewProperty property) {
if (mStackPositionAnimations.containsKey(property)) {
mStackPositionAnimations.get(property).cancel();
}
}
/**
* Returns the {@link MagnetizedObject} instance for the bubble stack, with the provided
* {@link MagnetizedObject.MagneticTarget} added as a target.
*/
public MagnetizedObject<StackAnimationController> getMagnetizedStack(
MagnetizedObject.MagneticTarget target) {
if (mMagnetizedStack == null) {
mMagnetizedStack = new MagnetizedObject<StackAnimationController>(
mLayout.getContext(),
this,
new StackPositionProperty(DynamicAnimation.TRANSLATION_X),
new StackPositionProperty(DynamicAnimation.TRANSLATION_Y)
) {
@Override
public float getWidth(@NonNull StackAnimationController underlyingObject) {
return mBubbleSize;
}
@Override
public float getHeight(@NonNull StackAnimationController underlyingObject) {
return mBubbleSize;
}
@Override
public void getLocationOnScreen(@NonNull StackAnimationController underlyingObject,
@NonNull int[] loc) {
loc[0] = (int) mStackPosition.x;
loc[1] = (int) mStackPosition.y;
}
};
mMagnetizedStack.addTarget(target);
mMagnetizedStack.setHapticsEnabled(true);
mMagnetizedStack.setFlingToTargetMinVelocity(FLING_TO_DISMISS_MIN_VELOCITY);
}
final ContentResolver contentResolver = mLayout.getContext().getContentResolver();
final float minVelocity = Settings.Secure.getFloat(contentResolver,
"bubble_dismiss_fling_min_velocity",
mMagnetizedStack.getFlingToTargetMinVelocity() /* default */);
final float maxVelocity = Settings.Secure.getFloat(contentResolver,
"bubble_dismiss_stick_max_velocity",
mMagnetizedStack.getStickToTargetMaxXVelocity() /* default */);
final float targetWidth = Settings.Secure.getFloat(contentResolver,
"bubble_dismiss_target_width_percent",
mMagnetizedStack.getFlingToTargetWidthPercent() /* default */);
mMagnetizedStack.setFlingToTargetMinVelocity(minVelocity);
mMagnetizedStack.setStickToTargetMaxXVelocity(maxVelocity);
mMagnetizedStack.setFlingToTargetWidthPercent(targetWidth);
return mMagnetizedStack;
}
/** Returns the number of 'real' bubbles (excluding overflow). */
private int getBubbleCount() {
return mBubbleCountSupplier.getAsInt();
}
/**
* FloatProperty that uses {@link #moveFirstBubbleWithStackFollowing} to set the first bubble's
* translation and animate the rest of the stack with it. A DynamicAnimation can animate this
* property directly to move the first bubble and cause the stack to 'follow' to the new
* location.
*
* This could also be achieved by simply animating the first bubble view and adding an update
* listener to dispatch movement to the rest of the stack. However, this would require
* duplication of logic in that update handler - it's simpler to keep all logic contained in the
* {@link #moveFirstBubbleWithStackFollowing} method.
*/
private class StackPositionProperty
extends FloatPropertyCompat<StackAnimationController> {
private final DynamicAnimation.ViewProperty mProperty;
private StackPositionProperty(DynamicAnimation.ViewProperty property) {
super(property.toString());
mProperty = property;
}
@Override
public float getValue(StackAnimationController controller) {
return mLayout.getChildCount() > 0 ? mProperty.getValue(mLayout.getChildAt(0)) : 0;
}
@Override
public void setValue(StackAnimationController controller, float value) {
moveFirstBubbleWithStackFollowing(mProperty, value);
}
}
}