blob: d83e284b96bbbb5c4e1c0439c6f06187dd95e574 [file] [log] [blame]
/*
* Copyright (C) 2017 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.bubble;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.app.PendingIntent.CanceledException;
import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.graphics.PixelFormat;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.RippleDrawable;
import android.net.Uri;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Handler;
import android.provider.Settings;
import android.support.annotation.ColorInt;
import android.support.annotation.IntDef;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.support.v4.graphics.ColorUtils;
import android.support.v4.os.BuildCompat;
import android.support.v4.view.animation.FastOutLinearInInterpolator;
import android.support.v4.view.animation.LinearOutSlowInInterpolator;
import android.transition.TransitionManager;
import android.transition.TransitionValues;
import android.view.ContextThemeWrapper;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.MarginLayoutParams;
import android.view.ViewPropertyAnimator;
import android.view.ViewTreeObserver.OnPreDrawListener;
import android.view.WindowManager;
import android.view.WindowManager.LayoutParams;
import android.view.animation.AnticipateInterpolator;
import android.view.animation.OvershootInterpolator;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.ViewAnimator;
import com.android.bubble.BubbleInfo.Action;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.List;
/**
* Creates and manages a bubble window from information in a {@link BubbleInfo}. Before creating, be
* sure to check whether bubbles may be shown using {@link #canShowBubbles(Context)} and request
* permission if necessary ({@link #getRequestPermissionIntent(Context)} is provided for
* convenience)
*/
public class Bubble {
// This class has some odd behavior that is not immediately obvious in order to avoid jank when
// resizing. See http://go/bubble-resize for details.
// How long text should show after showText(CharSequence) is called
private static final int SHOW_TEXT_DURATION_MILLIS = 3000;
// How long the new window should show before destroying the old one during resize operations.
// This ensures the new window has had time to draw first.
private static final int WINDOW_REDRAW_DELAY_MILLIS = 50;
private static Boolean canShowBubblesForTesting = null;
private final Context context;
private final WindowManager windowManager;
private final Handler handler;
private LayoutParams windowParams;
// Initialized in factory method
@SuppressWarnings("NullableProblems")
@NonNull
private BubbleInfo currentInfo;
@Visibility private int visibility;
private boolean expanded;
private boolean textShowing;
private boolean hideAfterText;
private int collapseEndAction;
@VisibleForTesting ViewHolder viewHolder;
private ViewPropertyAnimator collapseAnimation;
private Integer overrideGravity;
private ViewPropertyAnimator exitAnimator;
private final Runnable collapseRunnable =
new Runnable() {
@Override
public void run() {
textShowing = false;
if (hideAfterText) {
// Always reset here since text shouldn't keep showing.
hideAndReset();
} else {
doResize(
() -> viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_ICON));
}
}
};
private BubbleExpansionStateListener bubbleExpansionStateListener;
@Retention(RetentionPolicy.SOURCE)
@IntDef({CollapseEnd.NOTHING, CollapseEnd.HIDE})
private @interface CollapseEnd {
int NOTHING = 0;
int HIDE = 1;
}
@Retention(RetentionPolicy.SOURCE)
@IntDef({Visibility.ENTERING, Visibility.SHOWING, Visibility.EXITING, Visibility.HIDDEN})
private @interface Visibility {
int HIDDEN = 0;
int ENTERING = 1;
int SHOWING = 2;
int EXITING = 3;
}
/** Indicate bubble expansion state. */
@Retention(RetentionPolicy.SOURCE)
@IntDef({ExpansionState.START_EXPANDING, ExpansionState.START_COLLAPSING})
public @interface ExpansionState {
// TODO(yueg): add more states when needed
int START_EXPANDING = 0;
int START_COLLAPSING = 1;
}
/**
* Determines whether bubbles can be shown based on permissions obtained. This should be checked
* before attempting to create a Bubble.
*
* @return true iff bubbles are able to be shown.
* @see Settings#canDrawOverlays(Context)
*/
public static boolean canShowBubbles(@NonNull Context context) {
return canShowBubblesForTesting != null
? canShowBubblesForTesting
: VERSION.SDK_INT < VERSION_CODES.M || Settings.canDrawOverlays(context);
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
public static void setCanShowBubblesForTesting(boolean canShowBubbles) {
canShowBubblesForTesting = canShowBubbles;
}
/** Returns an Intent to request permission to show overlays */
@NonNull
public static Intent getRequestPermissionIntent(@NonNull Context context) {
return new Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.fromParts("package", context.getPackageName(), null));
}
/** Creates instances of Bubble. The default implementation just calls the constructor. */
@VisibleForTesting
public interface BubbleFactory {
Bubble createBubble(@NonNull Context context, @NonNull Handler handler);
}
private static BubbleFactory bubbleFactory = Bubble::new;
public static Bubble createBubble(@NonNull Context context, @NonNull BubbleInfo info) {
Bubble bubble = bubbleFactory.createBubble(context, new Handler());
bubble.setBubbleInfo(info);
return bubble;
}
@VisibleForTesting
public static void setBubbleFactory(@NonNull BubbleFactory bubbleFactory) {
Bubble.bubbleFactory = bubbleFactory;
}
@VisibleForTesting
public static void resetBubbleFactory() {
Bubble.bubbleFactory = Bubble::new;
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
Bubble(@NonNull Context context, @NonNull Handler handler) {
context = new ContextThemeWrapper(context, R.style.Theme_AppCompat);
this.context = context;
this.handler = handler;
windowManager = context.getSystemService(WindowManager.class);
viewHolder = new ViewHolder(context);
}
/** Expands the main bubble menu. */
public void expand() {
if (expanded || textShowing || currentInfo.getActions().isEmpty()) {
try {
currentInfo.getPrimaryIntent().send();
} catch (CanceledException e) {
throw new RuntimeException(e);
}
return;
}
if (bubbleExpansionStateListener != null) {
bubbleExpansionStateListener.onBubbleExpansionStateChanged(ExpansionState.START_EXPANDING);
}
doResize(
() -> {
onLeftRightSwitch(isDrawingFromRight());
viewHolder.setDrawerVisibility(View.VISIBLE);
});
View expandedView = viewHolder.getExpandedView();
expandedView
.getViewTreeObserver()
.addOnPreDrawListener(
new OnPreDrawListener() {
@Override
public boolean onPreDraw() {
expandedView.getViewTreeObserver().removeOnPreDrawListener(this);
expandedView.setTranslationX(
isDrawingFromRight() ? expandedView.getWidth() : -expandedView.getWidth());
expandedView
.animate()
.setInterpolator(new LinearOutSlowInInterpolator())
.translationX(0);
return false;
}
});
setFocused(true);
expanded = true;
}
/**
* Make the bubble visible. Will show a short entrance animation as it enters. If the bubble is
* already showing this method does nothing.
*/
public void show() {
if (collapseEndAction == CollapseEnd.HIDE) {
// If show() was called while collapsing, make sure we don't hide after.
collapseEndAction = CollapseEnd.NOTHING;
}
if (visibility == Visibility.SHOWING || visibility == Visibility.ENTERING) {
return;
}
hideAfterText = false;
if (windowParams == null) {
// Apps targeting O+ must use TYPE_APPLICATION_OVERLAY, which is not available prior to O.
@SuppressWarnings("deprecation")
@SuppressLint("InlinedApi")
int type =
BuildCompat.isAtLeastO()
? LayoutParams.TYPE_APPLICATION_OVERLAY
: LayoutParams.TYPE_PHONE;
windowParams =
new LayoutParams(
type,
LayoutParams.FLAG_NOT_TOUCH_MODAL
| LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
| LayoutParams.FLAG_NOT_FOCUSABLE
| LayoutParams.FLAG_LAYOUT_NO_LIMITS,
PixelFormat.TRANSLUCENT);
windowParams.gravity = Gravity.TOP | Gravity.LEFT;
windowParams.x = context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_x);
windowParams.y = currentInfo.getStartingYPosition();
windowParams.height = LayoutParams.WRAP_CONTENT;
windowParams.width = LayoutParams.WRAP_CONTENT;
}
if (exitAnimator != null) {
exitAnimator.cancel();
exitAnimator = null;
} else {
windowManager.addView(viewHolder.getRoot(), windowParams);
viewHolder.getPrimaryButton().setScaleX(0);
viewHolder.getPrimaryButton().setScaleY(0);
}
viewHolder.setChildClickable(true);
visibility = Visibility.ENTERING;
viewHolder
.getPrimaryButton()
.animate()
.setInterpolator(new OvershootInterpolator())
.scaleX(1)
.scaleY(1)
.withEndAction(() -> visibility = Visibility.SHOWING)
.start();
updatePrimaryIconAnimation();
}
/** Hide the bubble. */
public void hide() {
if (hideAfterText) {
// hideAndReset() will be called after showing text, do nothing here.
return;
}
hideHelper(this::defaultAfterHidingAnimation);
}
/** Hide the bubble and reset {@viewHolder} to initial state */
public void hideAndReset() {
hideHelper(
() -> {
defaultAfterHidingAnimation();
reset();
});
}
/** Returns whether the bubble is currently visible */
public boolean isVisible() {
return visibility == Visibility.SHOWING
|| visibility == Visibility.ENTERING
|| visibility == Visibility.EXITING;
}
/**
* Set the info for this Bubble to display
*
* @param bubbleInfo the BubbleInfo to display in this Bubble.
*/
public void setBubbleInfo(@NonNull BubbleInfo bubbleInfo) {
currentInfo = bubbleInfo;
update();
}
/**
* Update the state and behavior of actions.
*
* @param actions the new state of the bubble's actions
*/
public void updateActions(@NonNull List<Action> actions) {
currentInfo = BubbleInfo.from(currentInfo).setActions(actions).build();
updateButtonStates();
}
/** Returns the currently displayed BubbleInfo */
public BubbleInfo getBubbleInfo() {
return currentInfo;
}
/**
* Display text in the main bubble. The bubble's drawer is not expandable while text is showing,
* and the drawer will be closed if already open.
*
* @param text the text to display to the user
*/
public void showText(@NonNull CharSequence text) {
textShowing = true;
if (expanded) {
startCollapse(CollapseEnd.NOTHING);
doShowText(text);
} else {
// Need to transition from old bounds to new bounds manually
ChangeOnScreenBounds transition = new ChangeOnScreenBounds();
// Prepare and capture start values
TransitionValues startValues = new TransitionValues();
startValues.view = viewHolder.getPrimaryButton();
transition.addTarget(startValues.view);
transition.captureStartValues(startValues);
doResize(
() -> {
doShowText(text);
// Hide the text so we can animate it in
viewHolder.getPrimaryText().setAlpha(0);
ViewAnimator primaryButton = viewHolder.getPrimaryButton();
// Cancel the automatic transition scheduled in doShowText
TransitionManager.endTransitions((ViewGroup) primaryButton.getParent());
primaryButton
.getViewTreeObserver()
.addOnPreDrawListener(
new OnPreDrawListener() {
@Override
public boolean onPreDraw() {
primaryButton.getViewTreeObserver().removeOnPreDrawListener(this);
// Prepare and capture end values, always use the size of primaryText since
// its invisibility makes primaryButton smaller than expected
TransitionValues endValues = new TransitionValues();
endValues.values.put(
ChangeOnScreenBounds.PROPNAME_WIDTH,
viewHolder.getPrimaryText().getWidth());
endValues.values.put(
ChangeOnScreenBounds.PROPNAME_HEIGHT,
viewHolder.getPrimaryText().getHeight());
endValues.view = primaryButton;
transition.addTarget(endValues.view);
transition.captureEndValues(endValues);
// animate the primary button bounds change
Animator bounds =
transition.createAnimator(primaryButton, startValues, endValues);
// Animate the text in
Animator alpha =
ObjectAnimator.ofFloat(viewHolder.getPrimaryText(), View.ALPHA, 1f);
AnimatorSet set = new AnimatorSet();
set.play(bounds).before(alpha);
set.start();
return false;
}
});
});
}
handler.removeCallbacks(collapseRunnable);
handler.postDelayed(collapseRunnable, SHOW_TEXT_DURATION_MILLIS);
}
public void setBubbleExpansionStateListener(
BubbleExpansionStateListener bubbleExpansionStateListener) {
this.bubbleExpansionStateListener = bubbleExpansionStateListener;
}
@Nullable
Integer getGravityOverride() {
return overrideGravity;
}
void onMoveStart() {
startCollapse(CollapseEnd.NOTHING);
viewHolder
.getPrimaryButton()
.animate()
.translationZ(
context.getResources().getDimensionPixelOffset(R.dimen.bubble_move_elevation_change));
}
void onMoveFinish() {
viewHolder.getPrimaryButton().animate().translationZ(0);
// If it's GONE, no resize is necessary. If it's VISIBLE, it will get cleaned up when the
// collapse animation finishes
if (viewHolder.getExpandedView().getVisibility() == View.INVISIBLE) {
doResize(null);
}
}
void primaryButtonClick() {
expand();
}
void onLeftRightSwitch(boolean onRight) {
if (viewHolder.isMoving()) {
if (viewHolder.getExpandedView().getVisibility() == View.GONE) {
// If the drawer is not part of the layout we don't need to do anything. Layout flips will
// happen if necessary when opening the drawer.
return;
}
}
viewHolder
.getRoot()
.setLayoutDirection(onRight ? View.LAYOUT_DIRECTION_RTL : View.LAYOUT_DIRECTION_LTR);
View primaryContainer = viewHolder.getRoot().findViewById(R.id.bubble_primary_container);
ViewGroup.LayoutParams layoutParams = primaryContainer.getLayoutParams();
((FrameLayout.LayoutParams) layoutParams).gravity = onRight ? Gravity.RIGHT : Gravity.LEFT;
primaryContainer.setLayoutParams(layoutParams);
viewHolder
.getExpandedView()
.setBackgroundResource(
onRight
? R.drawable.bubble_background_pill_rtl
: R.drawable.bubble_background_pill_ltr);
}
LayoutParams getWindowParams() {
return windowParams;
}
View getRootView() {
return viewHolder.getRoot();
}
/**
* Hide the bubble if visible. Will run a short exit animation and before hiding, and {@code
* afterHiding} after hiding. If the bubble is currently showing text, will hide after the text is
* done displaying. If the bubble is not visible this method does nothing.
*/
private void hideHelper(Runnable afterHiding) {
if (visibility == Visibility.HIDDEN || visibility == Visibility.EXITING) {
return;
}
// Make bubble non clickable to prevent further buggy actions
viewHolder.setChildClickable(false);
if (textShowing) {
hideAfterText = true;
return;
}
if (collapseAnimation != null) {
collapseEndAction = CollapseEnd.HIDE;
return;
}
if (expanded) {
startCollapse(CollapseEnd.HIDE);
return;
}
visibility = Visibility.EXITING;
exitAnimator =
viewHolder
.getPrimaryButton()
.animate()
.setInterpolator(new AnticipateInterpolator())
.scaleX(0)
.scaleY(0)
.withEndAction(afterHiding);
exitAnimator.start();
}
private void reset() {
viewHolder = new ViewHolder(viewHolder.getRoot().getContext());
update();
}
private void update() {
RippleDrawable backgroundRipple =
(RippleDrawable)
context.getResources().getDrawable(R.drawable.bubble_ripple_circle, context.getTheme());
int primaryTint =
ColorUtils.compositeColors(
context.getColor(R.color.bubble_primary_background_darken),
currentInfo.getPrimaryColor());
backgroundRipple.getDrawable(0).setTint(primaryTint);
viewHolder.getPrimaryButton().setBackground(backgroundRipple);
setBackgroundDrawable(viewHolder.getFirstButton(), primaryTint);
setBackgroundDrawable(viewHolder.getSecondButton(), primaryTint);
setBackgroundDrawable(viewHolder.getThirdButton(), primaryTint);
int numButtons = currentInfo.getActions().size();
viewHolder.getThirdButton().setVisibility(numButtons < 3 ? View.GONE : View.VISIBLE);
viewHolder.getSecondButton().setVisibility(numButtons < 2 ? View.GONE : View.VISIBLE);
viewHolder.getPrimaryIcon().setImageIcon(currentInfo.getPrimaryIcon());
updatePrimaryIconAnimation();
viewHolder
.getExpandedView()
.setBackgroundTintList(ColorStateList.valueOf(currentInfo.getPrimaryColor()));
updateButtonStates();
}
private void updatePrimaryIconAnimation() {
Drawable drawable = viewHolder.getPrimaryIcon().getDrawable();
if (drawable instanceof Animatable) {
if (isVisible()) {
((Animatable) drawable).start();
} else {
((Animatable) drawable).stop();
}
}
}
private void setBackgroundDrawable(CheckableImageButton view, @ColorInt int color) {
RippleDrawable itemRipple =
(RippleDrawable)
context
.getResources()
.getDrawable(R.drawable.bubble_ripple_checkable_circle, context.getTheme());
itemRipple.getDrawable(0).setTint(color);
view.setBackground(itemRipple);
}
private void updateButtonStates() {
int numButtons = currentInfo.getActions().size();
if (numButtons >= 1) {
configureButton(currentInfo.getActions().get(0), viewHolder.getFirstButton());
if (numButtons >= 2) {
configureButton(currentInfo.getActions().get(1), viewHolder.getSecondButton());
if (numButtons >= 3) {
configureButton(currentInfo.getActions().get(2), viewHolder.getThirdButton());
}
}
}
}
private void doShowText(@NonNull CharSequence text) {
TransitionManager.beginDelayedTransition((ViewGroup) viewHolder.getPrimaryButton().getParent());
viewHolder.getPrimaryText().setText(text);
viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_TEXT);
}
private void configureButton(Action action, CheckableImageButton button) {
action
.getIcon()
.loadDrawableAsync(
context,
d -> {
button.setImageIcon(action.getIcon());
button.setContentDescription(action.getName());
button.setChecked(action.isChecked());
button.setEnabled(action.isEnabled());
},
handler);
button.setOnClickListener(v -> doAction(action));
}
private void doAction(Action action) {
try {
action.getIntent().send();
} catch (CanceledException e) {
throw new RuntimeException(e);
}
}
private void doResize(@Nullable Runnable operation) {
// If we're resizing on the right side of the screen, there is an implicit move operation
// necessary. The WindowManager does not sync the move and resize operations, so serious jank
// would occur. To fix this, instead of resizing the window, we create a new one and destroy
// the old one. There is a short delay before destroying the old view to ensure the new one has
// had time to draw.
ViewHolder oldViewHolder = viewHolder;
if (isDrawingFromRight()) {
viewHolder = new ViewHolder(oldViewHolder.getRoot().getContext());
update();
viewHolder
.getPrimaryButton()
.setDisplayedChild(oldViewHolder.getPrimaryButton().getDisplayedChild());
viewHolder.getPrimaryText().setText(oldViewHolder.getPrimaryText().getText());
}
if (operation != null) {
operation.run();
}
if (isDrawingFromRight()) {
swapViewHolders(oldViewHolder);
}
}
private void swapViewHolders(ViewHolder oldViewHolder) {
oldViewHolder.getShadowProvider().setVisibility(View.GONE);
ViewGroup root = viewHolder.getRoot();
windowManager.addView(root, windowParams);
root.getViewTreeObserver()
.addOnPreDrawListener(
new OnPreDrawListener() {
@Override
public boolean onPreDraw() {
root.getViewTreeObserver().removeOnPreDrawListener(this);
// Wait a bit before removing the old view; make sure the new one has drawn over it.
handler.postDelayed(
() -> windowManager.removeView(oldViewHolder.getRoot()),
WINDOW_REDRAW_DELAY_MILLIS);
return true;
}
});
}
private void startCollapse(@CollapseEnd int endAction) {
View expandedView = viewHolder.getExpandedView();
if (expandedView.getVisibility() != View.VISIBLE || collapseAnimation != null) {
// Drawer is already collapsed or animation is running.
return;
}
overrideGravity = isDrawingFromRight() ? Gravity.RIGHT : Gravity.LEFT;
setFocused(false);
if (collapseEndAction == CollapseEnd.NOTHING) {
collapseEndAction = endAction;
}
if (bubbleExpansionStateListener != null && collapseEndAction == CollapseEnd.NOTHING) {
bubbleExpansionStateListener.onBubbleExpansionStateChanged(ExpansionState.START_COLLAPSING);
}
collapseAnimation =
expandedView
.animate()
.translationX(isDrawingFromRight() ? expandedView.getWidth() : -expandedView.getWidth())
.setInterpolator(new FastOutLinearInInterpolator())
.withEndAction(
() -> {
collapseAnimation = null;
expanded = false;
if (textShowing) {
// Will do resize once the text is done.
return;
}
// Hide the drawer and resize if possible.
viewHolder.setDrawerVisibility(View.INVISIBLE);
if (!viewHolder.isMoving() || !isDrawingFromRight()) {
doResize(() -> viewHolder.setDrawerVisibility(View.GONE));
}
// If this collapse was to come before a hide, do it now.
if (collapseEndAction == CollapseEnd.HIDE) {
hide();
}
collapseEndAction = CollapseEnd.NOTHING;
// Resume normal gravity after any resizing is done.
handler.postDelayed(
() -> {
overrideGravity = null;
if (!viewHolder.isMoving()) {
viewHolder.undoGravityOverride();
}
},
// Need to wait twice as long for resize and layout
WINDOW_REDRAW_DELAY_MILLIS * 2);
});
}
private boolean isDrawingFromRight() {
return (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT;
}
private void setFocused(boolean focused) {
if (focused) {
windowParams.flags &= ~LayoutParams.FLAG_NOT_FOCUSABLE;
} else {
windowParams.flags |= LayoutParams.FLAG_NOT_FOCUSABLE;
}
windowManager.updateViewLayout(getRootView(), windowParams);
}
private void defaultAfterHidingAnimation() {
exitAnimator = null;
windowManager.removeView(viewHolder.getRoot());
visibility = Visibility.HIDDEN;
updatePrimaryIconAnimation();
}
@VisibleForTesting
class ViewHolder {
public static final int CHILD_INDEX_ICON = 0;
public static final int CHILD_INDEX_TEXT = 1;
private MoveHandler moveHandler;
private final WindowRoot root;
private final ViewAnimator primaryButton;
private final ImageView primaryIcon;
private final TextView primaryText;
private final CheckableImageButton firstButton;
private final CheckableImageButton secondButton;
private final CheckableImageButton thirdButton;
private final View expandedView;
private final View shadowProvider;
public ViewHolder(Context context) {
// Window root is not in the layout file so that the inflater has a view to inflate into
this.root = new WindowRoot(context);
LayoutInflater inflater = LayoutInflater.from(root.getContext());
View contentView = inflater.inflate(R.layout.bubble_base, root, true);
expandedView = contentView.findViewById(R.id.bubble_expanded_layout);
primaryButton = contentView.findViewById(R.id.bubble_button_primary);
primaryIcon = contentView.findViewById(R.id.bubble_icon_primary);
primaryText = contentView.findViewById(R.id.bubble_text);
shadowProvider = contentView.findViewById(R.id.bubble_drawer_shadow_provider);
firstButton = contentView.findViewById(R.id.bubble_icon_first);
secondButton = contentView.findViewById(R.id.bubble_icon_second);
thirdButton = contentView.findViewById(R.id.bubble_icon_third);
root.setOnBackPressedListener(
() -> {
if (visibility == Visibility.SHOWING && expanded) {
startCollapse(CollapseEnd.NOTHING);
return true;
}
return false;
});
root.setOnConfigurationChangedListener(
(configuration) -> {
// The values in the current MoveHandler may be stale, so replace it. Then ensure the
// Window is in bounds
moveHandler = new MoveHandler(primaryButton, Bubble.this);
moveHandler.snapToBounds();
});
root.setOnTouchListener(
(v, event) -> {
if (expanded && event.getActionMasked() == MotionEvent.ACTION_OUTSIDE) {
startCollapse(CollapseEnd.NOTHING);
return true;
}
return false;
});
expandedView
.getViewTreeObserver()
.addOnDrawListener(
() -> {
int translationX = (int) expandedView.getTranslationX();
int parentOffset =
((MarginLayoutParams) ((ViewGroup) expandedView.getParent()).getLayoutParams())
.leftMargin;
if (isDrawingFromRight()) {
int maxLeft =
shadowProvider.getRight()
- context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
shadowProvider.setLeft(
Math.min(maxLeft, expandedView.getLeft() + translationX + parentOffset));
} else {
int minRight =
shadowProvider.getLeft()
+ context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
shadowProvider.setRight(
Math.max(minRight, expandedView.getRight() + translationX + parentOffset));
}
});
moveHandler = new MoveHandler(primaryButton, Bubble.this);
}
private void setChildClickable(boolean clickable) {
firstButton.setClickable(clickable);
secondButton.setClickable(clickable);
thirdButton.setClickable(clickable);
primaryButton.setOnTouchListener(clickable ? moveHandler : null);
}
public ViewGroup getRoot() {
return root;
}
public ViewAnimator getPrimaryButton() {
return primaryButton;
}
public ImageView getPrimaryIcon() {
return primaryIcon;
}
public TextView getPrimaryText() {
return primaryText;
}
public CheckableImageButton getFirstButton() {
return firstButton;
}
public CheckableImageButton getSecondButton() {
return secondButton;
}
public CheckableImageButton getThirdButton() {
return thirdButton;
}
public View getExpandedView() {
return expandedView;
}
public View getShadowProvider() {
return shadowProvider;
}
public void setDrawerVisibility(int visibility) {
expandedView.setVisibility(visibility);
shadowProvider.setVisibility(visibility);
}
public boolean isMoving() {
return moveHandler.isMoving();
}
public void undoGravityOverride() {
moveHandler.undoGravityOverride();
}
}
/** Listener for bubble expansion state change. */
public interface BubbleExpansionStateListener {
void onBubbleExpansionStateChanged(@ExpansionState int expansionState);
}
}