blob: ca53fa7e6811691fc7982c5dc12d2b6f753d71ef [file] [log] [blame]
/*
* Copyright (C) 2019 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.systemui.bubbles;
import static android.graphics.Paint.ANTI_ALIAS_FLAG;
import static android.graphics.Paint.FILTER_BITMAP_FLAG;
import android.animation.ArgbEvaluator;
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Outline;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PointF;
import android.graphics.RectF;
import android.graphics.drawable.ShapeDrawable;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
import com.android.systemui.R;
import com.android.systemui.recents.TriangleShape;
/**
* Flyout view that appears as a 'chat bubble' alongside the bubble stack. The flyout can visually
* transform into the 'new' dot, which is used during flyout dismiss animations/gestures.
*/
public class BubbleFlyoutView extends FrameLayout {
/** Max width of the flyout, in terms of percent of the screen width. */
private static final float FLYOUT_MAX_WIDTH_PERCENT = .6f;
private final int mFlyoutPadding;
private final int mFlyoutSpaceFromBubble;
private final int mPointerSize;
private final int mBubbleSize;
private final int mBubbleBitmapSize;
private final float mBubbleIconTopPadding;
private final int mFlyoutElevation;
private final int mBubbleElevation;
private final int mFloatingBackgroundColor;
private final float mCornerRadius;
private final ViewGroup mFlyoutTextContainer;
private final ImageView mSenderAvatar;
private final TextView mSenderText;
private final TextView mMessageText;
/** Values related to the 'new' dot which we use to figure out where to collapse the flyout. */
private final float mNewDotRadius;
private final float mNewDotSize;
private final float mOriginalDotSize;
/**
* The paint used to draw the background, whose color changes as the flyout transitions to the
* tinted 'new' dot.
*/
private final Paint mBgPaint = new Paint(ANTI_ALIAS_FLAG | FILTER_BITMAP_FLAG);
private final ArgbEvaluator mArgbEvaluator = new ArgbEvaluator();
/**
* Triangular ShapeDrawables used for the triangle that points from the flyout to the bubble
* stack (a chat-bubble effect).
*/
private final ShapeDrawable mLeftTriangleShape;
private final ShapeDrawable mRightTriangleShape;
/** Whether the flyout arrow is on the left (pointing left) or right (pointing right). */
private boolean mArrowPointingLeft = true;
/** Color of the 'new' dot that the flyout will transform into. */
private int mDotColor;
/** The outline of the triangle, used for elevation shadows. */
private final Outline mTriangleOutline = new Outline();
/** The bounds of the flyout background, kept up to date as it transitions to the 'new' dot. */
private final RectF mBgRect = new RectF();
/**
* Percent progress in the transition from flyout to 'new' dot. These two values are the inverse
* of each other (if we're 40% transitioned to the dot, we're 60% flyout), but it makes the code
* much more readable.
*/
private float mPercentTransitionedToDot = 1f;
private float mPercentStillFlyout = 0f;
/**
* The difference in values between the flyout and the dot. These differences are gradually
* added over the course of the animation to transform the flyout into the 'new' dot.
*/
private float mFlyoutToDotWidthDelta = 0f;
private float mFlyoutToDotHeightDelta = 0f;
/** The translation values when the flyout is completely transitioned into the dot. */
private float mTranslationXWhenDot = 0f;
private float mTranslationYWhenDot = 0f;
/**
* The current translation values applied to the flyout background as it transitions into the
* 'new' dot.
*/
private float mBgTranslationX;
private float mBgTranslationY;
private float[] mDotCenter;
/** The flyout's X translation when at rest (not animating or dragging). */
private float mRestingTranslationX = 0f;
/** The badge sizes are defined as percentages of the app icon size. Same value as Launcher3. */
private static final float SIZE_PERCENTAGE = 0.228f;
private static final float DOT_SCALE = 1f;
/** Callback to run when the flyout is hidden. */
@Nullable private Runnable mOnHide;
public BubbleFlyoutView(Context context) {
super(context);
LayoutInflater.from(context).inflate(R.layout.bubble_flyout, this, true);
mFlyoutTextContainer = findViewById(R.id.bubble_flyout_text_container);
mSenderText = findViewById(R.id.bubble_flyout_name);
mSenderAvatar = findViewById(R.id.bubble_flyout_avatar);
mMessageText = mFlyoutTextContainer.findViewById(R.id.bubble_flyout_text);
final Resources res = getResources();
mFlyoutPadding = res.getDimensionPixelSize(R.dimen.bubble_flyout_padding_x);
mFlyoutSpaceFromBubble = res.getDimensionPixelSize(R.dimen.bubble_flyout_space_from_bubble);
mPointerSize = res.getDimensionPixelSize(R.dimen.bubble_flyout_pointer_size);
mBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
mBubbleBitmapSize = res.getDimensionPixelSize(R.dimen.bubble_bitmap_size);
mBubbleIconTopPadding = (mBubbleSize - mBubbleBitmapSize) / 2f;
mBubbleElevation = res.getDimensionPixelSize(R.dimen.bubble_elevation);
mFlyoutElevation = res.getDimensionPixelSize(R.dimen.bubble_flyout_elevation);
mOriginalDotSize = SIZE_PERCENTAGE * mBubbleBitmapSize;
mNewDotRadius = (DOT_SCALE * mOriginalDotSize) / 2f;
mNewDotSize = mNewDotRadius * 2f;
final TypedArray ta = mContext.obtainStyledAttributes(
new int[] {
android.R.attr.colorBackgroundFloating,
android.R.attr.dialogCornerRadius});
mFloatingBackgroundColor = ta.getColor(0, Color.WHITE);
mCornerRadius = ta.getDimensionPixelSize(1, 0);
ta.recycle();
// Add padding for the pointer on either side, onDraw will draw it in this space.
setPadding(mPointerSize, 0, mPointerSize, 0);
setWillNotDraw(false);
setClipChildren(false);
setTranslationZ(mFlyoutElevation);
setOutlineProvider(new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
BubbleFlyoutView.this.getOutline(outline);
}
});
mBgPaint.setColor(mFloatingBackgroundColor);
mLeftTriangleShape =
new ShapeDrawable(TriangleShape.createHorizontal(
mPointerSize, mPointerSize, true /* isPointingLeft */));
mLeftTriangleShape.setBounds(0, 0, mPointerSize, mPointerSize);
mLeftTriangleShape.getPaint().setColor(mFloatingBackgroundColor);
mRightTriangleShape =
new ShapeDrawable(TriangleShape.createHorizontal(
mPointerSize, mPointerSize, false /* isPointingLeft */));
mRightTriangleShape.setBounds(0, 0, mPointerSize, mPointerSize);
mRightTriangleShape.getPaint().setColor(mFloatingBackgroundColor);
}
@Override
protected void onDraw(Canvas canvas) {
renderBackground(canvas);
invalidateOutline();
super.onDraw(canvas);
}
/** Configures the flyout, collapsed into to dot form. */
void setupFlyoutStartingAsDot(
Bubble.FlyoutMessage flyoutMessage,
PointF stackPos,
float parentWidth,
boolean arrowPointingLeft,
int dotColor,
@Nullable Runnable onLayoutComplete,
@Nullable Runnable onHide,
float[] dotCenter,
boolean hideDot) {
if (flyoutMessage.senderAvatar != null && flyoutMessage.isGroupChat) {
mSenderAvatar.setVisibility(VISIBLE);
mSenderAvatar.setImageDrawable(flyoutMessage.senderAvatar);
} else {
mSenderAvatar.setVisibility(GONE);
mSenderAvatar.setTranslationX(0);
mMessageText.setTranslationX(0);
mSenderText.setTranslationX(0);
}
// Name visibility
if (!TextUtils.isEmpty(flyoutMessage.senderName)) {
mSenderText.setText(flyoutMessage.senderName);
mSenderText.setVisibility(VISIBLE);
} else {
mSenderText.setVisibility(GONE);
}
mArrowPointingLeft = arrowPointingLeft;
mDotColor = dotColor;
mOnHide = onHide;
mDotCenter = dotCenter;
setCollapsePercent(1f);
// Set the flyout TextView's max width in terms of percent, and then subtract out the
// padding so that the entire flyout view will be the desired width (rather than the
// TextView being the desired width + extra padding).
mMessageText.setMaxWidth(
(int) (parentWidth * FLYOUT_MAX_WIDTH_PERCENT) - mFlyoutPadding * 2);
mMessageText.setText(flyoutMessage.message);
// Wait for the TextView to lay out so we know its line count.
post(() -> {
float restingTranslationY;
// Multi line flyouts get top-aligned to the bubble.
if (mMessageText.getLineCount() > 1) {
restingTranslationY = stackPos.y + mBubbleIconTopPadding;
} else {
// Single line flyouts are vertically centered with respect to the bubble.
restingTranslationY =
stackPos.y + (mBubbleSize - mFlyoutTextContainer.getHeight()) / 2f;
}
setTranslationY(restingTranslationY);
// Calculate the translation required to position the flyout next to the bubble stack,
// with the desired padding.
mRestingTranslationX = mArrowPointingLeft
? stackPos.x + mBubbleSize + mFlyoutSpaceFromBubble
: stackPos.x - getWidth() - mFlyoutSpaceFromBubble;
// Calculate the difference in size between the flyout and the 'dot' so that we can
// transform into the dot later.
final float newDotSize = hideDot ? 0f : mNewDotSize;
mFlyoutToDotWidthDelta = getWidth() - newDotSize;
mFlyoutToDotHeightDelta = getHeight() - newDotSize;
// Calculate the translation values needed to be in the correct 'new dot' position.
final float adjustmentForScaleAway = hideDot ? 0f : (mOriginalDotSize / 2f);
final float dotPositionX = stackPos.x + mDotCenter[0] - adjustmentForScaleAway;
final float dotPositionY = stackPos.y + mDotCenter[1] - adjustmentForScaleAway;
final float distanceFromFlyoutLeftToDotCenterX = mRestingTranslationX - dotPositionX;
final float distanceFromLayoutTopToDotCenterY = restingTranslationY - dotPositionY;
mTranslationXWhenDot = -distanceFromFlyoutLeftToDotCenterX;
mTranslationYWhenDot = -distanceFromLayoutTopToDotCenterY;
if (onLayoutComplete != null) {
onLayoutComplete.run();
}
});
}
/**
* Hides the flyout and runs the optional callback passed into setupFlyoutStartingAsDot.
* The flyout has been animated into the 'new' dot by the time we call this, so no animations
* are needed.
*/
void hideFlyout() {
if (mOnHide != null) {
mOnHide.run();
mOnHide = null;
}
setVisibility(GONE);
}
/** Sets the percentage that the flyout should be collapsed into dot form. */
void setCollapsePercent(float percentCollapsed) {
// This is unlikely, but can happen in a race condition where the flyout view hasn't been
// laid out and returns 0 for getWidth(). We check for this condition at the sites where
// this method is called, but better safe than sorry.
if (Float.isNaN(percentCollapsed)) {
return;
}
mPercentTransitionedToDot = Math.max(0f, Math.min(percentCollapsed, 1f));
mPercentStillFlyout = (1f - mPercentTransitionedToDot);
// Move and fade out the text.
final float translationX = mPercentTransitionedToDot
* (mArrowPointingLeft ? -getWidth() : getWidth());
final float alpha = clampPercentage(
(mPercentStillFlyout - (1f - BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS))
/ BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS);
mMessageText.setTranslationX(translationX);
mMessageText.setAlpha(alpha);
mSenderText.setTranslationX(translationX);
mSenderText.setAlpha(alpha);
mSenderAvatar.setTranslationX(translationX);
mSenderAvatar.setAlpha(alpha);
// Reduce the elevation towards that of the topmost bubble.
setTranslationZ(
mFlyoutElevation
- (mFlyoutElevation - mBubbleElevation) * mPercentTransitionedToDot);
invalidate();
}
/** Return the flyout's resting X translation (translation when not dragging or animating). */
float getRestingTranslationX() {
return mRestingTranslationX;
}
/** Clamps a float to between 0 and 1. */
private float clampPercentage(float percent) {
return Math.min(1f, Math.max(0f, percent));
}
/**
* Renders the background, which is either the rounded 'chat bubble' flyout, or some state
* between that and the 'new' dot over the bubbles.
*/
private void renderBackground(Canvas canvas) {
// Calculate the width, height, and corner radius of the flyout given the current collapsed
// percentage.
final float width = getWidth() - (mFlyoutToDotWidthDelta * mPercentTransitionedToDot);
final float height = getHeight() - (mFlyoutToDotHeightDelta * mPercentTransitionedToDot);
final float interpolatedRadius = getInterpolatedRadius();
// Translate the flyout background towards the collapsed 'dot' state.
mBgTranslationX = mTranslationXWhenDot * mPercentTransitionedToDot;
mBgTranslationY = mTranslationYWhenDot * mPercentTransitionedToDot;
// Set the bounds of the rounded rectangle that serves as either the flyout background or
// the collapsed 'dot'. These bounds will also be used to provide the outline for elevation
// shadows. In the expanded flyout state, the left and right bounds leave space for the
// pointer triangle - as the flyout collapses, this space is reduced since the triangle
// retracts into the flyout.
mBgRect.set(
mPointerSize * mPercentStillFlyout /* left */,
0 /* top */,
width - mPointerSize * mPercentStillFlyout /* right */,
height /* bottom */);
mBgPaint.setColor(
(int) mArgbEvaluator.evaluate(
mPercentTransitionedToDot, mFloatingBackgroundColor, mDotColor));
canvas.save();
canvas.translate(mBgTranslationX, mBgTranslationY);
renderPointerTriangle(canvas, width, height);
canvas.drawRoundRect(mBgRect, interpolatedRadius, interpolatedRadius, mBgPaint);
canvas.restore();
}
/** Renders the 'pointer' triangle that points from the flyout to the bubble stack. */
private void renderPointerTriangle(
Canvas canvas, float currentFlyoutWidth, float currentFlyoutHeight) {
canvas.save();
// Translation to apply for the 'retraction' effect as the flyout collapses.
final float retractionTranslationX =
(mArrowPointingLeft ? 1 : -1) * (mPercentTransitionedToDot * mPointerSize * 2f);
// Place the arrow either at the left side, or the far right, depending on whether the
// flyout is on the left or right side.
final float arrowTranslationX =
mArrowPointingLeft
? retractionTranslationX
: currentFlyoutWidth - mPointerSize + retractionTranslationX;
// Vertically center the arrow at all times.
final float arrowTranslationY = currentFlyoutHeight / 2f - mPointerSize / 2f;
// Draw the appropriate direction of arrow.
final ShapeDrawable relevantTriangle =
mArrowPointingLeft ? mLeftTriangleShape : mRightTriangleShape;
canvas.translate(arrowTranslationX, arrowTranslationY);
relevantTriangle.setAlpha((int) (255f * mPercentStillFlyout));
relevantTriangle.draw(canvas);
// Save the triangle's outline for use in the outline provider, offsetting it to reflect its
// current position.
relevantTriangle.getOutline(mTriangleOutline);
mTriangleOutline.offset((int) arrowTranslationX, (int) arrowTranslationY);
canvas.restore();
}
/** Builds an outline that includes the transformed flyout background and triangle. */
private void getOutline(Outline outline) {
if (!mTriangleOutline.isEmpty()) {
// Draw the rect into the outline as a path so we can merge the triangle path into it.
final Path rectPath = new Path();
final float interpolatedRadius = getInterpolatedRadius();
rectPath.addRoundRect(mBgRect, interpolatedRadius,
interpolatedRadius, Path.Direction.CW);
outline.setPath(rectPath);
// Get rid of the triangle path once it has disappeared behind the flyout.
if (mPercentStillFlyout > 0.5f) {
outline.mPath.addPath(mTriangleOutline.mPath);
}
// Translate the outline to match the background's position.
final Matrix outlineMatrix = new Matrix();
outlineMatrix.postTranslate(getLeft() + mBgTranslationX, getTop() + mBgTranslationY);
// At the very end, retract the outline into the bubble so the shadow will be pulled
// into the flyout-dot as it (visually) becomes part of the bubble. We can't do this by
// animating translationZ to zero since then it'll go under the bubbles, which have
// elevation.
if (mPercentTransitionedToDot > 0.98f) {
final float percentBetween99and100 = (mPercentTransitionedToDot - 0.98f) / .02f;
final float percentShadowVisible = 1f - percentBetween99and100;
// Keep it centered.
outlineMatrix.postTranslate(
mNewDotRadius * percentBetween99and100,
mNewDotRadius * percentBetween99and100);
outlineMatrix.preScale(percentShadowVisible, percentShadowVisible);
}
outline.mPath.transform(outlineMatrix);
}
}
private float getInterpolatedRadius() {
return mNewDotRadius * mPercentTransitionedToDot
+ mCornerRadius * (1 - mPercentTransitionedToDot);
}
}