1 /* 2 * Copyright (C) 2019 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.bubbles.animation; 18 19 import android.content.res.Configuration; 20 import android.content.res.Resources; 21 import android.graphics.Path; 22 import android.graphics.Point; 23 import android.graphics.PointF; 24 import android.view.DisplayCutout; 25 import android.view.View; 26 import android.view.WindowInsets; 27 28 import androidx.annotation.Nullable; 29 import androidx.dynamicanimation.animation.DynamicAnimation; 30 import androidx.dynamicanimation.animation.SpringForce; 31 32 import com.android.systemui.Interpolators; 33 import com.android.systemui.R; 34 35 import com.google.android.collect.Sets; 36 37 import java.io.FileDescriptor; 38 import java.io.PrintWriter; 39 import java.util.Set; 40 41 /** 42 * Animation controller for bubbles when they're in their expanded state, or animating to/from the 43 * expanded state. This controls the expansion animation as well as bubbles 'dragging out' to be 44 * dismissed. 45 */ 46 public class ExpandedAnimationController 47 extends PhysicsAnimationLayout.PhysicsAnimationController { 48 49 /** 50 * How much to translate the bubbles when they're animating in/out. This value is multiplied by 51 * the bubble size. 52 */ 53 private static final int ANIMATE_TRANSLATION_FACTOR = 4; 54 55 /** Duration of the expand/collapse target path animation. */ 56 private static final int EXPAND_COLLAPSE_TARGET_ANIM_DURATION = 175; 57 58 /** Stiffness for the expand/collapse path-following animation. */ 59 private static final int EXPAND_COLLAPSE_ANIM_STIFFNESS = 1000; 60 61 /** What percentage of the screen to use when centering the bubbles in landscape. */ 62 private static final float CENTER_BUBBLES_LANDSCAPE_PERCENT = 0.66f; 63 64 /** Horizontal offset between bubbles, which we need to know to re-stack them. */ 65 private float mStackOffsetPx; 66 /** Space between status bar and bubbles in the expanded state. */ 67 private float mBubblePaddingTop; 68 /** Size of each bubble. */ 69 private float mBubbleSizePx; 70 /** Height of the status bar. */ 71 private float mStatusBarHeight; 72 /** Size of display. */ 73 private Point mDisplaySize; 74 /** Max number of bubbles shown in row above expanded view.*/ 75 private int mBubblesMaxRendered; 76 /** What the current screen orientation is. */ 77 private int mScreenOrientation; 78 79 /** Whether the dragged-out bubble is in the dismiss target. */ 80 private boolean mIndividualBubbleWithinDismissTarget = false; 81 82 private boolean mAnimatingExpand = false; 83 private boolean mAnimatingCollapse = false; 84 private Runnable mAfterExpand; 85 private Runnable mAfterCollapse; 86 private PointF mCollapsePoint; 87 88 /** 89 * Whether the dragged out bubble is springing towards the touch point, rather than using the 90 * default behavior of moving directly to the touch point. 91 * 92 * This happens when the user's finger exits the dismiss area while the bubble is magnetized to 93 * the center. Since the touch point differs from the bubble location, we need to animate the 94 * bubble back to the touch point to avoid a jarring instant location change from the center of 95 * the target to the touch point just outside the target bounds. 96 */ 97 private boolean mSpringingBubbleToTouch = false; 98 99 private int mExpandedViewPadding; 100 private float mLauncherGridDiff; 101 ExpandedAnimationController(Point displaySize, int expandedViewPadding, int orientation)102 public ExpandedAnimationController(Point displaySize, int expandedViewPadding, 103 int orientation) { 104 updateOrientation(orientation, displaySize); 105 mExpandedViewPadding = expandedViewPadding; 106 mLauncherGridDiff = 30f; 107 } 108 109 /** 110 * Whether the individual bubble has been dragged out of the row of bubbles far enough to cause 111 * the rest of the bubbles to animate to fill the gap. 112 */ 113 private boolean mBubbleDraggedOutEnough = false; 114 115 /** The bubble currently being dragged out of the row (to potentially be dismissed). */ 116 private View mBubbleDraggingOut; 117 118 /** 119 * Animates expanding the bubbles into a row along the top of the screen. 120 */ expandFromStack(Runnable after)121 public void expandFromStack(Runnable after) { 122 mAnimatingCollapse = false; 123 mAnimatingExpand = true; 124 mAfterExpand = after; 125 126 startOrUpdatePathAnimation(true /* expanding */); 127 } 128 129 /** Animate collapsing the bubbles back to their stacked position. */ collapseBackToStack(PointF collapsePoint, Runnable after)130 public void collapseBackToStack(PointF collapsePoint, Runnable after) { 131 mAnimatingExpand = false; 132 mAnimatingCollapse = true; 133 mAfterCollapse = after; 134 mCollapsePoint = collapsePoint; 135 136 startOrUpdatePathAnimation(false /* expanding */); 137 } 138 139 /** 140 * Update effective screen width based on current orientation. 141 * @param orientation Landscape or portrait. 142 * @param displaySize Updated display size. 143 */ updateOrientation(int orientation, Point displaySize)144 public void updateOrientation(int orientation, Point displaySize) { 145 mScreenOrientation = orientation; 146 mDisplaySize = displaySize; 147 if (mLayout != null) { 148 Resources res = mLayout.getContext().getResources(); 149 mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top); 150 mStatusBarHeight = res.getDimensionPixelSize( 151 com.android.internal.R.dimen.status_bar_height); 152 } 153 } 154 155 /** 156 * Animates the bubbles along a curved path, either to expand them along the top or collapse 157 * them back into a stack. 158 */ startOrUpdatePathAnimation(boolean expanding)159 private void startOrUpdatePathAnimation(boolean expanding) { 160 Runnable after; 161 162 if (expanding) { 163 after = () -> { 164 mAnimatingExpand = false; 165 166 if (mAfterExpand != null) { 167 mAfterExpand.run(); 168 } 169 170 mAfterExpand = null; 171 }; 172 } else { 173 after = () -> { 174 mAnimatingCollapse = false; 175 176 if (mAfterCollapse != null) { 177 mAfterCollapse.run(); 178 } 179 180 mAfterCollapse = null; 181 }; 182 } 183 184 // Animate each bubble individually, since each path will end in a different spot. 185 animationsForChildrenFromIndex(0, (index, animation) -> { 186 final View bubble = mLayout.getChildAt(index); 187 188 // Start a path at the bubble's current position. 189 final Path path = new Path(); 190 path.moveTo(bubble.getTranslationX(), bubble.getTranslationY()); 191 192 final float expandedY = getExpandedY(); 193 if (expanding) { 194 // If we're expanding, first draw a line from the bubble's current position to the 195 // top of the screen. 196 path.lineTo(bubble.getTranslationX(), expandedY); 197 198 // Then, draw a line across the screen to the bubble's resting position. 199 path.lineTo(getBubbleLeft(index), expandedY); 200 } else { 201 final float sideMultiplier = 202 mLayout.isFirstChildXLeftOfCenter(mCollapsePoint.x) ? -1 : 1; 203 final float stackedX = mCollapsePoint.x + (sideMultiplier * index * mStackOffsetPx); 204 205 // If we're collapsing, draw a line from the bubble's current position to the side 206 // of the screen where the bubble will be stacked. 207 path.lineTo(stackedX, expandedY); 208 209 // Then, draw a line down to the stack position. 210 path.lineTo(stackedX, mCollapsePoint.y); 211 } 212 213 // The lead bubble should be the bubble with the longest distance to travel when we're 214 // expanding, and the bubble with the shortest distance to travel when we're collapsing. 215 // During expansion from the left side, the last bubble has to travel to the far right 216 // side, so we have it lead and 'pull' the rest of the bubbles into place. From the 217 // right side, the first bubble is traveling to the top left, so it leads. During 218 // collapse to the left, the first bubble has the shortest travel time back to the stack 219 // position, so it leads (and vice versa). 220 final boolean firstBubbleLeads = 221 (expanding && !mLayout.isFirstChildXLeftOfCenter(bubble.getTranslationX())) 222 || (!expanding && mLayout.isFirstChildXLeftOfCenter(mCollapsePoint.x)); 223 final int startDelay = firstBubbleLeads 224 ? (index * 10) 225 : ((mLayout.getChildCount() - index) * 10); 226 227 animation 228 .followAnimatedTargetAlongPath( 229 path, 230 EXPAND_COLLAPSE_TARGET_ANIM_DURATION /* targetAnimDuration */, 231 Interpolators.LINEAR /* targetAnimInterpolator */) 232 .withStartDelay(startDelay) 233 .withStiffness(EXPAND_COLLAPSE_ANIM_STIFFNESS); 234 }).startAll(after); 235 } 236 237 /** Prepares the given bubble to be dragged out. */ prepareForBubbleDrag(View bubble)238 public void prepareForBubbleDrag(View bubble) { 239 mLayout.cancelAnimationsOnView(bubble); 240 241 mBubbleDraggingOut = bubble; 242 mBubbleDraggingOut.setTranslationZ(Short.MAX_VALUE); 243 } 244 245 /** 246 * Drags an individual bubble to the given coordinates. Bubbles to the right will animate to 247 * take its place once it's dragged out of the row of bubbles, and animate out of the way if the 248 * bubble is dragged back into the row. 249 */ dragBubbleOut(View bubbleView, float x, float y)250 public void dragBubbleOut(View bubbleView, float x, float y) { 251 if (mSpringingBubbleToTouch) { 252 if (mLayout.arePropertiesAnimatingOnView( 253 bubbleView, DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y)) { 254 animationForChild(mBubbleDraggingOut) 255 .translationX(x) 256 .translationY(y) 257 .withStiffness(SpringForce.STIFFNESS_HIGH) 258 .start(); 259 } else { 260 mSpringingBubbleToTouch = false; 261 } 262 } 263 264 if (!mSpringingBubbleToTouch && !mIndividualBubbleWithinDismissTarget) { 265 bubbleView.setTranslationX(x); 266 bubbleView.setTranslationY(y); 267 } 268 269 final boolean draggedOutEnough = 270 y > getExpandedY() + mBubbleSizePx || y < getExpandedY() - mBubbleSizePx; 271 if (draggedOutEnough != mBubbleDraggedOutEnough) { 272 updateBubblePositions(); 273 mBubbleDraggedOutEnough = draggedOutEnough; 274 } 275 } 276 277 /** Plays a dismiss animation on the dragged out bubble. */ 278 public void dismissDraggedOutBubble(View bubble, Runnable after) { 279 mIndividualBubbleWithinDismissTarget = false; 280 281 animationForChild(bubble) 282 .withStiffness(SpringForce.STIFFNESS_HIGH) 283 .scaleX(1.1f) 284 .scaleY(1.1f) 285 .alpha(0f, after) 286 .start(); 287 288 updateBubblePositions(); 289 } 290 291 @Nullable public View getDraggedOutBubble() { 292 return mBubbleDraggingOut; 293 } 294 295 /** Magnets the given bubble to the dismiss target. */ 296 public void magnetBubbleToDismiss( 297 View bubbleView, float velX, float velY, float destY, Runnable after) { 298 mIndividualBubbleWithinDismissTarget = true; 299 mSpringingBubbleToTouch = false; 300 animationForChild(bubbleView) 301 .withStiffness(SpringForce.STIFFNESS_MEDIUM) 302 .withDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY) 303 .withPositionStartVelocities(velX, velY) 304 .translationX(mLayout.getWidth() / 2f - mBubbleSizePx / 2f) 305 .translationY(destY, after) 306 .start(); 307 } 308 309 /** 310 * Springs the dragged-out bubble towards the given coordinates and sets flags to have touch 311 * events update the spring's final position until it's settled. 312 */ 313 public void demagnetizeBubbleTo(float x, float y, float velX, float velY) { 314 mIndividualBubbleWithinDismissTarget = false; 315 mSpringingBubbleToTouch = true; 316 317 animationForChild(mBubbleDraggingOut) 318 .translationX(x) 319 .translationY(y) 320 .withPositionStartVelocities(velX, velY) 321 .withStiffness(SpringForce.STIFFNESS_HIGH) 322 .start(); 323 } 324 325 /** 326 * Snaps a bubble back to its position within the bubble row, and animates the rest of the 327 * bubbles to accommodate it if it was previously dragged out past the threshold. 328 */ 329 public void snapBubbleBack(View bubbleView, float velX, float velY) { 330 final int index = mLayout.indexOfChild(bubbleView); 331 332 animationForChildAtIndex(index) 333 .position(getBubbleLeft(index), getExpandedY()) 334 .withPositionStartVelocities(velX, velY) 335 .start(() -> bubbleView.setTranslationZ(0f) /* after */); 336 337 updateBubblePositions(); 338 } 339 340 /** Resets bubble drag out gesture flags. */ onGestureFinished()341 public void onGestureFinished() { 342 mBubbleDraggedOutEnough = false; 343 mBubbleDraggingOut = null; 344 updateBubblePositions(); 345 } 346 347 /** 348 * Animates the bubbles to {@link #getExpandedY()} position. Used in response to IME showing. 349 */ updateYPosition(Runnable after)350 public void updateYPosition(Runnable after) { 351 if (mLayout == null) return; 352 animationsForChildrenFromIndex( 353 0, (i, anim) -> anim.translationY(getExpandedY())).startAll(after); 354 } 355 356 /** The Y value of the row of expanded bubbles. */ getExpandedY()357 public float getExpandedY() { 358 if (mLayout == null || mLayout.getRootWindowInsets() == null) { 359 return 0; 360 } 361 final WindowInsets insets = mLayout.getRootWindowInsets(); 362 return mBubblePaddingTop + Math.max( 363 mStatusBarHeight, 364 insets.getDisplayCutout() != null 365 ? insets.getDisplayCutout().getSafeInsetTop() 366 : 0); 367 } 368 369 /** Description of current animation controller state. */ dump(FileDescriptor fd, PrintWriter pw, String[] args)370 public void dump(FileDescriptor fd, PrintWriter pw, String[] args) { 371 pw.println("ExpandedAnimationController state:"); 372 pw.print(" isActive: "); pw.println(isActiveController()); 373 pw.print(" animatingExpand: "); pw.println(mAnimatingExpand); 374 pw.print(" animatingCollapse: "); pw.println(mAnimatingCollapse); 375 pw.print(" bubbleInDismiss: "); pw.println(mIndividualBubbleWithinDismissTarget); 376 pw.print(" springingBubble: "); pw.println(mSpringingBubbleToTouch); 377 } 378 379 @Override onActiveControllerForLayout(PhysicsAnimationLayout layout)380 void onActiveControllerForLayout(PhysicsAnimationLayout layout) { 381 final Resources res = layout.getResources(); 382 mStackOffsetPx = res.getDimensionPixelSize(R.dimen.bubble_stack_offset); 383 mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top); 384 mBubbleSizePx = res.getDimensionPixelSize(R.dimen.individual_bubble_size); 385 mStatusBarHeight = 386 res.getDimensionPixelSize(com.android.internal.R.dimen.status_bar_height); 387 mBubblesMaxRendered = res.getInteger(R.integer.bubbles_max_rendered); 388 389 // Ensure that all child views are at 1x scale, and visible, in case they were animating 390 // in. 391 mLayout.setVisibility(View.VISIBLE); 392 animationsForChildrenFromIndex(0 /* startIndex */, (index, animation) -> 393 animation.scaleX(1f).scaleY(1f).alpha(1f)).startAll(); 394 } 395 396 @Override getAnimatedProperties()397 Set<DynamicAnimation.ViewProperty> getAnimatedProperties() { 398 return Sets.newHashSet( 399 DynamicAnimation.TRANSLATION_X, 400 DynamicAnimation.TRANSLATION_Y, 401 DynamicAnimation.SCALE_X, 402 DynamicAnimation.SCALE_Y, 403 DynamicAnimation.ALPHA); 404 } 405 406 @Override getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index)407 int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) { 408 return NONE; 409 } 410 411 @Override getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property)412 float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) { 413 return 0; 414 } 415 416 @Override getSpringForce(DynamicAnimation.ViewProperty property, View view)417 SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) { 418 return new SpringForce() 419 .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY) 420 .setStiffness(SpringForce.STIFFNESS_LOW); 421 } 422 423 @Override onChildAdded(View child, int index)424 void onChildAdded(View child, int index) { 425 // If a bubble is added while the expand/collapse animations are playing, update the 426 // animation to include the new bubble. 427 if (mAnimatingExpand) { 428 startOrUpdatePathAnimation(true /* expanding */); 429 } else if (mAnimatingCollapse) { 430 startOrUpdatePathAnimation(false /* expanding */); 431 } else { 432 child.setTranslationX(getBubbleLeft(index)); 433 animationForChild(child) 434 .translationY( 435 getExpandedY() - mBubbleSizePx * ANIMATE_TRANSLATION_FACTOR, /* from */ 436 getExpandedY() /* to */) 437 .start(); 438 updateBubblePositions(); 439 } 440 } 441 442 @Override onChildRemoved(View child, int index, Runnable finishRemoval)443 void onChildRemoved(View child, int index, Runnable finishRemoval) { 444 final PhysicsAnimationLayout.PhysicsPropertyAnimator animator = animationForChild(child); 445 446 // If we're removing the dragged-out bubble, that means it got dismissed. 447 if (child.equals(mBubbleDraggingOut)) { 448 mBubbleDraggingOut = null; 449 finishRemoval.run(); 450 } else { 451 animator.alpha(0f, finishRemoval /* endAction */) 452 .withStiffness(SpringForce.STIFFNESS_HIGH) 453 .withDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY) 454 .scaleX(1.1f) 455 .scaleY(1.1f) 456 .start(); 457 } 458 459 // Animate all the other bubbles to their new positions sans this bubble. 460 updateBubblePositions(); 461 } 462 463 @Override onChildReordered(View child, int oldIndex, int newIndex)464 void onChildReordered(View child, int oldIndex, int newIndex) { 465 updateBubblePositions(); 466 467 // We expect reordering during collapse, since we'll put the last selected bubble on top. 468 // Update the collapse animation so they end up in the right stacked positions. 469 if (mAnimatingCollapse) { 470 startOrUpdatePathAnimation(false /* expanding */); 471 } 472 } 473 updateBubblePositions()474 private void updateBubblePositions() { 475 if (mAnimatingExpand || mAnimatingCollapse) { 476 return; 477 } 478 479 for (int i = 0; i < mLayout.getChildCount(); i++) { 480 final View bubble = mLayout.getChildAt(i); 481 482 // Don't animate the dragging out bubble, or it'll jump around while being dragged. It 483 // will be snapped to the correct X value after the drag (if it's not dismissed). 484 if (bubble.equals(mBubbleDraggingOut)) { 485 return; 486 } 487 488 animationForChild(bubble) 489 .translationX(getBubbleLeft(i)) 490 .start(); 491 } 492 } 493 494 /** 495 * @param index Bubble index in row. 496 * @return Bubble left x from left edge of screen. 497 */ getBubbleLeft(int index)498 public float getBubbleLeft(int index) { 499 final float bubbleFromRowLeft = index * (mBubbleSizePx + getSpaceBetweenBubbles()); 500 return getRowLeft() + bubbleFromRowLeft; 501 } 502 503 /** 504 * When expanded, the bubbles are centered in the screen. In portrait, all available space is 505 * used. In landscape we have too much space so the value is restricted. This method accounts 506 * for window decorations (nav bar, cutouts). 507 * 508 * @return the desired width to display the expanded bubbles in. 509 */ getWidthForDisplayingBubbles()510 private float getWidthForDisplayingBubbles() { 511 final float availableWidth = getAvailableScreenWidth(true /* includeStableInsets */); 512 if (mScreenOrientation == Configuration.ORIENTATION_LANDSCAPE) { 513 // display size y in landscape will be the smaller dimension of the screen 514 return Math.max(mDisplaySize.y, availableWidth * CENTER_BUBBLES_LANDSCAPE_PERCENT); 515 } else { 516 return availableWidth; 517 } 518 } 519 520 /** 521 * Determines the available screen width without the cutout. 522 * 523 * @param subtractStableInsets Whether or not stable insets should also be removed from the 524 * returned width. 525 * @return the total screen width available accounting for cutouts and insets, 526 * iff {@param includeStableInsets} is true. 527 */ getAvailableScreenWidth(boolean subtractStableInsets)528 private float getAvailableScreenWidth(boolean subtractStableInsets) { 529 float availableSize = mDisplaySize.x; 530 WindowInsets insets = mLayout != null ? mLayout.getRootWindowInsets() : null; 531 if (insets != null) { 532 int cutoutLeft = 0; 533 int cutoutRight = 0; 534 DisplayCutout cutout = insets.getDisplayCutout(); 535 if (cutout != null) { 536 cutoutLeft = cutout.getSafeInsetLeft(); 537 cutoutRight = cutout.getSafeInsetRight(); 538 } 539 final int stableLeft = subtractStableInsets ? insets.getStableInsetLeft() : 0; 540 final int stableRight = subtractStableInsets ? insets.getStableInsetRight() : 0; 541 availableSize -= Math.max(stableLeft, cutoutLeft); 542 availableSize -= Math.max(stableRight, cutoutRight); 543 } 544 return availableSize; 545 } 546 getRowLeft()547 private float getRowLeft() { 548 if (mLayout == null) { 549 return 0; 550 } 551 552 int bubbleCount = mLayout.getChildCount(); 553 554 final float totalBubbleWidth = bubbleCount * mBubbleSizePx; 555 final float totalGapWidth = (bubbleCount - 1) * getSpaceBetweenBubbles(); 556 final float rowWidth = totalGapWidth + totalBubbleWidth; 557 558 // This display size we're using includes the size of the insets, we want the true 559 // center of the display minus the notch here, which means we should include the 560 // stable insets (e.g. status bar, nav bar) in this calculation. 561 final float trueCenter = getAvailableScreenWidth(false /* subtractStableInsets */) / 2f; 562 final float halfRow = rowWidth / 2f; 563 final float rowLeft = trueCenter - halfRow; 564 565 return rowLeft; 566 } 567 568 /** 569 * @return Space between bubbles in row above expanded view. 570 */ getSpaceBetweenBubbles()571 private float getSpaceBetweenBubbles() { 572 /** 573 * Ordered left to right: 574 * Screen edge 575 * [mExpandedViewPadding] 576 * Expanded view edge 577 * [launcherGridDiff] --- arbitrary value until launcher exports widths 578 * Launcher's app icon grid edge that we must match 579 */ 580 final float rowMargins = (mExpandedViewPadding + mLauncherGridDiff) * 2; 581 final float maxRowWidth = getWidthForDisplayingBubbles() - rowMargins; 582 583 final float totalBubbleWidth = mBubblesMaxRendered * mBubbleSizePx; 584 final float totalGapWidth = maxRowWidth - totalBubbleWidth; 585 586 final int gapCount = mBubblesMaxRendered - 1; 587 final float gapWidth = totalGapWidth / gapCount; 588 return gapWidth; 589 } 590 } 591