419 lines
14 KiB
Java
419 lines
14 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 android.annotation.Nullable;
|
|
import android.app.Notification;
|
|
import android.content.Context;
|
|
import android.graphics.Bitmap;
|
|
import android.graphics.Canvas;
|
|
import android.graphics.Path;
|
|
import android.graphics.Rect;
|
|
import android.graphics.drawable.BitmapDrawable;
|
|
import android.os.Bundle;
|
|
import android.text.TextUtils;
|
|
import android.util.AttributeSet;
|
|
import android.view.LayoutInflater;
|
|
import android.view.accessibility.AccessibilityNodeInfo;
|
|
import android.widget.ImageView;
|
|
|
|
import androidx.constraintlayout.widget.ConstraintLayout;
|
|
|
|
import com.android.launcher3.R;
|
|
import com.android.launcher3.icons.DotRenderer;
|
|
import com.android.wm.shell.common.bubbles.BubbleBarLocation;
|
|
import com.android.wm.shell.common.bubbles.BubbleInfo;
|
|
import com.android.wm.shell.shared.animation.Interpolators;
|
|
|
|
// TODO: (b/276978250) This is will be similar to WMShell's BadgedImageView, it'd be nice to share.
|
|
|
|
/**
|
|
* View that displays a bubble icon, along with an app badge on either the left or
|
|
* right side of the view.
|
|
*/
|
|
public class BubbleView extends ConstraintLayout {
|
|
|
|
public static final int DEFAULT_PATH_SIZE = 100;
|
|
|
|
private final ImageView mBubbleIcon;
|
|
private final ImageView mAppIcon;
|
|
private int mBubbleSize;
|
|
|
|
private float mDragTranslationX;
|
|
private float mOffsetX;
|
|
|
|
private DotRenderer mDotRenderer;
|
|
private DotRenderer.DrawParams mDrawParams;
|
|
private int mDotColor;
|
|
private Rect mTempBounds = new Rect();
|
|
|
|
// Whether the dot is animating
|
|
private boolean mDotIsAnimating;
|
|
// What scale value the dot is animating to
|
|
private float mAnimatingToDotScale;
|
|
// The current scale value of the dot
|
|
private float mDotScale;
|
|
|
|
private boolean mProvideShadowOutline = true;
|
|
|
|
// TODO: (b/273310265) handle RTL
|
|
// Whether the bubbles are positioned on the left or right side of the screen
|
|
private boolean mOnLeft = false;
|
|
|
|
private BubbleBarItem mBubble;
|
|
private boolean mIsOverflow;
|
|
|
|
private Bitmap mIcon;
|
|
|
|
@Nullable
|
|
private Controller mController;
|
|
|
|
@Nullable
|
|
private BubbleBarBubbleIconsFactory mIconFactory = null;
|
|
|
|
public BubbleView(Context context) {
|
|
this(context, null);
|
|
}
|
|
|
|
public BubbleView(Context context, AttributeSet attrs) {
|
|
this(context, attrs, 0);
|
|
}
|
|
|
|
public BubbleView(Context context, AttributeSet attrs, int defStyleAttr) {
|
|
this(context, attrs, defStyleAttr, 0);
|
|
}
|
|
|
|
public BubbleView(Context context, AttributeSet attrs, int defStyleAttr,
|
|
int defStyleRes) {
|
|
super(context, attrs, defStyleAttr, defStyleRes);
|
|
// We manage positioning the badge ourselves
|
|
setLayoutDirection(LAYOUT_DIRECTION_LTR);
|
|
|
|
LayoutInflater.from(context).inflate(R.layout.bubble_view, this);
|
|
mBubbleIcon = findViewById(R.id.icon_view);
|
|
mAppIcon = findViewById(R.id.app_icon_view);
|
|
|
|
mDrawParams = new DotRenderer.DrawParams();
|
|
|
|
setFocusable(true);
|
|
setClickable(true);
|
|
}
|
|
|
|
private void updateBubbleSizeAndDotRender() {
|
|
int updatedBubbleSize = Math.min(getWidth(), getHeight());
|
|
if (updatedBubbleSize == mBubbleSize) return;
|
|
mBubbleSize = updatedBubbleSize;
|
|
mIconFactory = new BubbleBarBubbleIconsFactory(mContext, mBubbleSize);
|
|
updateBubbleIcon();
|
|
if (mBubble == null || mBubble instanceof BubbleBarOverflow) return;
|
|
Path dotPath = ((BubbleBarBubble) mBubble).getDotPath();
|
|
mDotRenderer = new DotRenderer(mBubbleSize, dotPath, DEFAULT_PATH_SIZE);
|
|
}
|
|
|
|
/**
|
|
* Set translation-x while this bubble is being dragged.
|
|
* Translation applied to the view is a sum of {@code translationX} and offset defined by
|
|
* {@link #setOffsetX(float)}.
|
|
*/
|
|
public void setDragTranslationX(float translationX) {
|
|
mDragTranslationX = translationX;
|
|
applyDragTranslation();
|
|
}
|
|
|
|
/**
|
|
* Get translation value applied via {@link #setDragTranslationX(float)}.
|
|
*/
|
|
public float getDragTranslationX() {
|
|
return mDragTranslationX;
|
|
}
|
|
|
|
/**
|
|
* Set offset on x-axis while dragging.
|
|
* Used to counter parent translation in order to keep the dragged view at the current position
|
|
* on screen.
|
|
* Translation applied to the view is a sum of {@code offsetX} and translation defined by
|
|
* {@link #setDragTranslationX(float)}
|
|
*/
|
|
public void setOffsetX(float offsetX) {
|
|
mOffsetX = offsetX;
|
|
applyDragTranslation();
|
|
}
|
|
|
|
@Override
|
|
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
|
|
super.onLayout(changed, left, top, right, bottom);
|
|
updateBubbleSizeAndDotRender();
|
|
}
|
|
|
|
private void applyDragTranslation() {
|
|
setTranslationX(mDragTranslationX + mOffsetX);
|
|
}
|
|
|
|
@Override
|
|
public void dispatchDraw(Canvas canvas) {
|
|
super.dispatchDraw(canvas);
|
|
|
|
if (!shouldDrawDot()) {
|
|
return;
|
|
}
|
|
|
|
getDrawingRect(mTempBounds);
|
|
|
|
mDrawParams.dotColor = mDotColor;
|
|
mDrawParams.iconBounds = mTempBounds;
|
|
mDrawParams.leftAlign = mOnLeft;
|
|
mDrawParams.scale = mDotScale;
|
|
|
|
mDotRenderer.draw(canvas, mDrawParams);
|
|
}
|
|
|
|
@Override
|
|
public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) {
|
|
super.onInitializeAccessibilityNodeInfoInternal(info);
|
|
info.addAction(AccessibilityNodeInfo.ACTION_COLLAPSE);
|
|
if (mBubble instanceof BubbleBarBubble) {
|
|
info.addAction(AccessibilityNodeInfo.ACTION_DISMISS);
|
|
}
|
|
if (mController != null) {
|
|
if (mController.getBubbleBarLocation().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, Bundle arguments) {
|
|
if (super.performAccessibilityActionInternal(action, arguments)) {
|
|
return true;
|
|
}
|
|
if (action == AccessibilityNodeInfo.ACTION_COLLAPSE) {
|
|
if (mController != null) {
|
|
mController.collapse();
|
|
}
|
|
return true;
|
|
}
|
|
if (action == AccessibilityNodeInfo.ACTION_DISMISS) {
|
|
if (mController != null) {
|
|
mController.dismiss(this);
|
|
}
|
|
return true;
|
|
}
|
|
if (action == R.id.action_move_left) {
|
|
if (mController != null) {
|
|
mController.updateBubbleBarLocation(BubbleBarLocation.LEFT);
|
|
}
|
|
}
|
|
if (action == R.id.action_move_right) {
|
|
if (mController != null) {
|
|
mController.updateBubbleBarLocation(BubbleBarLocation.RIGHT);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void setController(@Nullable Controller controller) {
|
|
mController = controller;
|
|
}
|
|
|
|
/** Sets the bubble being rendered in this view. */
|
|
public void setBubble(BubbleBarBubble bubble) {
|
|
mBubble = bubble;
|
|
mIcon = bubble.getIcon();
|
|
updateBubbleIcon();
|
|
mAppIcon.setImageBitmap(bubble.getBadge());
|
|
mDotColor = bubble.getDotColor();
|
|
mDotRenderer = new DotRenderer(mBubbleSize, bubble.getDotPath(), DEFAULT_PATH_SIZE);
|
|
String contentDesc = bubble.getInfo().getTitle();
|
|
if (TextUtils.isEmpty(contentDesc)) {
|
|
contentDesc = getResources().getString(R.string.bubble_bar_bubble_fallback_description);
|
|
}
|
|
String appName = bubble.getInfo().getAppName();
|
|
if (!TextUtils.isEmpty(appName)) {
|
|
contentDesc = getResources().getString(R.string.bubble_bar_bubble_description,
|
|
contentDesc, appName);
|
|
}
|
|
setContentDescription(contentDesc);
|
|
}
|
|
|
|
private void updateBubbleIcon() {
|
|
Bitmap icon = null;
|
|
if (mIcon != null) {
|
|
icon = mIcon;
|
|
if (mIconFactory != null) {
|
|
BitmapDrawable iconDrawable = new BitmapDrawable(getResources(), icon);
|
|
icon = mIconFactory.createShadowedIconBitmap(iconDrawable, /* scale = */ 1f);
|
|
}
|
|
}
|
|
mBubbleIcon.setImageBitmap(icon);
|
|
}
|
|
|
|
/**
|
|
* Sets that this bubble represents the overflow. The overflow appears in the list of bubbles
|
|
* but does not represent app content, instead it shows recent bubbles that couldn't fit into
|
|
* the list of bubbles. It doesn't show an app icon because it is part of system UI / doesn't
|
|
* come from an app.
|
|
*/
|
|
public void setOverflow(BubbleBarOverflow overflow, Bitmap bitmap) {
|
|
mBubble = overflow;
|
|
mIsOverflow = true;
|
|
mIcon = bitmap;
|
|
updateBubbleIcon();
|
|
mAppIcon.setVisibility(GONE); // Overflow doesn't show the app badge
|
|
setContentDescription(getResources().getString(R.string.bubble_bar_overflow_description));
|
|
}
|
|
|
|
/** Whether this view represents the overflow button. */
|
|
public boolean isOverflow() {
|
|
return mIsOverflow;
|
|
}
|
|
|
|
/** Returns the bubble being rendered in this view. */
|
|
@Nullable
|
|
public BubbleBarItem getBubble() {
|
|
return mBubble;
|
|
}
|
|
|
|
void updateDotVisibility(boolean animate) {
|
|
final float targetScale = hasUnseenContent() ? 1f : 0f;
|
|
if (animate) {
|
|
animateDotScale(targetScale);
|
|
} else {
|
|
mDotScale = targetScale;
|
|
mAnimatingToDotScale = targetScale;
|
|
invalidate();
|
|
}
|
|
}
|
|
|
|
void setBadgeScale(float fraction) {
|
|
mAppIcon.setScaleX(fraction);
|
|
mAppIcon.setScaleY(fraction);
|
|
}
|
|
|
|
boolean hasUnseenContent() {
|
|
return mBubble != null
|
|
&& mBubble instanceof BubbleBarBubble
|
|
&& !((BubbleBarBubble) mBubble).getInfo().isNotificationSuppressed();
|
|
}
|
|
|
|
/**
|
|
* Used to determine if we can skip drawing frames.
|
|
*
|
|
* <p>Generally we should draw the dot when it is requested to be shown and there is unseen
|
|
* content. But when the dot is removed, we still want to draw frames so that it can be scaled
|
|
* out.
|
|
*/
|
|
private boolean shouldDrawDot() {
|
|
// if there's no dot there's nothing to draw, unless the dot was removed and we're in the
|
|
// middle of removing it
|
|
return hasUnseenContent() || mDotIsAnimating;
|
|
}
|
|
|
|
/** Updates the dot scale to the specified fraction from 0 to 1. */
|
|
private void setDotScale(float fraction) {
|
|
if (!shouldDrawDot()) {
|
|
return;
|
|
}
|
|
mDotScale = fraction;
|
|
invalidate();
|
|
}
|
|
|
|
void showDotIfNeeded(float fraction) {
|
|
if (!hasUnseenContent()) {
|
|
return;
|
|
}
|
|
setDotScale(fraction);
|
|
}
|
|
|
|
void showDotIfNeeded(boolean animate) {
|
|
// only show the dot if we have unseen content
|
|
if (!hasUnseenContent()) {
|
|
return;
|
|
}
|
|
if (animate) {
|
|
animateDotScale(1f);
|
|
} else {
|
|
setDotScale(1f);
|
|
}
|
|
}
|
|
|
|
void hideDot() {
|
|
animateDotScale(0f);
|
|
}
|
|
|
|
/** Marks this bubble such that it no longer has unseen content, and hides the dot. */
|
|
void markSeen() {
|
|
if (mBubble instanceof BubbleBarBubble bubble) {
|
|
BubbleInfo info = bubble.getInfo();
|
|
info.setFlags(
|
|
info.getFlags() | Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION);
|
|
hideDot();
|
|
}
|
|
}
|
|
|
|
/** Animates the dot to the given scale. */
|
|
private void animateDotScale(float toScale) {
|
|
boolean isDotScaleChanging = Float.compare(mDotScale, toScale) != 0;
|
|
|
|
// Don't restart the animation if we're already animating to the given value or if the dot
|
|
// scale is not changing
|
|
if ((mDotIsAnimating && mAnimatingToDotScale == toScale) || !isDotScaleChanging) {
|
|
return;
|
|
}
|
|
mDotIsAnimating = true;
|
|
mAnimatingToDotScale = toScale;
|
|
|
|
final boolean showDot = toScale > 0f;
|
|
|
|
clearAnimation();
|
|
animate()
|
|
.setDuration(200)
|
|
.setInterpolator(Interpolators.FAST_OUT_SLOW_IN)
|
|
.setUpdateListener((valueAnimator) -> {
|
|
float fraction = valueAnimator.getAnimatedFraction();
|
|
fraction = showDot ? fraction : 1f - fraction;
|
|
setDotScale(fraction);
|
|
}).withEndAction(() -> {
|
|
setDotScale(showDot ? 1f : 0f);
|
|
mDotIsAnimating = false;
|
|
}).start();
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
String toString = mBubble != null ? mBubble.getKey() : "null";
|
|
return "BubbleView{" + toString + "}";
|
|
}
|
|
|
|
/** Interface for BubbleView to communicate with its controller */
|
|
public interface Controller {
|
|
/** Get current bubble bar {@link BubbleBarLocation} */
|
|
BubbleBarLocation getBubbleBarLocation();
|
|
|
|
/** This bubble should be dismissed */
|
|
void dismiss(BubbleView bubble);
|
|
|
|
/** Collapse the bubble bar */
|
|
void collapse();
|
|
|
|
/** Request bubble bar location to be updated to the given location */
|
|
void updateBubbleBarLocation(BubbleBarLocation location);
|
|
}
|
|
}
|