1 /* 2 * Copyright (C) 2023 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 package com.android.launcher3.taskbar.bubbles; 17 18 import android.animation.Animator; 19 import android.animation.ValueAnimator; 20 import android.annotation.Nullable; 21 import android.content.Context; 22 import android.graphics.Rect; 23 import android.util.AttributeSet; 24 import android.util.Log; 25 import android.view.MotionEvent; 26 import android.view.View; 27 import android.view.ViewGroup; 28 import android.widget.FrameLayout; 29 30 import com.android.launcher3.R; 31 import com.android.launcher3.taskbar.TaskbarActivityContext; 32 import com.android.launcher3.views.ActivityContext; 33 34 import java.util.List; 35 import java.util.function.Consumer; 36 37 /** 38 * The view that holds all the bubble views. Modifying this view should happen through 39 * {@link BubbleBarViewController}. Updates to the bubbles themselves (adds, removes, updates, 40 * selection) should happen through {@link BubbleBarController} which is the source of truth 41 * for state information about the bubbles. 42 * <p> 43 * The bubble bar has a couple of visual states: 44 * - stashed as a handle 45 * - unstashed but collapsed, in this state the bar is showing but the bubbles are stacked within it 46 * - unstashed and expanded, in this state the bar is showing and the bubbles are shown in a row 47 * with one of the bubbles being selected. Additionally, WMShell will display the expanded bubble 48 * view above the bar. 49 * <p> 50 * The bubble bar has some behavior related to taskbar: 51 * - When taskbar is unstashed, bubble bar will also become unstashed (but in its "collapsed" 52 * state) 53 * - When taskbar is stashed, bubble bar will also become stashed (unless bubble bar is in its 54 * "expanded" state) 55 * - When bubble bar is in its "expanded" state, taskbar becomes stashed 56 * <p> 57 * If there are no bubbles, the bubble bar and bubble stashed handle are not shown. Additionally 58 * the bubble bar and stashed handle are not shown on lockscreen. 59 * <p> 60 * When taskbar is in persistent or 3 button nav mode, the bubble bar is not available, and instead 61 * the bubbles are shown fully by WMShell in their floating mode. 62 */ 63 public class BubbleBarView extends FrameLayout { 64 65 private static final String TAG = BubbleBarView.class.getSimpleName(); 66 67 // TODO: (b/273594744) calculate the amount of space we have and base the max on that 68 // if it's smaller than 5. 69 private static final int MAX_BUBBLES = 5; 70 private static final int ARROW_POSITION_ANIMATION_DURATION_MS = 200; 71 private static final int WIDTH_ANIMATION_DURATION_MS = 200; 72 73 private final BubbleBarBackground mBubbleBarBackground; 74 75 /** 76 * The current bounds of all the bubble bar. Note that these bounds may not account for 77 * translation. The bounds should be retrieved using {@link #getBubbleBarBounds()} which 78 * updates the bounds and accounts for translation. 79 */ 80 private final Rect mBubbleBarBounds = new Rect(); 81 // The amount the bubbles overlap when they are stacked in the bubble bar 82 private final float mIconOverlapAmount; 83 // The spacing between the bubbles when they are expanded in the bubble bar 84 private final float mIconSpacing; 85 // The size of a bubble in the bar 86 private final float mIconSize; 87 // The elevation of the bubbles within the bar 88 private final float mBubbleElevation; 89 90 // Whether the bar is expanded (i.e. the bubble activity is being displayed). 91 private boolean mIsBarExpanded = false; 92 // The currently selected bubble view. 93 private BubbleView mSelectedBubbleView; 94 // The click listener when the bubble bar is collapsed. 95 private View.OnClickListener mOnClickListener; 96 97 private final Rect mTempRect = new Rect(); 98 private float mRelativePivotX = 1f; 99 private float mRelativePivotY = 1f; 100 101 // An animator that represents the expansion state of the bubble bar, where 0 corresponds to the 102 // collapsed state and 1 to the fully expanded state. 103 private final ValueAnimator mWidthAnimator = ValueAnimator.ofFloat(0, 1); 104 105 // We don't reorder the bubbles when they are expanded as it could be jarring for the user 106 // this runnable will be populated with any reordering of the bubbles that should be applied 107 // once they are collapsed. 108 @Nullable 109 private Runnable mReorderRunnable; 110 111 @Nullable 112 private Consumer<String> mUpdateSelectedBubbleAfterCollapse; 113 114 @Nullable 115 private BubbleView mDraggedBubbleView; 116 BubbleBarView(Context context)117 public BubbleBarView(Context context) { 118 this(context, null); 119 } 120 BubbleBarView(Context context, AttributeSet attrs)121 public BubbleBarView(Context context, AttributeSet attrs) { 122 this(context, attrs, 0); 123 } 124 BubbleBarView(Context context, AttributeSet attrs, int defStyleAttr)125 public BubbleBarView(Context context, AttributeSet attrs, int defStyleAttr) { 126 this(context, attrs, defStyleAttr, 0); 127 } 128 BubbleBarView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)129 public BubbleBarView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { 130 super(context, attrs, defStyleAttr, defStyleRes); 131 TaskbarActivityContext activityContext = ActivityContext.lookupContext(context); 132 133 mIconOverlapAmount = getResources().getDimensionPixelSize(R.dimen.bubblebar_icon_overlap); 134 mIconSpacing = getResources().getDimensionPixelSize(R.dimen.bubblebar_icon_spacing); 135 mIconSize = getResources().getDimensionPixelSize(R.dimen.bubblebar_icon_size); 136 mBubbleElevation = getResources().getDimensionPixelSize(R.dimen.bubblebar_icon_elevation); 137 setClipToPadding(false); 138 139 mBubbleBarBackground = new BubbleBarBackground(activityContext, 140 getResources().getDimensionPixelSize(R.dimen.bubblebar_size)); 141 setBackgroundDrawable(mBubbleBarBackground); 142 143 mWidthAnimator.setDuration(WIDTH_ANIMATION_DURATION_MS); 144 mWidthAnimator.addUpdateListener(animation -> { 145 updateChildrenRenderNodeProperties(); 146 invalidate(); 147 }); 148 mWidthAnimator.addListener(new Animator.AnimatorListener() { 149 @Override 150 public void onAnimationCancel(Animator animation) { 151 } 152 153 @Override 154 public void onAnimationEnd(Animator animation) { 155 mBubbleBarBackground.showArrow(mIsBarExpanded); 156 if (!mIsBarExpanded && mReorderRunnable != null) { 157 mReorderRunnable.run(); 158 mReorderRunnable = null; 159 } 160 // If the bar was just collapsed and the overflow was the last bubble that was 161 // selected, set the first bubble as selected. 162 if (!mIsBarExpanded && mUpdateSelectedBubbleAfterCollapse != null 163 && mSelectedBubbleView.getBubble() instanceof BubbleBarOverflow) { 164 BubbleView firstBubble = (BubbleView) getChildAt(0); 165 mUpdateSelectedBubbleAfterCollapse.accept(firstBubble.getBubble().getKey()); 166 } 167 updateWidth(); 168 } 169 170 @Override 171 public void onAnimationRepeat(Animator animation) { 172 } 173 174 @Override 175 public void onAnimationStart(Animator animation) { 176 mBubbleBarBackground.showArrow(true); 177 } 178 }); 179 } 180 181 @Override onLayout(boolean changed, int left, int top, int right, int bottom)182 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 183 super.onLayout(changed, left, top, right, bottom); 184 mBubbleBarBounds.left = left; 185 mBubbleBarBounds.top = top; 186 mBubbleBarBounds.right = right; 187 mBubbleBarBounds.bottom = bottom; 188 189 // The bubble bar handle is aligned according to the relative pivot, 190 // by default it's aligned to the bottom edge of the screen so scale towards that 191 setPivotX(mRelativePivotX * getWidth()); 192 setPivotY(mRelativePivotY * getHeight()); 193 194 // Position the views 195 updateChildrenRenderNodeProperties(); 196 } 197 198 /** 199 * Updates the bounds with translation that may have been applied and returns the result. 200 */ getBubbleBarBounds()201 public Rect getBubbleBarBounds() { 202 mBubbleBarBounds.top = getTop() + (int) getTranslationY(); 203 mBubbleBarBounds.bottom = getBottom() + (int) getTranslationY(); 204 return mBubbleBarBounds; 205 } 206 207 /** 208 * Set bubble bar relative pivot value for X and Y, applied as a fraction of view width/height 209 * respectively. If the value is not in range of 0 to 1 it will be normalized. 210 * @param x relative X pivot value in range 0..1 211 * @param y relative Y pivot value in range 0..1 212 */ setRelativePivot(float x, float y)213 public void setRelativePivot(float x, float y) { 214 mRelativePivotX = Float.max(Float.min(x, 1), 0); 215 mRelativePivotY = Float.max(Float.min(y, 1), 0); 216 requestLayout(); 217 } 218 219 /** 220 * Get current relative pivot for X axis 221 */ getRelativePivotX()222 public float getRelativePivotX() { 223 return mRelativePivotX; 224 } 225 226 /** 227 * Get current relative pivot for Y axis 228 */ getRelativePivotY()229 public float getRelativePivotY() { 230 return mRelativePivotY; 231 } 232 233 // TODO: (b/280605790) animate it 234 @Override addView(View child, int index, ViewGroup.LayoutParams params)235 public void addView(View child, int index, ViewGroup.LayoutParams params) { 236 if (getChildCount() + 1 > MAX_BUBBLES) { 237 // the last child view is the overflow bubble and we shouldn't remove that. remove the 238 // second to last child view. 239 removeViewInLayout(getChildAt(getChildCount() - 2)); 240 } 241 super.addView(child, index, params); 242 updateWidth(); 243 } 244 245 // TODO: (b/283309949) animate it 246 @Override removeView(View view)247 public void removeView(View view) { 248 super.removeView(view); 249 updateWidth(); 250 } 251 updateWidth()252 private void updateWidth() { 253 LayoutParams lp = (FrameLayout.LayoutParams) getLayoutParams(); 254 lp.width = (int) (mIsBarExpanded ? expandedWidth() : collapsedWidth()); 255 setLayoutParams(lp); 256 } 257 258 /** @return the horizontal margin between the bubble bar and the edge of the screen. */ getHorizontalMargin()259 int getHorizontalMargin() { 260 LayoutParams lp = (FrameLayout.LayoutParams) getLayoutParams(); 261 return lp.getMarginEnd(); 262 } 263 264 /** 265 * Updates the z order, positions, and badge visibility of the bubble views in the bar based 266 * on the expanded state. 267 */ updateChildrenRenderNodeProperties()268 private void updateChildrenRenderNodeProperties() { 269 final float widthState = (float) mWidthAnimator.getAnimatedValue(); 270 final float currentWidth = getWidth(); 271 final float expandedWidth = expandedWidth(); 272 final float collapsedWidth = collapsedWidth(); 273 int bubbleCount = getChildCount(); 274 final float ty = (mBubbleBarBounds.height() - mIconSize) / 2f; 275 final boolean animate = getVisibility() == VISIBLE; 276 for (int i = 0; i < bubbleCount; i++) { 277 BubbleView bv = (BubbleView) getChildAt(i); 278 bv.setTranslationY(ty); 279 280 // the position of the bubble when the bar is fully expanded 281 final float expandedX = i * (mIconSize + mIconSpacing); 282 // the position of the bubble when the bar is fully collapsed 283 final float collapsedX = i == 0 ? 0 : mIconOverlapAmount; 284 285 if (mIsBarExpanded) { 286 // where the bubble will end up when the animation ends 287 final float targetX = currentWidth - expandedWidth + expandedX; 288 bv.setTranslationX(widthState * (targetX - collapsedX) + collapsedX); 289 // if we're fully expanded, set the z level to 0 or to bubble elevation if dragged 290 if (widthState == 1f) { 291 bv.setZ(bv == mDraggedBubbleView ? mBubbleElevation : 0); 292 } 293 // When we're expanded, we're not stacked so we're not behind the stack 294 bv.setBehindStack(false, animate); 295 bv.setAlpha(1); 296 } else { 297 final float targetX = currentWidth - collapsedWidth + collapsedX; 298 bv.setTranslationX(widthState * (expandedX - targetX) + targetX); 299 bv.setZ((MAX_BUBBLES * mBubbleElevation) - i); 300 // If we're not the first bubble we're behind the stack 301 bv.setBehindStack(i > 0, animate); 302 // If we're fully collapsed, hide all bubbles except for the first 2. If there are 303 // only 2 bubbles, hide the second bubble as well because it's the overflow. 304 if (widthState == 0) { 305 if (i > 1) { 306 bv.setAlpha(0); 307 } else if (i == 1 && bubbleCount == 2) { 308 bv.setAlpha(0); 309 } 310 } 311 } 312 } 313 314 // update the arrow position 315 final float collapsedArrowPosition = arrowPositionForSelectedWhenCollapsed(); 316 final float expandedArrowPosition = arrowPositionForSelectedWhenExpanded(); 317 final float interpolatedWidth = 318 widthState * (expandedWidth - collapsedWidth) + collapsedWidth; 319 if (mIsBarExpanded) { 320 // when the bar is expanding, the selected bubble is always the first, so the arrow 321 // always shifts with the interpolated width. 322 final float arrowPosition = currentWidth - interpolatedWidth + collapsedArrowPosition; 323 mBubbleBarBackground.setArrowPosition(arrowPosition); 324 } else { 325 final float targetPosition = currentWidth - collapsedWidth + collapsedArrowPosition; 326 final float arrowPosition = 327 targetPosition + widthState * (expandedArrowPosition - targetPosition); 328 mBubbleBarBackground.setArrowPosition(arrowPosition); 329 } 330 331 mBubbleBarBackground.setArrowAlpha((int) (255 * widthState)); 332 mBubbleBarBackground.setWidth(interpolatedWidth); 333 } 334 335 /** 336 * Reorders the views to match the provided list. 337 */ reorder(List<BubbleView> viewOrder)338 public void reorder(List<BubbleView> viewOrder) { 339 if (isExpanded() || mWidthAnimator.isRunning()) { 340 mReorderRunnable = () -> doReorder(viewOrder); 341 } else { 342 doReorder(viewOrder); 343 } 344 } 345 346 // TODO: (b/273592694) animate it doReorder(List<BubbleView> viewOrder)347 private void doReorder(List<BubbleView> viewOrder) { 348 if (!isExpanded()) { 349 for (int i = 0; i < viewOrder.size(); i++) { 350 View child = viewOrder.get(i); 351 // this child view may have already been removed so verify that it still exists 352 // before reordering it, otherwise it will be re-added. 353 int indexOfChild = indexOfChild(child); 354 if (child != null && indexOfChild >= 0) { 355 removeViewInLayout(child); 356 addViewInLayout(child, i, child.getLayoutParams()); 357 } 358 } 359 updateChildrenRenderNodeProperties(); 360 } 361 } 362 setUpdateSelectedBubbleAfterCollapse( Consumer<String> updateSelectedBubbleAfterCollapse)363 public void setUpdateSelectedBubbleAfterCollapse( 364 Consumer<String> updateSelectedBubbleAfterCollapse) { 365 mUpdateSelectedBubbleAfterCollapse = updateSelectedBubbleAfterCollapse; 366 } 367 368 /** 369 * Sets which bubble view should be shown as selected. 370 */ setSelectedBubble(BubbleView view)371 public void setSelectedBubble(BubbleView view) { 372 mSelectedBubbleView = view; 373 updateArrowForSelected(/* shouldAnimate= */ true); 374 } 375 376 /** 377 * Sets the dragged bubble view to correctly apply Z order. Dragged view should appear on top 378 */ setDraggedBubble(@ullable BubbleView view)379 public void setDraggedBubble(@Nullable BubbleView view) { 380 mDraggedBubbleView = view; 381 requestLayout(); 382 } 383 384 /** 385 * Update the arrow position to match the selected bubble. 386 * 387 * @param shouldAnimate whether or not to animate the arrow. If the bar was just expanded, this 388 * should be set to {@code false}. Otherwise set this to {@code true}. 389 */ updateArrowForSelected(boolean shouldAnimate)390 private void updateArrowForSelected(boolean shouldAnimate) { 391 if (mSelectedBubbleView == null) { 392 Log.w(TAG, "trying to update selection arrow without a selected view!"); 393 return; 394 } 395 final int index = indexOfChild(mSelectedBubbleView); 396 // Find the center of the bubble when it's expanded, set the arrow position to it. 397 final float tx = getPaddingStart() + index * (mIconSize + mIconSpacing) + mIconSize / 2f; 398 399 if (shouldAnimate) { 400 final float currentArrowPosition = mBubbleBarBackground.getArrowPositionX(); 401 ValueAnimator animator = ValueAnimator.ofFloat(currentArrowPosition, tx); 402 animator.setDuration(ARROW_POSITION_ANIMATION_DURATION_MS); 403 animator.addUpdateListener(animation -> { 404 float x = (float) animation.getAnimatedValue(); 405 mBubbleBarBackground.setArrowPosition(x); 406 invalidate(); 407 }); 408 animator.start(); 409 } else { 410 mBubbleBarBackground.setArrowPosition(tx); 411 invalidate(); 412 } 413 } 414 arrowPositionForSelectedWhenExpanded()415 private float arrowPositionForSelectedWhenExpanded() { 416 final int index = indexOfChild(mSelectedBubbleView); 417 return getPaddingStart() + index * (mIconSize + mIconSpacing) + mIconSize / 2f; 418 } 419 arrowPositionForSelectedWhenCollapsed()420 private float arrowPositionForSelectedWhenCollapsed() { 421 final int index = indexOfChild(mSelectedBubbleView); 422 return getPaddingStart() + index * (mIconOverlapAmount) + mIconSize / 2f; 423 } 424 425 @Override setOnClickListener(View.OnClickListener listener)426 public void setOnClickListener(View.OnClickListener listener) { 427 mOnClickListener = listener; 428 setOrUnsetClickListener(); 429 } 430 431 /** 432 * The click listener used for the bubble view gets added / removed depending on whether 433 * the bar is expanded or collapsed, this updates whether the listener is set based on state. 434 */ setOrUnsetClickListener()435 private void setOrUnsetClickListener() { 436 super.setOnClickListener(mIsBarExpanded ? null : mOnClickListener); 437 } 438 439 /** 440 * Sets whether the bubble bar is expanded or collapsed. 441 */ setExpanded(boolean isBarExpanded)442 public void setExpanded(boolean isBarExpanded) { 443 if (mIsBarExpanded != isBarExpanded) { 444 mIsBarExpanded = isBarExpanded; 445 updateArrowForSelected(/* shouldAnimate= */ false); 446 setOrUnsetClickListener(); 447 if (isBarExpanded) { 448 mWidthAnimator.start(); 449 } else { 450 mWidthAnimator.reverse(); 451 } 452 } 453 } 454 455 /** 456 * Returns whether the bubble bar is expanded. 457 */ isExpanded()458 public boolean isExpanded() { 459 return mIsBarExpanded; 460 } 461 expandedWidth()462 private float expandedWidth() { 463 final int childCount = getChildCount(); 464 final int horizontalPadding = getPaddingStart() + getPaddingEnd(); 465 return childCount * (mIconSize + mIconSpacing) + horizontalPadding; 466 } 467 collapsedWidth()468 private float collapsedWidth() { 469 final int childCount = getChildCount(); 470 final int horizontalPadding = getPaddingStart() + getPaddingEnd(); 471 // If there are more than 2 bubbles, the first 2 should be visible when collapsed. 472 // Otherwise just the first bubble should be visible because we don't show the overflow. 473 return childCount > 2 474 ? mIconSize + mIconOverlapAmount + horizontalPadding 475 : mIconSize + horizontalPadding; 476 } 477 478 /** 479 * Returns whether the given MotionEvent, *in screen coordinates*, is within bubble bar 480 * touch bounds. 481 */ isEventOverAnyItem(MotionEvent ev)482 public boolean isEventOverAnyItem(MotionEvent ev) { 483 if (getVisibility() == View.VISIBLE) { 484 getBoundsOnScreen(mTempRect); 485 return mTempRect.contains((int) ev.getX(), (int) ev.getY()); 486 } 487 return false; 488 } 489 490 @Override onInterceptTouchEvent(MotionEvent ev)491 public boolean onInterceptTouchEvent(MotionEvent ev) { 492 if (!mIsBarExpanded) { 493 // When the bar is collapsed, all taps on it should expand it. 494 return true; 495 } 496 return super.onInterceptTouchEvent(ev); 497 } 498 } 499