Files
Lawnchair/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java
T
Mykola Podolian 6ddfa5dfd5 Drag and drop from the search view on the overview screen.
When the bubble bar is stashed and an icon is dragged from the search
grid towards it, the bubble bar should be un-stashed. Conversely, if the
icon is dragged to the opposite location of the stashed bubble bar, the
handle should teleport out, and the collapsed bubble bar should
teleport in.
This change implements logic that addresses this behavior.

Bug: 399678274
Test: Manual. Drag and drop application icons from the search grid to
the bubble bar drop areas.
Flag: com.android.wm.shell.enable_create_any_bubble

Change-Id: Ief9ef3e5c24f8199b2812fd0adf63ae3f6cf8f32
2025-03-20 09:39:35 -07:00

1669 lines
69 KiB
Java

/*
* 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.launcher3.LauncherAnimUtils.VIEW_ALPHA;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
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 com.android.app.animation.Interpolators;
import com.android.launcher3.R;
import com.android.launcher3.anim.AnimatorListeners;
import com.android.launcher3.taskbar.BarsLocationAnimatorHelper;
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.
* <p>
* 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.
* <p>
* 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
* <p>
* 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.
* <p>
* 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 {
public static final long FADE_OUT_ANIM_POSITION_DURATION_MS = 100L;
public static final long FADE_IN_ANIM_ALPHA_DURATION_MS = 100L;
public static final long FADE_OUT_BUBBLE_BAR_DURATION_MS = 150L;
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 = 400;
private static final int SCALE_ANIMATION_DURATION_MS = 200;
/**
* 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<BubbleBarView> 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 ValueAnimator mWidthAnimator = createExpansionAnimator(/* expanding = */ false);
@Nullable
private ValueAnimator mDismissAnimator = null;
/** 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<String> mUpdateSelectedBubbleAfterCollapse;
private boolean mDragging;
@Nullable
private BubbleView mDraggedBubbleView;
@Nullable
private BubbleView mDismissedByDragBubbleView;
private float mAlphaDuringDrag = 1f;
/** Additional translation in the y direction that is applied to each bubble */
private float mBubbleOffsetY;
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.dragged_bubble_elevation);
mPointerSize = getResources()
.getDimensionPixelSize(R.dimen.bubblebar_pointer_visible_size);
setClipToPadding(false);
mBubbleBarBackground = new BubbleBarBackground(context, getBubbleBarExpandedHeight());
setBackgroundDrawable(mBubbleBarBackground);
}
/**
* 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 offset of each bubble view in the y direction from the base position in the bar.
*/
public void setBubbleOffsetY(float offsetY) {
mBubbleOffsetY = offsetY;
for (int i = 0; i < getChildCount(); i++) {
getChildAt(i).setTranslationY(getBubbleTranslationY());
}
}
/**
* Set the bubble icons size and spacing between the bubbles and the borders of the bubble
* bar.
*/
public void setIconSizeAndPaddingForPinning(float newIconSize, float newBubbleBarPadding) {
mBubbleBarPadding = newBubbleBarPadding;
mIconScale = newIconSize / mIconSize;
updateBubblesLayoutProperties(mBubbleBarLocation);
invalidate();
}
/**
* 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(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK);
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 (action == AccessibilityNodeInfo.ACTION_EXPAND
|| action == AccessibilityNodeInfo.ACTION_CLICK) {
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,
BubbleBarLocation.UpdateSource.A11Y_ACTION_BAR);
return true;
}
if (action == R.id.action_move_right) {
mController.updateBubbleBarLocation(BubbleBarLocation.RIGHT,
BubbleBarLocation.UpdateSource.A11Y_ACTION_BAR);
return true;
}
return super.performAccessibilityActionInternal(action, arguments);
}
@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;
mController.setIsDragging(dragging);
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);
}
/** Set whether the background should show the drop target */
public void showDropTarget(boolean isDropTarget) {
mBubbleBarBackground.showDropTarget(isDropTarget);
}
/** Returns whether the Bubble Bar is currently displaying a drop target. */
public boolean isShowingDropTarget() {
return mBubbleBarBackground.isShowingDropTarget();
}
/**
* 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 = animateToBubbleBarLocationOut(bubbleBarLocation);
mBubbleBarLocationAnimator.addListener(AnimatorListeners.forEndCallback(() -> {
// Animate it in
mBubbleBarLocationAnimator = animateToBubbleBarLocationIn(mBubbleBarLocation,
bubbleBarLocation);
mBubbleBarLocationAnimator.start();
}));
mBubbleBarLocationAnimator.start();
}
/** Creates animator for animating bubble bar in. */
public Animator animateToBubbleBarLocationIn(BubbleBarLocation fromLocation,
BubbleBarLocation toLocation) {
updateBubblesLayoutProperties(toLocation);
mBubbleBarBackground.setAnchorLeft(toLocation.isOnLeft(isLayoutRtl()));
ObjectAnimator alphaInAnim = ObjectAnimator.ofFloat(BubbleBarView.this,
getLocationAnimAlphaProperty(), 1f);
return BarsLocationAnimatorHelper.getBubbleBarLocationInAnimator(toLocation, fromLocation,
getDistanceFromOtherSide(), alphaInAnim, this);
}
/**
* Creates animator for animating bubble bar out.
*
* @param targetLocation the location bubble br should animate to.
*/
public Animator animateToBubbleBarLocationOut(BubbleBarLocation targetLocation) {
ObjectAnimator alphaOutAnim = ObjectAnimator.ofFloat(
this, getLocationAnimAlphaProperty(), 0f);
Animator outAnimation = BarsLocationAnimatorHelper.getBubbleBarLocationOutAnimator(
this,
targetLocation,
alphaOutAnim);
outAnimation.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(@NonNull Animator animation, boolean isReverse) {
// need to restore the original bar view state in case icon is dropped to the bubble
// bar original location
updateBubblesLayoutProperties(targetLocation);
mBubbleBarBackground.setAnchorLeft(targetLocation.isOnLeft(isLayoutRtl()));
setTranslationX(0f);
}
});
return outAnimation;
}
/**
* 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<? super BubbleBarView> 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 (mIsBarExpanded && 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 without updating the selected bubble. */
public void addBubble(BubbleView bubble) {
addBubble(bubble, /* bubbleToSelect = */ null);
}
/**
* Add a new bubble to the bubble bar and selects the provided bubble.
*
* @param bubble bubble to add
* @param bubbleToSelect if {@code null}, then selected bubble does not change
*/
public void addBubble(BubbleView bubble, @Nullable BubbleView bubbleToSelect) {
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();
}
};
if (bubbleToSelect != null) {
mBubbleAnimator.animateNewBubble(indexOfChild(mSelectedBubbleView),
indexOfChild(bubbleToSelect), listener);
} else {
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,
@Nullable BubbleView bubbleToSelect, Runnable onEndRunnable) {
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams((int) mIconSize, (int) mIconSize,
Gravity.LEFT);
int addedIndex = addedBubble.isOverflow() ? getChildCount() : 0;
if (!isExpanded()) {
removeView(removedBubble);
addView(addedBubble, addedIndex, lp);
if (onEndRunnable != null) {
onEndRunnable.run();
}
return;
}
addedBubble.setScaleX(0f);
addedBubble.setScaleY(0f);
addView(addedBubble, addedIndex, lp);
int indexOfCurrentSelectedBubble = indexOfChild(mSelectedBubbleView);
int indexOfBubbleToRemove = indexOfChild(removedBubble);
int indexOfNewlySelectedBubble = bubbleToSelect == null
? indexOfCurrentSelectedBubble : indexOfChild(bubbleToSelect);
// Since removed bubble is kept till the end of the animation we should check if there are
// more than one bubble. In such a case the bar will remain open without the selected bubble
if (mSelectedBubbleView == removedBubble
&& bubbleToSelect == null
&& getBubbleChildCount() > 1) {
Log.w(TAG, "Remove the currently selected bubble without selecting a new one.");
}
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(indexOfCurrentSelectedBubble,
indexOfNewlySelectedBubble, indexOfBubbleToRemove, addedIndex, listener);
}
@Override
public void addView(View child, int index, ViewGroup.LayoutParams params) {
super.addView(child, index, params);
updateLayoutParams();
updateBubbleAccessibilityStates();
updateContentDescription();
updateDotsAndBadgesIfCollapsed();
}
/** Removes the given bubble from the bubble bar. */
public void removeBubble(View bubble) {
if (isExpanded()) {
final boolean dismissedByDrag = mDraggedBubbleView == bubble;
if (dismissedByDrag) {
mDismissedByDragBubbleView = mDraggedBubbleView;
}
boolean removingLastRemainingBubble = getBubbleChildCount() == 1;
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,
removingLastRemainingBubble, listener);
if (removingLastRemainingBubble && mDismissAnimator == null) {
createDismissAnimator().start();
}
} 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;
updateDotsAndBadgesIfCollapsed();
}
private ValueAnimator createDismissAnimator() {
ValueAnimator animator =
ValueAnimator.ofFloat(0, 1).setDuration(FADE_OUT_BUBBLE_BAR_DURATION_MS);
animator.setInterpolator(Interpolators.EMPHASIZED);
Runnable onEnd = () -> {
mDismissAnimator = null;
setAlpha(0);
};
addAnimationCallBacks(animator, /* onStart= */ null, onEnd,
/* onUpdate= */ anim -> setAlpha(1 - anim.getAnimatedFraction()));
mDismissAnimator = animator;
return animator;
}
/** Dismisses the bubble bar */
public void dismiss(Runnable onDismissed) {
if (mDismissAnimator == null) {
createDismissAnimator().start();
}
addAnimationCallBacks(mDismissAnimator, null, onDismissed, null);
}
/**
* Return child views in the order which they are shown on the screen.
* <p>
* 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.<br>
*
* 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).<br>
*
* 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<View> getChildViewsInOnScreenOrder() {
List<View> 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 updateDotsAndBadgesIfCollapsed() {
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 badge and the dot if it has
// it. the rest of the bubbles should hide their badges and dots.
if (i == 0) {
bubbleView.showBadge();
if (bubbleView.hasUnseenContent()) {
bubbleView.showDotIfNeeded(/* animate= */ true);
} else {
bubbleView.hideDot();
}
} else {
bubbleView.hideBadge();
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();
final float ty = getBubbleTranslationY();
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 {
// when the bar is on the right, the first bubble always has translation 0. the only
// case where another bubble has translation 0 is when we only have 1 bubble and the
// overflow. otherwise all other bubbles should be shifted by the overlap amount.
if (bubbleIndex == 0 || getBubbleChildCount() == 1) {
translationX = 0f;
} else {
translationX = mIconOverlapAmount;
}
}
return mBubbleBarPadding + translationX - getScaleIconShift();
}
private float getBubbleTranslationY() {
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
return mBubbleOffsetY + bubbleBarAnimatedTop + mBubbleBarPadding - getScaleIconShift();
}
/**
* Reorders the views to match the provided list.
*/
public void reorder(List<BubbleView> viewOrder) {
if (isExpanded() || mWidthAnimator.isRunning()) {
mReorderRunnable = () -> doReorder(viewOrder);
} else {
doReorder(viewOrder);
}
}
// TODO: (b/273592694) animate it
private void doReorder(List<BubbleView> 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();
updateDotsAndBadgesIfCollapsed();
}
}
public void setUpdateSelectedBubbleAfterCollapse(
Consumer<String> 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();
mWidthAnimator = createExpansionAnimator(isBarExpanded);
mWidthAnimator.start();
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
? getCollapsedWidthWithMaxVisibleBubbles()
: getScaledIconSize() + horizontalPadding;
}
float getCollapsedWidthWithMaxVisibleBubbles() {
return getScaledIconSize() + mIconOverlapAmount + 2 * mBubbleBarPadding;
}
float getCollapsedWidthForIconSizeAndPadding(int iconSize, int bubbleBarPadding) {
final int bubbleChildCount = Math.min(getBubbleChildCount(), MAX_VISIBLE_BUBBLES_COLLAPSED);
if (bubbleChildCount == 0) return 0;
final int spacesCount = bubbleChildCount - 1;
final float horizontalPadding = 2 * bubbleBarPadding;
return iconSize * bubbleChildCount + mIconOverlapAmount * spacesCount + 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 getArrowHeight() {
return 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<BubbleView> getBubbles() {
List<BubbleView> bubbles = new ArrayList<>();
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (child instanceof BubbleView bubble) {
bubbles.add(bubble);
}
}
return bubbles;
}
/** Creates an animator based on the expanding or collapsing action. */
private ValueAnimator createExpansionAnimator(boolean expanding) {
float startValue = expanding ? 0 : 1;
if ((mWidthAnimator != null && mWidthAnimator.isRunning())) {
startValue = (float) mWidthAnimator.getAnimatedValue();
mWidthAnimator.cancel();
}
float endValue = expanding ? 1 : 0;
ValueAnimator animator = ValueAnimator.ofFloat(startValue, endValue);
animator.setDuration(WIDTH_ANIMATION_DURATION_MS);
animator.setInterpolator(Interpolators.EMPHASIZED);
addAnimationCallBacks(animator,
/* 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= */ anim -> {
updateBubblesLayoutProperties(mBubbleBarLocation);
invalidate();
});
return animator;
}
/**
* Returns the distance between the top left corner of the bubble bar to the center of the dot
* of the selected bubble.
*/
PointF getSelectedBubbleDotDistanceFromTopLeft() {
if (mSelectedBubbleView == null) {
return new PointF(0, 0);
}
final int indexOfSelectedBubble = indexOfChild(mSelectedBubbleView);
final boolean onLeft = mBubbleBarLocation.isOnLeft(isLayoutRtl());
final float selectedBubbleTx = isExpanded()
? getExpandedBubbleTranslationX(indexOfSelectedBubble, getChildCount(), onLeft)
: getCollapsedBubbleTranslationX(indexOfSelectedBubble, getChildCount(), onLeft);
PointF selectedBubbleDotCenter = mSelectedBubbleView.getDotCenter();
return new PointF(
selectedBubbleTx + selectedBubbleDotCenter.x,
mBubbleBarPadding + mPointerSize + selectedBubbleDotCenter.y);
}
int getSelectedBubbleDotColor() {
return mSelectedBubbleView == null ? 0 : mSelectedBubbleView.getDotColor();
}
int getPointerSize() {
return mPointerSize;
}
float getBubbleElevation() {
return mBubbleElevation;
}
/** 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,
@BubbleBarLocation.UpdateSource int source);
/** Notifies the controller that bubble bar is being dragged */
void setIsDragging(boolean dragging);
}
}