/* * Copyright (C) 2023 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.launcher3.taskbar.bubbles; import static com.android.app.animation.Interpolators.EMPHASIZED_ACCELERATE; import static com.android.launcher3.LauncherAnimUtils.VIEW_ALPHA; import static com.android.launcher3.LauncherAnimUtils.VIEW_TRANSLATE_X; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.animation.ValueAnimator; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.PointF; import android.graphics.Rect; import android.os.Bundle; import android.util.AttributeSet; import android.util.FloatProperty; import android.util.LayoutDirection; import android.util.Log; import android.view.Gravity; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.accessibility.AccessibilityNodeInfo; import android.widget.FrameLayout; import androidx.dynamicanimation.animation.SpringForce; import com.android.launcher3.R; import com.android.launcher3.anim.SpringAnimationBuilder; import com.android.launcher3.taskbar.bubbles.animation.BubbleAnimator; import com.android.launcher3.util.DisplayController; import com.android.wm.shell.shared.bubbles.BubbleBarLocation; import java.io.PrintWriter; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; /** * The view that holds all the bubble views. Modifying this view should happen through * {@link BubbleBarViewController}. Updates to the bubbles themselves (adds, removes, updates, * selection) should happen through {@link BubbleBarController} which is the source of truth * for state information about the bubbles. *

* The bubble bar has a couple of visual states: * - stashed as a handle * - unstashed but collapsed, in this state the bar is showing but the bubbles are stacked within it * - unstashed and expanded, in this state the bar is showing and the bubbles are shown in a row * with one of the bubbles being selected. Additionally, WMShell will display the expanded bubble * view above the bar. *

* The bubble bar has some behavior related to taskbar: * - When taskbar is unstashed, bubble bar will also become unstashed (but in its "collapsed" * state) * - When taskbar is stashed, bubble bar will also become stashed (unless bubble bar is in its * "expanded" state) * - When bubble bar is in its "expanded" state, taskbar becomes stashed *

* If there are no bubbles, the bubble bar and bubble stashed handle are not shown. Additionally * the bubble bar and stashed handle are not shown on lockscreen. *

* When taskbar is in persistent or 3 button nav mode, the bubble bar is not available, and instead * the bubbles are shown fully by WMShell in their floating mode. */ public class BubbleBarView extends FrameLayout { private static final String TAG = "BubbleBarView"; // TODO: (b/273594744) calculate the amount of space we have and base the max on that // if it's smaller than 5. private static final int MAX_BUBBLES = 5; private static final int MAX_VISIBLE_BUBBLES_COLLAPSED = 2; private static final int ARROW_POSITION_ANIMATION_DURATION_MS = 200; private static final int WIDTH_ANIMATION_DURATION_MS = 200; private static final int SCALE_ANIMATION_DURATION_MS = 200; private static final long FADE_OUT_ANIM_ALPHA_DURATION_MS = 50L; private static final long FADE_OUT_ANIM_ALPHA_DELAY_MS = 50L; private static final long FADE_OUT_ANIM_POSITION_DURATION_MS = 100L; // During fade out animation we shift the bubble bar 1/80th of the screen width private static final float FADE_OUT_ANIM_POSITION_SHIFT = 1 / 80f; private static final long FADE_IN_ANIM_ALPHA_DURATION_MS = 100L; // Use STIFFNESS_MEDIUMLOW which is not defined in the API constants private static final float FADE_IN_ANIM_POSITION_SPRING_STIFFNESS = 400f; // During fade in animation we shift the bubble bar 1/60th of the screen width private static final float FADE_IN_ANIM_POSITION_SHIFT = 1 / 60f; /** * Custom property to set alpha value for the bar view while a bubble is being dragged. * Skips applying alpha to the dragged bubble. */ private static final FloatProperty BUBBLE_DRAG_ALPHA = new FloatProperty<>("bubbleDragAlpha") { @Override public void setValue(BubbleBarView bubbleBarView, float alpha) { bubbleBarView.setAlphaDuringBubbleDrag(alpha); } @Override public Float get(BubbleBarView bubbleBarView) { return bubbleBarView.mAlphaDuringDrag; } }; private final BubbleBarBackground mBubbleBarBackground; /** * The current bounds of all the bubble bar. Note that these bounds may not account for * translation. The bounds should be retrieved using {@link #getBubbleBarBounds()} which * updates the bounds and accounts for translation. */ private final Rect mBubbleBarBounds = new Rect(); // The amount the bubbles overlap when they are stacked in the bubble bar private final float mIconOverlapAmount; // The spacing between the bubbles when bubble bar is expanded private final float mExpandedBarIconsSpacing; // The spacing between the bubbles and the borders of the bubble bar private float mBubbleBarPadding; // The size of a bubble in the bar private float mIconSize; // The scale of bubble icons private float mIconScale = 1f; // The elevation of the bubbles within the bar private final float mBubbleElevation; private final float mDragElevation; private final int mPointerSize; // Whether the bar is expanded (i.e. the bubble activity is being displayed). private boolean mIsBarExpanded = false; // The currently selected bubble view. @Nullable private BubbleView mSelectedBubbleView; private BubbleBarLocation mBubbleBarLocation = BubbleBarLocation.DEFAULT; // The click listener when the bubble bar is collapsed. private View.OnClickListener mOnClickListener; private final Rect mTempRect = new Rect(); private float mRelativePivotX = 1f; private float mRelativePivotY = 1f; // An animator that represents the expansion state of the bubble bar, where 0 corresponds to the // collapsed state and 1 to the fully expanded state. private final ValueAnimator mWidthAnimator = ValueAnimator.ofFloat(0, 1); /** An animator used for animating individual bubbles in the bubble bar while expanded. */ @Nullable private BubbleAnimator mBubbleAnimator = null; @Nullable private ValueAnimator mScalePaddingAnimator; @Nullable private Animator mBubbleBarLocationAnimator = null; // We don't reorder the bubbles when they are expanded as it could be jarring for the user // this runnable will be populated with any reordering of the bubbles that should be applied // once they are collapsed. @Nullable private Runnable mReorderRunnable; @Nullable private Consumer mUpdateSelectedBubbleAfterCollapse; private boolean mDragging; @Nullable private BubbleView mDraggedBubbleView; @Nullable private BubbleView mDismissedByDragBubbleView; private float mAlphaDuringDrag = 1f; private Controller mController; private int mPreviousLayoutDirection = LayoutDirection.UNDEFINED; public BubbleBarView(Context context) { this(context, null); } public BubbleBarView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public BubbleBarView(Context context, AttributeSet attrs, int defStyleAttr) { this(context, attrs, defStyleAttr, 0); } public BubbleBarView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); setVisibility(INVISIBLE); mIconOverlapAmount = getResources().getDimensionPixelSize(R.dimen.bubblebar_icon_overlap); mBubbleBarPadding = getResources().getDimensionPixelSize(R.dimen.bubblebar_icon_spacing); mIconSize = getResources().getDimensionPixelSize(R.dimen.bubblebar_icon_size); mExpandedBarIconsSpacing = getResources().getDimensionPixelSize( R.dimen.bubblebar_expanded_icon_spacing); mBubbleElevation = getResources().getDimensionPixelSize(R.dimen.bubblebar_icon_elevation); mDragElevation = getResources().getDimensionPixelSize(R.dimen.bubblebar_drag_elevation); mPointerSize = getResources() .getDimensionPixelSize(R.dimen.bubblebar_pointer_visible_size); setClipToPadding(false); mBubbleBarBackground = new BubbleBarBackground(context, getBubbleBarExpandedHeight()); setBackgroundDrawable(mBubbleBarBackground); mWidthAnimator.setDuration(WIDTH_ANIMATION_DURATION_MS); addAnimationCallBacks(mWidthAnimator, /* onStart= */ () -> mBubbleBarBackground.showArrow(true), /* onEnd= */ () -> { mBubbleBarBackground.showArrow(mIsBarExpanded); if (!mIsBarExpanded && mReorderRunnable != null) { mReorderRunnable.run(); mReorderRunnable = null; } // If the bar was just collapsed and the overflow was the last bubble that was // selected, set the first bubble as selected. if (!mIsBarExpanded && mUpdateSelectedBubbleAfterCollapse != null && mSelectedBubbleView != null && mSelectedBubbleView.getBubble() instanceof BubbleBarOverflow) { BubbleView firstBubble = (BubbleView) getChildAt(0); mUpdateSelectedBubbleAfterCollapse.accept(firstBubble.getBubble().getKey()); } // If the bar was just expanded, remove the dot from the selected bubble. if (mIsBarExpanded && mSelectedBubbleView != null) { mSelectedBubbleView.markSeen(); } updateLayoutParams(); }, /* onUpdate= */ animator -> { updateBubblesLayoutProperties(mBubbleBarLocation); invalidate(); }); } /** * Animates icon sizes and spacing between icons and bubble bar borders. * * @param newIconSize new icon size * @param newBubbleBarPadding spacing between icons and bubble bar borders. */ public void animateBubbleBarIconSize(float newIconSize, float newBubbleBarPadding) { if (!isIconSizeOrPaddingUpdated(newIconSize, newBubbleBarPadding)) { return; } if (mScalePaddingAnimator != null && mScalePaddingAnimator.isRunning()) { mScalePaddingAnimator.cancel(); } ValueAnimator scalePaddingAnimator = ValueAnimator.ofFloat(0f, 1f); scalePaddingAnimator.setDuration(SCALE_ANIMATION_DURATION_MS); boolean isPaddingUpdated = isPaddingUpdated(newBubbleBarPadding); boolean isIconSizeUpdated = isIconSizeUpdated(newIconSize); float initialScale = mIconScale; float initialPadding = mBubbleBarPadding; float targetScale = newIconSize / getScaledIconSize(); addAnimationCallBacks(scalePaddingAnimator, /* onStart= */ null, /* onEnd= */ () -> setIconSizeAndPadding(newIconSize, newBubbleBarPadding), /* onUpdate= */ animator -> { float transitionProgress = (float) animator.getAnimatedValue(); if (isIconSizeUpdated) { mIconScale = initialScale + (targetScale - initialScale) * transitionProgress; } if (isPaddingUpdated) { mBubbleBarPadding = initialPadding + (newBubbleBarPadding - initialPadding) * transitionProgress; } updateBubblesLayoutProperties(mBubbleBarLocation); invalidate(); }); scalePaddingAnimator.start(); mScalePaddingAnimator = scalePaddingAnimator; } @Override public void setTranslationX(float translationX) { super.setTranslationX(translationX); if (mDraggedBubbleView != null) { // Apply reverse of the translation as an offset to the dragged view. This ensures // that the dragged bubble stays at the current location on the screen and its // position is not affected by the parent translation. mDraggedBubbleView.setOffsetX(-translationX); } } /** * Set scale for bubble bar background in x direction */ public void setBackgroundScaleX(float scaleX) { mBubbleBarBackground.setScaleX(scaleX); } /** * Set scale for bubble bar background in y direction */ public void setBackgroundScaleY(float scaleY) { mBubbleBarBackground.setScaleY(scaleY); } /** * Set alpha for bubble views */ public void setBubbleAlpha(float alpha) { for (int i = 0; i < getChildCount(); i++) { getChildAt(i).setAlpha(alpha); } } /** * Set alpha for bar background */ public void setBackgroundAlpha(float alpha) { mBubbleBarBackground.setAlpha((int) (255 * alpha)); } /** * Sets new icon sizes and newBubbleBarPadding between icons and bubble bar borders. * * @param newIconSize new icon size * @param newBubbleBarPadding newBubbleBarPadding between icons and bubble bar borders. */ public void setIconSizeAndPadding(float newIconSize, float newBubbleBarPadding) { // TODO(b/335457839): handle new bubble animation during the size change if (!isIconSizeOrPaddingUpdated(newIconSize, newBubbleBarPadding)) { return; } mIconScale = 1f; mBubbleBarPadding = newBubbleBarPadding; mIconSize = newIconSize; int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { View childView = getChildAt(i); childView.setScaleX(mIconScale); childView.setScaleY(mIconScale); FrameLayout.LayoutParams params = (LayoutParams) childView.getLayoutParams(); params.height = (int) mIconSize; params.width = (int) mIconSize; childView.setLayoutParams(params); } mBubbleBarBackground.setBackgroundHeight(getBubbleBarHeight()); updateLayoutParams(); } private float getScaledIconSize() { return mIconSize * mIconScale; } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); mBubbleBarBounds.left = left; mBubbleBarBounds.top = top + mPointerSize; mBubbleBarBounds.right = right; mBubbleBarBounds.bottom = bottom; // The bubble bar handle is aligned according to the relative pivot, // by default it's aligned to the bottom edge of the screen so scale towards that setPivotX(mRelativePivotX * getWidth()); setPivotY(mRelativePivotY * getHeight()); if (!mDragging) { // Position the views when not dragging updateBubblesLayoutProperties(mBubbleBarLocation); } } @Override public void onRtlPropertiesChanged(int layoutDirection) { if (mBubbleBarLocation == BubbleBarLocation.DEFAULT && mPreviousLayoutDirection != layoutDirection) { Log.d(TAG, "BubbleBar RTL properties changed, new layoutDirection=" + layoutDirection + " previous layoutDirection=" + mPreviousLayoutDirection); mPreviousLayoutDirection = layoutDirection; onBubbleBarLocationChanged(); } } @Override public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) { super.onInitializeAccessibilityNodeInfoInternal(info); // Always show only expand action as the menu is only for collapsed bubble bar info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND); info.addAction(new AccessibilityNodeInfo.AccessibilityAction(R.id.action_dismiss_all, getResources().getString(R.string.bubble_bar_action_dismiss_all))); if (mBubbleBarLocation.isOnLeft(isLayoutRtl())) { info.addAction(new AccessibilityNodeInfo.AccessibilityAction(R.id.action_move_right, getResources().getString(R.string.bubble_bar_action_move_right))); } else { info.addAction(new AccessibilityNodeInfo.AccessibilityAction(R.id.action_move_left, getResources().getString(R.string.bubble_bar_action_move_left))); } } @Override public boolean performAccessibilityActionInternal(int action, @androidx.annotation.Nullable Bundle arguments) { if (super.performAccessibilityActionInternal(action, arguments)) { return true; } if (action == AccessibilityNodeInfo.ACTION_EXPAND) { mController.expandBubbleBar(); return true; } if (action == R.id.action_dismiss_all) { mController.dismissBubbleBar(); return true; } if (action == R.id.action_move_left) { mController.updateBubbleBarLocation(BubbleBarLocation.LEFT); return true; } if (action == R.id.action_move_right) { mController.updateBubbleBarLocation(BubbleBarLocation.RIGHT); return true; } return false; } @SuppressLint("RtlHardcoded") private void onBubbleBarLocationChanged() { final boolean onLeft = mBubbleBarLocation.isOnLeft(isLayoutRtl()); mBubbleBarBackground.setAnchorLeft(onLeft); mRelativePivotX = onLeft ? 0f : 1f; LayoutParams lp = (LayoutParams) getLayoutParams(); lp.gravity = Gravity.BOTTOM | (onLeft ? Gravity.LEFT : Gravity.RIGHT); setLayoutParams(lp); // triggers a relayout updateBubbleAccessibilityStates(); } /** * @return current {@link BubbleBarLocation} */ public BubbleBarLocation getBubbleBarLocation() { return mBubbleBarLocation; } /** * Update {@link BubbleBarLocation} */ public void setBubbleBarLocation(BubbleBarLocation bubbleBarLocation) { resetDragAnimation(); if (bubbleBarLocation != mBubbleBarLocation) { mBubbleBarLocation = bubbleBarLocation; onBubbleBarLocationChanged(); } } /** * Set whether this view is currently being dragged */ public void setIsDragging(boolean dragging) { if (mDragging == dragging) { return; } mDragging = dragging; setElevation(dragging ? mDragElevation : mBubbleElevation); if (!mDragging) { // Relayout after dragging to ensure that the dragged bubble is positioned correctly requestLayout(); } } /** * Get translation for bubble bar when drag is released and it needs to animate back to the * resting position. * Resting position is based on the supplied location. If the supplied location is different * from the internal location that was used during bubble bar layout, translation values are * calculated to position the bar at the desired location. * * @param initialTranslation initial bubble bar translation at the start of drag * @param location desired location of the bubble bar when drag is released * @return point with x and y values representing translation on x and y-axis */ public PointF getBubbleBarDragReleaseTranslation(PointF initialTranslation, BubbleBarLocation location) { float dragEndTranslationX = initialTranslation.x; if (getBubbleBarLocation().isOnLeft(isLayoutRtl()) != location.isOnLeft(isLayoutRtl())) { // Bubble bar is laid out on left or right side of the screen. And the desired new // location is on the other side. Calculate x translation value required to shift // bubble bar from one side to the other. final float shift = getDistanceFromOtherSide(); if (location.isOnLeft(isLayoutRtl())) { // New location is on the left, shift left // before -> |......ooo.| after -> |.ooo......| dragEndTranslationX = -shift; } else { // New location is on the right, shift right // before -> |.ooo......| after -> |......ooo.| dragEndTranslationX = shift; } } return new PointF(dragEndTranslationX, mController.getBubbleBarTranslationY()); } /** * Get translation for a bubble when drag is released and it needs to animate back to the * resting position. * Resting position is based on the supplied location. If the supplied location is different * from the internal location that was used during bubble bar layout, translation values are * calculated to position the bar at the desired location. * * @param initialTranslation initial bubble translation inside the bar at the start of drag * @param location desired location of the bubble bar when drag is released * @return point with x and y values representing translation on x and y-axis */ public PointF getDraggedBubbleReleaseTranslation(PointF initialTranslation, BubbleBarLocation location) { float dragEndTranslationX = initialTranslation.x; boolean newLocationOnLeft = location.isOnLeft(isLayoutRtl()); if (getBubbleBarLocation().isOnLeft(isLayoutRtl()) != newLocationOnLeft) { // Calculate translationX based on bar and bubble translations float bubbleBarTx = getBubbleBarDragReleaseTranslation(initialTranslation, location).x; float bubbleTx = getExpandedBubbleTranslationX( indexOfChild(mDraggedBubbleView), getChildCount(), newLocationOnLeft); dragEndTranslationX = bubbleBarTx + bubbleTx; } // translationY does not change during drag and can be reused return new PointF(dragEndTranslationX, initialTranslation.y); } private float getDistanceFromOtherSide() { // Calculate the shift needed to position the bubble bar on the other side int displayWidth = getResources().getDisplayMetrics().widthPixels; int margin = 0; if (getLayoutParams() instanceof MarginLayoutParams lp) { margin += lp.leftMargin; margin += lp.rightMargin; } return (float) (displayWidth - getWidth() - margin); } /** * Animate bubble bar to the given location transiently. Does not modify the layout or the value * returned by {@link #getBubbleBarLocation()}. */ public void animateToBubbleBarLocation(BubbleBarLocation bubbleBarLocation) { if (mBubbleBarLocationAnimator != null && mBubbleBarLocationAnimator.isRunning()) { mBubbleBarLocationAnimator.removeAllListeners(); mBubbleBarLocationAnimator.cancel(); } // Location animation uses two separate animators. // First animator hides the bar. // After it completes, bubble positions in the bar and arrow position is updated. // Second animator is started to show the bar. mBubbleBarLocationAnimator = getLocationUpdateFadeOutAnimator(bubbleBarLocation); mBubbleBarLocationAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { updateBubblesLayoutProperties(bubbleBarLocation); mBubbleBarBackground.setAnchorLeft(bubbleBarLocation.isOnLeft(isLayoutRtl())); // Animate it in mBubbleBarLocationAnimator = getLocationUpdateFadeInAnimator(bubbleBarLocation); mBubbleBarLocationAnimator.start(); } }); mBubbleBarLocationAnimator.start(); } private Animator getLocationUpdateFadeOutAnimator(BubbleBarLocation newLocation) { final float shift = getResources().getDisplayMetrics().widthPixels * FADE_OUT_ANIM_POSITION_SHIFT; final boolean onLeft = newLocation.isOnLeft(isLayoutRtl()); final float tx = getTranslationX() + (onLeft ? -shift : shift); ObjectAnimator positionAnim = ObjectAnimator.ofFloat(this, VIEW_TRANSLATE_X, tx) .setDuration(FADE_OUT_ANIM_POSITION_DURATION_MS); positionAnim.setInterpolator(EMPHASIZED_ACCELERATE); ObjectAnimator alphaAnim = ObjectAnimator.ofFloat(this, getLocationAnimAlphaProperty(), 0f) .setDuration(FADE_OUT_ANIM_ALPHA_DURATION_MS); alphaAnim.setStartDelay(FADE_OUT_ANIM_ALPHA_DELAY_MS); AnimatorSet animatorSet = new AnimatorSet(); animatorSet.playTogether(positionAnim, alphaAnim); return animatorSet; } private Animator getLocationUpdateFadeInAnimator(BubbleBarLocation newLocation) { final float shift = getResources().getDisplayMetrics().widthPixels * FADE_IN_ANIM_POSITION_SHIFT; final boolean onLeft = newLocation.isOnLeft(isLayoutRtl()); final float startTx; final float finalTx; if (newLocation == mBubbleBarLocation) { // Animated location matches layout location. finalTx = 0; } else { // We are animating in to a transient location, need to move the bar accordingly. finalTx = getDistanceFromOtherSide() * (onLeft ? -1 : 1); } if (onLeft) { // Bar will be shown on the left side. Start point is shifted right. startTx = finalTx + shift; } else { // Bar will be shown on the right side. Start point is shifted left. startTx = finalTx - shift; } ValueAnimator positionAnim = new SpringAnimationBuilder(getContext()) .setStartValue(startTx) .setEndValue(finalTx) .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY) .setStiffness(FADE_IN_ANIM_POSITION_SPRING_STIFFNESS) .build(this, VIEW_TRANSLATE_X); ObjectAnimator alphaAnim = ObjectAnimator.ofFloat(this, getLocationAnimAlphaProperty(), 1f) .setDuration(FADE_IN_ANIM_ALPHA_DURATION_MS); AnimatorSet animatorSet = new AnimatorSet(); animatorSet.playTogether(positionAnim, alphaAnim); return animatorSet; } /** * Get property that can be used to animate the alpha value for the bar. * When a bubble is being dragged, uses {@link #BUBBLE_DRAG_ALPHA}. * Falls back to {@link com.android.launcher3.LauncherAnimUtils#VIEW_ALPHA} otherwise. */ private FloatProperty getLocationAnimAlphaProperty() { return mDraggedBubbleView == null ? VIEW_ALPHA : BUBBLE_DRAG_ALPHA; } /** * Set alpha value for the bar while a bubble is being dragged. * We can not update the alpha on the bar directly because the dragged bubble would be affected * as well. As it is a child view. * Instead, while a bubble is being dragged, set alpha on each child view, that is not the * dragged view. And set an alpha on the background. * This allows for the dragged bubble to remain visible while the bar is hidden during * animation. */ private void setAlphaDuringBubbleDrag(float alpha) { mAlphaDuringDrag = alpha; final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { View view = getChildAt(i); if (view != mDraggedBubbleView) { view.setAlpha(alpha); } } if (mBubbleBarBackground != null) { mBubbleBarBackground.setAlpha((int) (255 * alpha)); } } private void resetDragAnimation() { if (mBubbleBarLocationAnimator != null) { mBubbleBarLocationAnimator.removeAllListeners(); mBubbleBarLocationAnimator.cancel(); mBubbleBarLocationAnimator = null; } setAlphaDuringBubbleDrag(1f); setTranslationX(0f); if (getBubbleChildCount() > 0) { setAlpha(1f); } } /** * Get bubble bar top coordinate on screen when bar is resting */ public int getRestingTopPositionOnScreen() { int displayHeight = DisplayController.INSTANCE.get(getContext()).getInfo().currentSize.y; int bubbleBarHeight = getBubbleBarBounds().height(); return displayHeight - bubbleBarHeight + (int) mController.getBubbleBarTranslationY(); } /** Returns the bounds with translation that may have been applied. */ public Rect getBubbleBarBounds() { Rect bounds = new Rect(mBubbleBarBounds); bounds.top = getTop() + (int) getTranslationY() + mPointerSize; bounds.bottom = getBottom() + (int) getTranslationY(); return bounds; } /** Returns the expanded bounds with translation that may have been applied. */ public Rect getBubbleBarExpandedBounds() { Rect expandedBounds = getBubbleBarBounds(); if (!isExpanded() || isExpanding()) { if (mBubbleBarLocation.isOnLeft(isLayoutRtl())) { expandedBounds.right = expandedBounds.left + (int) expandedWidth(); } else { expandedBounds.left = expandedBounds.right - (int) expandedWidth(); } } return expandedBounds; } /** * Set bubble bar relative pivot value for X and Y, applied as a fraction of view width/height * respectively. If the value is not in range of 0 to 1 it will be normalized. * * @param x relative X pivot value in range 0..1 * @param y relative Y pivot value in range 0..1 */ public void setRelativePivot(float x, float y) { mRelativePivotX = Float.max(Float.min(x, 1), 0); mRelativePivotY = Float.max(Float.min(y, 1), 0); requestLayout(); } /** Like {@link #setRelativePivot(float, float)} but only updates pivot y. */ public void setRelativePivotY(float y) { setRelativePivot(mRelativePivotX, y); } /** * Get current relative pivot for X axis */ public float getRelativePivotX() { return mRelativePivotX; } /** * Get current relative pivot for Y axis */ public float getRelativePivotY() { return mRelativePivotY; } /** Add a new bubble to the bubble bar. */ public void addBubble(BubbleView bubble) { FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams((int) mIconSize, (int) mIconSize, Gravity.LEFT); final int index = bubble.isOverflow() ? getChildCount() : 0; if (isExpanded()) { // if we're expanded scale the new bubble in bubble.setScaleX(0f); bubble.setScaleY(0f); addView(bubble, index, lp); bubble.showDotIfNeeded(/* animate= */ false); mBubbleAnimator = new BubbleAnimator(mIconSize, mExpandedBarIconsSpacing, getChildCount(), mBubbleBarLocation.isOnLeft(isLayoutRtl())); BubbleAnimator.Listener listener = new BubbleAnimator.Listener() { @Override public void onAnimationEnd() { updateLayoutParams(); mBubbleAnimator = null; } @Override public void onAnimationCancel() { bubble.setScaleX(1); bubble.setScaleY(1); } @Override public void onAnimationUpdate(float animatedFraction) { bubble.setScaleX(animatedFraction); bubble.setScaleY(animatedFraction); updateBubblesLayoutProperties(mBubbleBarLocation); invalidate(); } }; mBubbleAnimator.animateNewBubble(indexOfChild(mSelectedBubbleView), listener); } else { addView(bubble, index, lp); } } /** Add a new bubble and remove an old bubble from the bubble bar. */ public void addBubbleAndRemoveBubble(BubbleView addedBubble, BubbleView removedBubble, Runnable onEndRunnable) { FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams((int) mIconSize, (int) mIconSize, Gravity.LEFT); boolean isOverflowSelected = mSelectedBubbleView.isOverflow(); boolean removingOverflow = removedBubble.isOverflow(); boolean addingOverflow = addedBubble.isOverflow(); if (!isExpanded()) { removeView(removedBubble); int index = addingOverflow ? getChildCount() : 0; addView(addedBubble, index, lp); if (onEndRunnable != null) { onEndRunnable.run(); } return; } int index = addingOverflow ? getChildCount() : 0; addedBubble.setScaleX(0f); addedBubble.setScaleY(0f); addView(addedBubble, index, lp); if (isOverflowSelected && removingOverflow) { // The added bubble will be selected mSelectedBubbleView = addedBubble; } int indexOfSelectedBubble = indexOfChild(mSelectedBubbleView); int indexOfBubbleToRemove = indexOfChild(removedBubble); mBubbleAnimator = new BubbleAnimator(mIconSize, mExpandedBarIconsSpacing, getChildCount(), mBubbleBarLocation.isOnLeft(isLayoutRtl())); BubbleAnimator.Listener listener = new BubbleAnimator.Listener() { @Override public void onAnimationEnd() { removeView(removedBubble); updateLayoutParams(); mBubbleAnimator = null; if (onEndRunnable != null) { onEndRunnable.run(); } } @Override public void onAnimationCancel() { addedBubble.setScaleX(1); addedBubble.setScaleY(1); removedBubble.setScaleX(0); removedBubble.setScaleY(0); } @Override public void onAnimationUpdate(float animatedFraction) { addedBubble.setScaleX(animatedFraction); addedBubble.setScaleY(animatedFraction); removedBubble.setScaleX(1 - animatedFraction); removedBubble.setScaleY(1 - animatedFraction); updateBubblesLayoutProperties(mBubbleBarLocation); invalidate(); } }; mBubbleAnimator.animateNewAndRemoveOld(indexOfSelectedBubble, indexOfBubbleToRemove, listener); } @Override public void addView(View child, int index, ViewGroup.LayoutParams params) { super.addView(child, index, params); updateLayoutParams(); updateBubbleAccessibilityStates(); updateContentDescription(); } /** Removes the given bubble from the bubble bar. */ public void removeBubble(View bubble) { if (isExpanded()) { // TODO b/347062801 - animate the bubble bar if the last bubble is removed final boolean dismissedByDrag = mDraggedBubbleView == bubble; if (dismissedByDrag) { mDismissedByDragBubbleView = mDraggedBubbleView; } int bubbleCount = getChildCount(); mBubbleAnimator = new BubbleAnimator(mIconSize, mExpandedBarIconsSpacing, bubbleCount, mBubbleBarLocation.isOnLeft(isLayoutRtl())); BubbleAnimator.Listener listener = new BubbleAnimator.Listener() { @Override public void onAnimationEnd() { removeView(bubble); mBubbleAnimator = null; } @Override public void onAnimationCancel() { bubble.setScaleX(0); bubble.setScaleY(0); } @Override public void onAnimationUpdate(float animatedFraction) { // don't update the scale if this bubble was dismissed by drag if (!dismissedByDrag) { bubble.setScaleX(1 - animatedFraction); bubble.setScaleY(1 - animatedFraction); } updateBubblesLayoutProperties(mBubbleBarLocation); invalidate(); } }; int bubbleIndex = indexOfChild(bubble); BubbleView lastBubble = (BubbleView) getChildAt(bubbleCount - 1); String lastBubbleKey = lastBubble.getBubble().getKey(); boolean removingLastBubble = BubbleBarOverflow.KEY.equals(lastBubbleKey) ? bubbleIndex == bubbleCount - 2 : bubbleIndex == bubbleCount - 1; mBubbleAnimator.animateRemovedBubble( indexOfChild(bubble), indexOfChild(mSelectedBubbleView), removingLastBubble, listener); } else { removeView(bubble); } } // TODO: (b/283309949) animate it @Override public void removeView(View view) { super.removeView(view); if (view == mSelectedBubbleView) { mSelectedBubbleView = null; mBubbleBarBackground.showArrow(false); } updateLayoutParams(); updateBubbleAccessibilityStates(); updateContentDescription(); mDismissedByDragBubbleView = null; updateNotificationDotsIfCollapsed(); } /** * Return child views in the order which they are shown on the screen. *

* Child views (bubbles) are always ordered based on recency. The most recent bubble is at index * 0. * For example if the child views are (1)(2)(3) then (1) is the most recent bubble and at index * 0.
* * How bubbles show up on the screen depends on the bubble bar location. If the bar is on the * left, the most recent bubble is shown on the right. The bubbles from the example above would * be shown as: (3)(2)(1).
* * If bubble bar is on the right, then the most recent bubble is on the left. Bubbles from the * example above would be shown as: (1)(2)(3). */ private List getChildViewsInOnScreenOrder() { List childViews = new ArrayList<>(getChildCount()); for (int i = 0; i < getChildCount(); i++) { childViews.add(getChildAt(i)); } if (mBubbleBarLocation.isOnLeft(isLayoutRtl())) { // Visually child views are shown in reverse order when bar is on the left return childViews.reversed(); } return childViews; } private void updateNotificationDotsIfCollapsed() { if (isExpanded()) { return; } for (int i = 0; i < getChildCount(); i++) { BubbleView bubbleView = (BubbleView) getChildAt(i); // when we're collapsed, the first bubble should show the dot if it has it. the rest of // the bubbles should hide their dots. if (i == 0 && bubbleView.hasUnseenContent()) { bubbleView.showDotIfNeeded(/* animate= */ true); } else { bubbleView.hideDot(); } } } private void updateLayoutParams() { LayoutParams lp = (FrameLayout.LayoutParams) getLayoutParams(); lp.height = (int) getBubbleBarExpandedHeight(); lp.width = (int) (mIsBarExpanded ? expandedWidth() : collapsedWidth()); setLayoutParams(lp); } private float getBubbleBarHeight() { return mIsBarExpanded ? getBubbleBarExpandedHeight() : getBubbleBarCollapsedHeight(); } /** @return the horizontal margin between the bubble bar and the edge of the screen. */ int getHorizontalMargin() { LayoutParams lp = (FrameLayout.LayoutParams) getLayoutParams(); return lp.getMarginEnd(); } /** * Updates the z order, positions, and badge visibility of the bubble views in the bar based * on the expanded state. */ private void updateBubblesLayoutProperties(BubbleBarLocation bubbleBarLocation) { final float widthState = (float) mWidthAnimator.getAnimatedValue(); final float currentWidth = getWidth(); final float expandedWidth = expandedWidth(); final float collapsedWidth = collapsedWidth(); int childCount = getChildCount(); float viewBottom = mBubbleBarBounds.height() + (isExpanded() ? mPointerSize : 0); float bubbleBarAnimatedTop = viewBottom - getBubbleBarHeight(); // When translating X & Y the scale is ignored, so need to deduct it from the translations final float ty = bubbleBarAnimatedTop + mBubbleBarPadding - getScaleIconShift(); final boolean onLeft = bubbleBarLocation.isOnLeft(isLayoutRtl()); // elevation state is opposite to widthState - when expanded all icons are flat float elevationState = (1 - widthState); for (int i = 0; i < childCount; i++) { BubbleView bv = (BubbleView) getChildAt(i); if (bv == mDraggedBubbleView || bv == mDismissedByDragBubbleView) { // Skip the dragged bubble. Its translation is managed by the drag controller. continue; } // Clear out drag translation and offset bv.setDragTranslationX(0f); bv.setOffsetX(0f); if (mBubbleAnimator == null || !mBubbleAnimator.isRunning()) { // if the bubble animator is running don't set scale here, it will be set by the // animator bv.setScaleX(mIconScale); bv.setScaleY(mIconScale); } bv.setTranslationY(ty); // the position of the bubble when the bar is fully expanded final float expandedX = getExpandedBubbleTranslationX(i, childCount, onLeft); // the position of the bubble when the bar is fully collapsed final float collapsedX = getCollapsedBubbleTranslationX(i, childCount, onLeft); // slowly animate elevation while keeping correct Z ordering float fullElevationForChild = (MAX_BUBBLES * mBubbleElevation) - i; bv.setZ(fullElevationForChild * elevationState); // only update the dot and badge scale if we're expanding or collapsing if (mWidthAnimator.isRunning()) { // The dot for the selected bubble scales in the opposite direction of the expansion // animation. bv.showDotIfNeeded(bv == mSelectedBubbleView ? 1 - widthState : widthState); // The badge for the selected bubble is always at full scale. All other bubbles // scale according to the expand animation. bv.setBadgeScale(bv == mSelectedBubbleView ? 1 : widthState); } if (mIsBarExpanded) { // If bar is on the right, account for bubble bar expanding and shifting left final float expandedBarShift = onLeft ? 0 : currentWidth - expandedWidth; // where the bubble will end up when the animation ends final float targetX = expandedX + expandedBarShift; bv.setTranslationX(widthState * (targetX - collapsedX) + collapsedX); bv.setVisibility(VISIBLE); } else { // If bar is on the right, account for bubble bar expanding and shifting left final float collapsedBarShift = onLeft ? 0 : currentWidth - collapsedWidth; final float targetX = collapsedX + collapsedBarShift; bv.setTranslationX(widthState * (expandedX - targetX) + targetX); // If we're fully collapsed, hide all bubbles except for the first 2, excluding // the overflow. if (widthState == 0) { if (bv.isOverflow() || i > MAX_VISIBLE_BUBBLES_COLLAPSED - 1) { bv.setVisibility(INVISIBLE); } else { bv.setVisibility(VISIBLE); } } } } // update the arrow position final float collapsedArrowPosition = arrowPositionForSelectedWhenCollapsed( bubbleBarLocation); final float expandedArrowPosition = arrowPositionForSelectedWhenExpanded(bubbleBarLocation); final float interpolatedWidth = widthState * (expandedWidth - collapsedWidth) + collapsedWidth; final float arrowPosition; float interpolatedShift = (expandedArrowPosition - collapsedArrowPosition) * widthState; if (onLeft) { arrowPosition = collapsedArrowPosition + interpolatedShift; } else { if (mIsBarExpanded) { arrowPosition = currentWidth - interpolatedWidth + collapsedArrowPosition + interpolatedShift; } else { final float targetPosition = currentWidth - collapsedWidth + collapsedArrowPosition; arrowPosition = targetPosition + widthState * (expandedArrowPosition - targetPosition); } } mBubbleBarBackground.setArrowPosition(arrowPosition); mBubbleBarBackground.setArrowHeightFraction(widthState); mBubbleBarBackground.setWidth(interpolatedWidth); mBubbleBarBackground.setBackgroundHeight(getBubbleBarExpandedHeight()); } private float getScaleIconShift() { return (mIconSize - getScaledIconSize()) / 2; } private float getExpandedBubbleTranslationX(int bubbleIndex, int bubbleCount, boolean onLeft) { if (bubbleIndex < 0 || bubbleIndex >= bubbleCount) { return 0; } final float iconAndSpacing = getScaledIconSize() + mExpandedBarIconsSpacing; float translationX; if (mBubbleAnimator != null && mBubbleAnimator.isRunning()) { return mBubbleAnimator.getBubbleTranslationX(bubbleIndex) + mBubbleBarPadding; } else if (onLeft) { translationX = mBubbleBarPadding + (bubbleCount - bubbleIndex - 1) * iconAndSpacing; } else { translationX = mBubbleBarPadding + bubbleIndex * iconAndSpacing; } return translationX - getScaleIconShift(); } private float getCollapsedBubbleTranslationX(int bubbleIndex, int childCount, boolean onLeft) { if (bubbleIndex < 0 || bubbleIndex >= childCount) { return 0; } float translationX; if (onLeft) { // Shift the first bubble only if there are more bubbles if (bubbleIndex == 0 && getBubbleChildCount() >= MAX_VISIBLE_BUBBLES_COLLAPSED) { translationX = mIconOverlapAmount; } else { translationX = 0f; } } else { if (bubbleIndex == 1 && getBubbleChildCount() >= MAX_VISIBLE_BUBBLES_COLLAPSED) { translationX = mIconOverlapAmount; } else { translationX = 0f; } } return mBubbleBarPadding + translationX - getScaleIconShift(); } /** * Reorders the views to match the provided list. */ public void reorder(List viewOrder) { if (isExpanded() || mWidthAnimator.isRunning()) { mReorderRunnable = () -> doReorder(viewOrder); } else { doReorder(viewOrder); } } // TODO: (b/273592694) animate it private void doReorder(List viewOrder) { if (!isExpanded()) { for (int i = 0; i < viewOrder.size(); i++) { View child = viewOrder.get(i); // this child view may have already been removed so verify that it still exists // before reordering it, otherwise it will be re-added. int indexOfChild = indexOfChild(child); if (child != null && indexOfChild >= 0) { removeViewInLayout(child); addViewInLayout(child, i, child.getLayoutParams()); } } updateBubblesLayoutProperties(mBubbleBarLocation); updateContentDescription(); updateNotificationDotsIfCollapsed(); } } public void setUpdateSelectedBubbleAfterCollapse( Consumer updateSelectedBubbleAfterCollapse) { mUpdateSelectedBubbleAfterCollapse = updateSelectedBubbleAfterCollapse; } void setController(Controller controller) { mController = controller; } /** * Sets which bubble view should be shown as selected. */ public void setSelectedBubble(BubbleView view) { BubbleView previouslySelectedBubble = mSelectedBubbleView; mSelectedBubbleView = view; mBubbleBarBackground.showArrow(view != null); // if bubbles are being animated, the arrow position will be set as part of the animation if (mBubbleAnimator == null) { updateArrowForSelected(previouslySelectedBubble != null); } if (view != null) { if (isExpanded()) { view.markSeen(); } else { // when collapsed, the selected bubble should show the dot if it has it view.showDotIfNeeded(/* animate= */ true); } } } /** * Sets the dragged bubble view to correctly apply Z order. Dragged view should appear on top */ public void setDraggedBubble(@Nullable BubbleView view) { if (mDraggedBubbleView != null) { mDraggedBubbleView.setZ(0); } mDraggedBubbleView = view; if (view != null) { view.setZ(mDragElevation); // we started dragging a bubble. reset the bubble that was previously dismissed by drag mDismissedByDragBubbleView = null; } setIsDragging(view != null); } /** * Update the arrow position to match the selected bubble. * * @param shouldAnimate whether or not to animate the arrow. If the bar was just expanded, this * should be set to {@code false}. Otherwise set this to {@code true}. */ private void updateArrowForSelected(boolean shouldAnimate) { if (mSelectedBubbleView == null) { Log.w(TAG, "trying to update selection arrow without a selected view!"); return; } // Find the center of the bubble when it's expanded, set the arrow position to it. final float tx = arrowPositionForSelectedWhenExpanded(mBubbleBarLocation); final float currentArrowPosition = mBubbleBarBackground.getArrowPositionX(); if (tx == currentArrowPosition) { // arrow position remains unchanged return; } if (shouldAnimate && currentArrowPosition > expandedWidth()) { Log.d(TAG, "arrow out of bounds of expanded view, skip animation"); shouldAnimate = false; } if (shouldAnimate) { ValueAnimator animator = ValueAnimator.ofFloat(currentArrowPosition, tx); animator.setDuration(ARROW_POSITION_ANIMATION_DURATION_MS); animator.addUpdateListener(animation -> { float x = (float) animation.getAnimatedValue(); mBubbleBarBackground.setArrowPosition(x); invalidate(); }); animator.start(); } else { mBubbleBarBackground.setArrowPosition(tx); invalidate(); } } private float arrowPositionForSelectedWhenExpanded(BubbleBarLocation bubbleBarLocation) { if (mBubbleAnimator != null && mBubbleAnimator.isRunning()) { return mBubbleAnimator.getArrowPosition() + mBubbleBarPadding; } final int index = indexOfChild(mSelectedBubbleView); final float selectedBubbleTranslationX = getExpandedBubbleTranslationX( index, getChildCount(), bubbleBarLocation.isOnLeft(isLayoutRtl())); return selectedBubbleTranslationX + mIconSize / 2f; } private float arrowPositionForSelectedWhenCollapsed(BubbleBarLocation bubbleBarLocation) { final int index = indexOfChild(mSelectedBubbleView); final int bubblePosition; if (bubbleBarLocation.isOnLeft(isLayoutRtl())) { // Bubble positions are reversed. First bubble may be shifted, if there are more // bubbles than the current bubble and overflow. bubblePosition = index == 0 && getChildCount() > MAX_VISIBLE_BUBBLES_COLLAPSED ? 1 : 0; } else { bubblePosition = index >= MAX_VISIBLE_BUBBLES_COLLAPSED ? MAX_VISIBLE_BUBBLES_COLLAPSED - 1 : index; } return mBubbleBarPadding + bubblePosition * (mIconOverlapAmount) + getScaledIconSize() / 2f; } @Override public void setOnClickListener(View.OnClickListener listener) { mOnClickListener = listener; setOrUnsetClickListener(); } /** * The click listener used for the bubble view gets added / removed depending on whether * the bar is expanded or collapsed, this updates whether the listener is set based on state. */ private void setOrUnsetClickListener() { super.setOnClickListener(mIsBarExpanded ? null : mOnClickListener); } /** * Sets whether the bubble bar is expanded or collapsed. */ public void setExpanded(boolean isBarExpanded) { if (mIsBarExpanded != isBarExpanded) { mIsBarExpanded = isBarExpanded; updateArrowForSelected(/* shouldAnimate= */ false); setOrUnsetClickListener(); if (isBarExpanded) { mWidthAnimator.start(); } else { mWidthAnimator.reverse(); } updateBubbleAccessibilityStates(); announceExpandedStateChange(); } } /** * Returns whether the bubble bar is expanded. */ public boolean isExpanded() { return mIsBarExpanded; } /** * Returns whether the bubble bar is expanding. */ public boolean isExpanding() { return mWidthAnimator.isRunning() && mIsBarExpanded; } /** * Get width of the bubble bar as if it would be expanded. * * @return width of the bubble bar in its expanded state, regardless of current width */ public float expandedWidth() { final int childCount = getChildCount(); final float horizontalPadding = 2 * mBubbleBarPadding; if (mBubbleAnimator != null && mBubbleAnimator.isRunning()) { return mBubbleAnimator.getExpandedWidth() + horizontalPadding; } // spaces amount is less than child count by 1, or 0 if no child views final float totalSpace = Math.max(childCount - 1, 0) * mExpandedBarIconsSpacing; final float totalIconSize = childCount * getScaledIconSize(); return totalIconSize + totalSpace + horizontalPadding; } /** * Get width of the bubble bar if it is collapsed */ float collapsedWidth() { final int bubbleChildCount = getBubbleChildCount(); final float horizontalPadding = 2 * mBubbleBarPadding; // If there are more than 2 bubbles, the first 2 should be visible when collapsed, // excluding the overflow. return bubbleChildCount >= MAX_VISIBLE_BUBBLES_COLLAPSED ? getScaledIconSize() + mIconOverlapAmount + horizontalPadding : getScaledIconSize() + horizontalPadding; } /** Returns the child count excluding the overflow if it's present. */ int getBubbleChildCount() { return hasOverflow() ? getChildCount() - 1 : getChildCount(); } private float getBubbleBarExpandedHeight() { return getBubbleBarCollapsedHeight() + mPointerSize; } float getBubbleBarCollapsedHeight() { // the pointer is invisible when collapsed return getScaledIconSize() + mBubbleBarPadding * 2; } /** * Returns whether the given MotionEvent, *in screen coordinates*, is within bubble bar * touch bounds. */ public boolean isEventOverAnyItem(MotionEvent ev) { if (getVisibility() == VISIBLE) { getBoundsOnScreen(mTempRect); return mTempRect.contains((int) ev.getX(), (int) ev.getY()); } return false; } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { mController.onBubbleBarTouched(); if (!mIsBarExpanded) { // When the bar is collapsed, all taps on it should expand it. return true; } return super.onInterceptTouchEvent(ev); } private boolean hasOverflow() { // Overflow is always the last bubble View lastChild = getChildAt(getChildCount() - 1); if (lastChild instanceof BubbleView bubbleView) { return bubbleView.getBubble() instanceof BubbleBarOverflow; } return false; } private void updateBubbleAccessibilityStates() { if (mIsBarExpanded) { // Bar is expanded, focus on the bubbles setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); // Set up a11y navigation order. Get list of child views in the order they are shown // on screen. And use that to set up navigation so that swiping left focuses the view // on the left and swiping right focuses view on the right. View prevChild = null; for (View childView : getChildViewsInOnScreenOrder()) { childView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); childView.setFocusable(true); final View finalPrevChild = prevChild; // Always need to set a new delegate to clear out any previous. childView.setAccessibilityDelegate(new AccessibilityDelegate() { @Override public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { super.onInitializeAccessibilityNodeInfo(host, info); if (finalPrevChild != null) { info.setTraversalAfter(finalPrevChild); } } }); prevChild = childView; } } else { // Bar is collapsed, only focus on the bar setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); for (int i = 0; i < getChildCount(); i++) { View childView = getChildAt(i); childView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); childView.setFocusable(false); } } } private void updateContentDescription() { View firstChild = getChildAt(0); CharSequence contentDesc = firstChild != null ? firstChild.getContentDescription() : ""; // Don't count overflow if it exists int bubbleCount = getChildCount() - (hasOverflow() ? 1 : 0); if (bubbleCount > 1) { contentDesc = getResources().getString(R.string.bubble_bar_description_multiple_bubbles, contentDesc, bubbleCount - 1); } setContentDescription(contentDesc); } private void announceExpandedStateChange() { final CharSequence selectedBubbleContentDesc; if (mSelectedBubbleView != null) { selectedBubbleContentDesc = mSelectedBubbleView.getContentDescription(); } else { selectedBubbleContentDesc = getResources().getString( R.string.bubble_bar_bubble_fallback_description); } final String msg; if (mIsBarExpanded) { msg = getResources().getString(R.string.bubble_bar_accessibility_announce_expand, selectedBubbleContentDesc); } else { msg = getResources().getString(R.string.bubble_bar_accessibility_announce_collapse, selectedBubbleContentDesc); } announceForAccessibility(msg); } private boolean isIconSizeOrPaddingUpdated(float newIconSize, float newBubbleBarPadding) { return isIconSizeUpdated(newIconSize) || isPaddingUpdated(newBubbleBarPadding); } private boolean isIconSizeUpdated(float newIconSize) { return Float.compare(mIconSize, newIconSize) != 0; } private boolean isPaddingUpdated(float newBubbleBarPadding) { return Float.compare(mBubbleBarPadding, newBubbleBarPadding) != 0; } private void addAnimationCallBacks(@NonNull ValueAnimator animator, @Nullable Runnable onStart, @Nullable Runnable onEnd, @Nullable ValueAnimator.AnimatorUpdateListener onUpdate) { if (onUpdate != null) animator.addUpdateListener(onUpdate); animator.addListener(new Animator.AnimatorListener() { @Override public void onAnimationCancel(Animator animator) { } @Override public void onAnimationStart(Animator animator) { if (onStart != null) onStart.run(); } @Override public void onAnimationEnd(Animator animator) { if (onEnd != null) onEnd.run(); } @Override public void onAnimationRepeat(Animator animator) { } }); } /** Dumps the current state of BubbleBarView. */ public void dump(PrintWriter pw) { pw.println("BubbleBarView state:"); pw.println(" visibility: " + getVisibility()); pw.println(" alpha: " + getAlpha()); pw.println(" translationY: " + getTranslationY()); pw.println(" childCount: " + getChildCount()); pw.println(" hasOverflow: " + hasOverflow()); for (BubbleView bubbleView: getBubbles()) { BubbleBarItem bubble = bubbleView.getBubble(); String key = bubble == null ? "null" : bubble.getKey(); pw.println(" bubble key: " + key); } pw.println(" isExpanded: " + isExpanded()); if (mBubbleAnimator != null) { pw.println(" mBubbleAnimator.isRunning(): " + mBubbleAnimator.isRunning()); pw.println(" mBubbleAnimator is null"); } pw.println(" mDragging: " + mDragging); } private List getBubbles() { List bubbles = new ArrayList<>(); for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); if (child instanceof BubbleView bubble) { bubbles.add(bubble); } } return bubbles; } /** Interface for BubbleBarView to communicate with its controller. */ interface Controller { /** Returns the translation Y that the bubble bar should have. */ float getBubbleBarTranslationY(); /** Notifies the controller that the bubble bar was touched. */ void onBubbleBarTouched(); /** Requests the controller to expand bubble bar */ void expandBubbleBar(); /** Requests the controller to dismiss the bubble bar */ void dismissBubbleBar(); /** Requests the controller to update bubble bar location to the given value */ void updateBubbleBarLocation(BubbleBarLocation location); } }