• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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