summaryrefslogtreecommitdiff
path: root/java/com/android/dialershared/bubble
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/android/dialershared/bubble')
-rw-r--r--java/com/android/dialershared/bubble/AndroidManifest.xml22
-rw-r--r--java/com/android/dialershared/bubble/Bubble.java667
-rw-r--r--java/com/android/dialershared/bubble/BubbleInfo.java116
-rw-r--r--java/com/android/dialershared/bubble/ChangeOnScreenBounds.java166
-rw-r--r--java/com/android/dialershared/bubble/CheckableImageButton.java101
-rw-r--r--java/com/android/dialershared/bubble/MoveHandler.java250
-rw-r--r--java/com/android/dialershared/bubble/WindowRoot.java55
-rw-r--r--java/com/android/dialershared/bubble/res/color/bubble_checkable_mask.xml21
-rw-r--r--java/com/android/dialershared/bubble/res/color/bubble_icon_tint_states.xml21
-rw-r--r--java/com/android/dialershared/bubble/res/drawable/bubble_background_pill_ltr.xml23
-rw-r--r--java/com/android/dialershared/bubble/res/drawable/bubble_background_pill_rtl.xml23
-rw-r--r--java/com/android/dialershared/bubble/res/drawable/bubble_ripple_checkable_circle.xml30
-rw-r--r--java/com/android/dialershared/bubble/res/drawable/bubble_ripple_circle.xml26
-rw-r--r--java/com/android/dialershared/bubble/res/layout/bubble_base.xml116
-rw-r--r--java/com/android/dialershared/bubble/res/values/colors.xml20
-rw-r--r--java/com/android/dialershared/bubble/res/values/values.xml27
16 files changed, 1684 insertions, 0 deletions
diff --git a/java/com/android/dialershared/bubble/AndroidManifest.xml b/java/com/android/dialershared/bubble/AndroidManifest.xml
new file mode 100644
index 000000000..1a94aafc1
--- /dev/null
+++ b/java/com/android/dialershared/bubble/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<!--
+ ~ 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
+ -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.dialershared.bubble">
+
+ <uses-sdk android:minSdkVersion="21"/>
+ <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+</manifest>
diff --git a/java/com/android/dialershared/bubble/Bubble.java b/java/com/android/dialershared/bubble/Bubble.java
new file mode 100644
index 000000000..3eb88aa22
--- /dev/null
+++ b/java/com/android/dialershared/bubble/Bubble.java
@@ -0,0 +1,667 @@
+/*
+ * 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.dialershared.bubble;
+
+import android.animation.Animator;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.animation.PropertyValuesHolder;
+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.RippleDrawable;
+import android.net.Uri;
+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.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.dialershared.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 {
+
+ // 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 LayoutParams windowParams;
+
+ // Initialized in factory method
+ @SuppressWarnings("NullableProblems")
+ @NonNull
+ private BubbleInfo currentInfo;
+
+ private boolean isShowing;
+ private boolean expanded;
+ private boolean textShowing;
+ private boolean hideAfterText;
+
+ private final Handler handler = new Handler();
+
+ private ViewHolder viewHolder;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({CollapseEnd.NOTHING, CollapseEnd.HIDE})
+ private @interface CollapseEnd {
+ int NOTHING = 0;
+ int HIDE = 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
+ : 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,
+ new Uri.Builder().scheme("package").fragment(context.getPackageName()).build());
+ }
+
+ /** Creates instances of Bubble. The default implementation just calls the constructor. */
+ @VisibleForTesting
+ public interface BubbleFactory {
+ Bubble createBubble(@NonNull Context context);
+ }
+
+ private static BubbleFactory bubbleFactory = Bubble::new;
+
+ public static Bubble createBubble(@NonNull Context context, @NonNull BubbleInfo info) {
+ Bubble bubble = bubbleFactory.createBubble(context);
+ bubble.setBubbleInfo(info);
+ return bubble;
+ }
+
+ @VisibleForTesting
+ public static void setBubbleFactory(@NonNull BubbleFactory bubbleFactory) {
+ Bubble.bubbleFactory = bubbleFactory;
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ Bubble(@NonNull Context context) {
+ context = new ContextThemeWrapper(context, R.style.Theme_AppCompat);
+ this.context = context;
+ windowManager = context.getSystemService(WindowManager.class);
+
+ viewHolder = new ViewHolder(context);
+ }
+
+ /**
+ * 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 (isShowing) {
+ 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,
+ PixelFormat.TRANSLUCENT);
+ windowParams.gravity = Gravity.TOP | Gravity.LEFT;
+ windowParams.x =
+ context.getResources().getDimensionPixelOffset(R.dimen.bubble_initial_offset_x);
+ windowParams.y =
+ context.getResources().getDimensionPixelOffset(R.dimen.bubble_initial_offset_y);
+ windowParams.height = LayoutParams.WRAP_CONTENT;
+ windowParams.width = LayoutParams.WRAP_CONTENT;
+ }
+
+ windowManager.addView(viewHolder.getRoot(), windowParams);
+ ObjectAnimator showAnimator =
+ ObjectAnimator.ofPropertyValuesHolder(
+ viewHolder.getPrimaryButton(),
+ PropertyValuesHolder.ofFloat(View.SCALE_X, 0, 1),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, 0, 1));
+ showAnimator.setInterpolator(new OvershootInterpolator());
+ showAnimator.start();
+ isShowing = true;
+ }
+
+ /**
+ * Hide the button if visible. Will run a short exit animation before 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.
+ */
+ public void hide() {
+ if (!isShowing) {
+ return;
+ }
+
+ if (textShowing) {
+ hideAfterText = true;
+ return;
+ }
+
+ if (expanded) {
+ startCollapse(CollapseEnd.HIDE);
+ return;
+ }
+
+ viewHolder
+ .getPrimaryButton()
+ .animate()
+ .setInterpolator(new AnticipateInterpolator())
+ .scaleX(0)
+ .scaleY(0)
+ .withEndAction(
+ () -> {
+ windowManager.removeView(viewHolder.getRoot());
+ isShowing = false;
+ })
+ .start();
+ }
+
+ /** Returns whether the bubble is currently visible */
+ public boolean isShowing() {
+ return isShowing;
+ }
+
+ /**
+ * 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
+ TransitionValues endValues = new TransitionValues();
+ 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(null);
+ handler.postDelayed(
+ () -> {
+ textShowing = false;
+ if (hideAfterText) {
+ hide();
+ } else {
+ doResize(
+ () -> viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_ICON));
+ }
+ },
+ SHOW_TEXT_DURATION_MILLIS);
+ }
+
+ 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);
+ }
+
+ void primaryButtonClick() {
+ if (expanded || textShowing || currentInfo.getActions().isEmpty()) {
+ try {
+ currentInfo.getPrimaryAction().send();
+ } catch (CanceledException e) {
+ throw new RuntimeException(e);
+ }
+ return;
+ }
+
+ boolean onRight = (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT;
+ doResize(
+ () -> {
+ onLeftRightSwitch(onRight);
+ viewHolder.getExpandedView().setVisibility(View.VISIBLE);
+ });
+ View expandedView = viewHolder.getExpandedView();
+ expandedView
+ .getViewTreeObserver()
+ .addOnPreDrawListener(
+ new OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ expandedView.getViewTreeObserver().removeOnPreDrawListener(this);
+ expandedView.setTranslationX(
+ onRight ? expandedView.getWidth() : -expandedView.getWidth());
+ expandedView
+ .animate()
+ .setInterpolator(new LinearOutSlowInInterpolator())
+ .translationX(0);
+ return false;
+ }
+ });
+ setFocused(true);
+ expanded = true;
+ }
+
+ void onLeftRightSwitch(boolean onRight) {
+ 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();
+ }
+
+ 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());
+
+ viewHolder
+ .getExpandedView()
+ .setBackgroundTintList(ColorStateList.valueOf(currentInfo.getPrimaryColor()));
+
+ updateButtonStates();
+ }
+
+ 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.getAction().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.
+ boolean onRight = (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT;
+ ViewHolder oldViewHolder = viewHolder;
+ if (onRight) {
+ 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 (onRight) {
+ swapViewHolders(oldViewHolder);
+ }
+ }
+
+ private void swapViewHolders(ViewHolder oldViewHolder) {
+ 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 ViewPropertyAnimator startCollapse(@CollapseEnd int collapseEndAction) {
+ setFocused(false);
+ boolean onRight = (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT;
+ View expandedView = viewHolder.getExpandedView();
+ return expandedView
+ .animate()
+ .translationX(onRight ? expandedView.getWidth() : -expandedView.getWidth())
+ .setInterpolator(new FastOutLinearInInterpolator())
+ .withEndAction(
+ () -> {
+ expanded = false;
+ if (collapseEndAction == CollapseEnd.HIDE) {
+ hide();
+ } else if (!textShowing) {
+ // Don't swap the window while the user is moving it, even if we're on the right.
+ // The movement will help hide the jank of the resize.
+ boolean swapWindow = onRight && !viewHolder.isMoving();
+ if (swapWindow) {
+ // We don't actually need to set the drawer to GONE since in the new window it
+ // will already be GONE. Just do the resize operation.
+ doResize(null);
+ } else {
+ expandedView.setVisibility(View.GONE);
+ }
+ }
+ });
+ }
+
+ 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 class ViewHolder {
+
+ public static final int CHILD_INDEX_ICON = 0;
+ public static final int CHILD_INDEX_TEXT = 1;
+
+ private final 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;
+
+ 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);
+
+ 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 (isShowing && expanded) {
+ startCollapse(CollapseEnd.NOTHING);
+ return true;
+ }
+ return false;
+ });
+ root.setOnTouchListener(
+ (v, event) -> {
+ if (expanded && event.getActionMasked() == MotionEvent.ACTION_OUTSIDE) {
+ startCollapse(CollapseEnd.NOTHING);
+ return true;
+ }
+ return false;
+ });
+ moveHandler = new MoveHandler(primaryButton, Bubble.this);
+ }
+
+ 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 boolean isMoving() {
+ return moveHandler.isMoving();
+ }
+ }
+}
diff --git a/java/com/android/dialershared/bubble/BubbleInfo.java b/java/com/android/dialershared/bubble/BubbleInfo.java
new file mode 100644
index 000000000..52417ae7b
--- /dev/null
+++ b/java/com/android/dialershared/bubble/BubbleInfo.java
@@ -0,0 +1,116 @@
+/*
+ * 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.dialershared.bubble;
+
+import android.app.PendingIntent;
+import android.graphics.drawable.Icon;
+import android.support.annotation.ColorInt;
+import android.support.annotation.NonNull;
+import com.google.auto.value.AutoValue;
+import java.util.Collections;
+import java.util.List;
+
+/** Info for displaying a {@link Bubble} */
+@AutoValue
+public abstract class BubbleInfo {
+ @ColorInt
+ public abstract int getPrimaryColor();
+
+ @NonNull
+ public abstract Icon getPrimaryIcon();
+
+ @NonNull
+ public abstract PendingIntent getPrimaryAction();
+
+ @NonNull
+ public abstract List<Action> getActions();
+
+ public static Builder builder() {
+ return new AutoValue_BubbleInfo.Builder().setActions(Collections.emptyList());
+ }
+
+ public static Builder from(@NonNull BubbleInfo bubbleInfo) {
+ return builder()
+ .setPrimaryAction(bubbleInfo.getPrimaryAction())
+ .setPrimaryColor(bubbleInfo.getPrimaryColor())
+ .setPrimaryIcon(bubbleInfo.getPrimaryIcon())
+ .setActions(bubbleInfo.getActions());
+ }
+
+ /** Builder for {@link BubbleInfo} */
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ public abstract Builder setPrimaryColor(@ColorInt int primaryColor);
+
+ public abstract Builder setPrimaryIcon(@NonNull Icon primaryIcon);
+
+ public abstract Builder setPrimaryAction(@NonNull PendingIntent primaryAction);
+
+ public abstract Builder setActions(List<Action> actions);
+
+ public abstract BubbleInfo build();
+ }
+
+ /** Represents actions to be shown in the bubble when expanded */
+ @AutoValue
+ public abstract static class Action {
+
+ @NonNull
+ public abstract Icon getIcon();
+
+ @NonNull
+ public abstract CharSequence getName();
+
+ @NonNull
+ public abstract PendingIntent getAction();
+
+ public abstract boolean isEnabled();
+
+ public abstract boolean isChecked();
+
+ public static Builder builder() {
+ return new AutoValue_BubbleInfo_Action.Builder().setEnabled(true).setChecked(false);
+ }
+
+ public static Builder from(@NonNull Action action) {
+ return builder()
+ .setAction(action.getAction())
+ .setChecked(action.isChecked())
+ .setEnabled(action.isEnabled())
+ .setName(action.getName())
+ .setIcon(action.getIcon());
+ }
+
+ /** Builder for {@link Action} */
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ public abstract Builder setIcon(@NonNull Icon icon);
+
+ public abstract Builder setName(@NonNull CharSequence name);
+
+ public abstract Builder setAction(@NonNull PendingIntent action);
+
+ public abstract Builder setEnabled(boolean enabled);
+
+ public abstract Builder setChecked(boolean checked);
+
+ public abstract Action build();
+ }
+ }
+}
diff --git a/java/com/android/dialershared/bubble/ChangeOnScreenBounds.java b/java/com/android/dialershared/bubble/ChangeOnScreenBounds.java
new file mode 100644
index 000000000..4da6a3561
--- /dev/null
+++ b/java/com/android/dialershared/bubble/ChangeOnScreenBounds.java
@@ -0,0 +1,166 @@
+/*
+ * 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.dialershared.bubble;
+
+import android.animation.Animator;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.graphics.Path;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.transition.Transition;
+import android.transition.TransitionValues;
+import android.util.Property;
+import android.view.View;
+import android.view.ViewGroup;
+
+/** Similar to {@link android.transition.ChangeBounds ChangeBounds} but works across windows */
+public class ChangeOnScreenBounds extends Transition {
+
+ private static final String PROPNAME_BOUNDS = "bubble:changeScreenBounds:bounds";
+ private static final String PROPNAME_SCREEN_X = "bubble:changeScreenBounds:screenX";
+ private static final String PROPNAME_SCREEN_Y = "bubble:changeScreenBounds:screenY";
+
+ private static final Property<ViewBounds, PointF> TOP_LEFT_PROPERTY =
+ new Property<ViewBounds, PointF>(PointF.class, "topLeft") {
+ @Override
+ public void set(ViewBounds viewBounds, PointF topLeft) {
+ viewBounds.setTopLeft(topLeft);
+ }
+
+ @Override
+ public PointF get(ViewBounds viewBounds) {
+ return null;
+ }
+ };
+
+ private static final Property<ViewBounds, PointF> BOTTOM_RIGHT_PROPERTY =
+ new Property<ViewBounds, PointF>(PointF.class, "bottomRight") {
+ @Override
+ public void set(ViewBounds viewBounds, PointF bottomRight) {
+ viewBounds.setBottomRight(bottomRight);
+ }
+
+ @Override
+ public PointF get(ViewBounds viewBounds) {
+ return null;
+ }
+ };
+ private final int[] tempLocation = new int[2];
+
+ @Override
+ public void captureStartValues(TransitionValues transitionValues) {
+ captureValues(transitionValues);
+ }
+
+ @Override
+ public void captureEndValues(TransitionValues transitionValues) {
+ captureValues(transitionValues);
+ }
+
+ private void captureValues(TransitionValues values) {
+ View view = values.view;
+
+ if (view.isLaidOut() || view.getWidth() != 0 || view.getHeight() != 0) {
+ values.values.put(
+ PROPNAME_BOUNDS,
+ new Rect(view.getLeft(), view.getTop(), view.getRight(), view.getBottom()));
+ values.view.getLocationOnScreen(tempLocation);
+ values.values.put(PROPNAME_SCREEN_X, tempLocation[0]);
+ values.values.put(PROPNAME_SCREEN_Y, tempLocation[1]);
+ }
+ }
+
+ @Override
+ public Animator createAnimator(
+ ViewGroup sceneRoot, TransitionValues startValues, TransitionValues endValues) {
+ Rect startBounds = (Rect) startValues.values.get(PROPNAME_BOUNDS);
+
+ // Offset the startBounds by the difference in screen position
+ int startScreenX = (Integer) startValues.values.get(PROPNAME_SCREEN_X);
+ int startScreenY = (Integer) startValues.values.get(PROPNAME_SCREEN_Y);
+ int endScreenX = (Integer) endValues.values.get(PROPNAME_SCREEN_X);
+ int endScreenY = (Integer) endValues.values.get(PROPNAME_SCREEN_Y);
+ startBounds.offset(startScreenX - endScreenX, startScreenY - endScreenY);
+
+ Rect endBounds = (Rect) endValues.values.get(PROPNAME_BOUNDS);
+ final int startLeft = startBounds.left;
+ final int endLeft = endBounds.left;
+ final int startTop = startBounds.top;
+ final int endTop = endBounds.top;
+ final int startRight = startBounds.right;
+ final int endRight = endBounds.right;
+ final int startBottom = startBounds.bottom;
+ final int endBottom = endBounds.bottom;
+ ViewBounds viewBounds = new ViewBounds(endValues.view);
+ viewBounds.setTopLeft(new PointF(startLeft, startTop));
+ viewBounds.setBottomRight(new PointF(startRight, startBottom));
+
+ // Animate the top left and bottom right corners along a path
+ Path topLeftPath = getPathMotion().getPath(startLeft, startTop, endLeft, endTop);
+ ObjectAnimator topLeftAnimator =
+ ObjectAnimator.ofObject(viewBounds, TOP_LEFT_PROPERTY, null, topLeftPath);
+
+ Path bottomRightPath = getPathMotion().getPath(startRight, startBottom, endRight, endBottom);
+ ObjectAnimator bottomRightAnimator =
+ ObjectAnimator.ofObject(viewBounds, BOTTOM_RIGHT_PROPERTY, null, bottomRightPath);
+ AnimatorSet set = new AnimatorSet();
+ set.playTogether(topLeftAnimator, bottomRightAnimator);
+ return set;
+ }
+
+ private static class ViewBounds {
+ private int left;
+ private int top;
+ private int right;
+ private int bottom;
+ private final View view;
+ private int topLeftCalls;
+ private int bottomRightCalls;
+
+ public ViewBounds(View view) {
+ this.view = view;
+ }
+
+ public void setTopLeft(PointF topLeft) {
+ left = Math.round(topLeft.x);
+ top = Math.round(topLeft.y);
+ topLeftCalls++;
+ if (topLeftCalls == bottomRightCalls) {
+ updateLeftTopRightBottom();
+ }
+ }
+
+ public void setBottomRight(PointF bottomRight) {
+ right = Math.round(bottomRight.x);
+ bottom = Math.round(bottomRight.y);
+ bottomRightCalls++;
+ if (topLeftCalls == bottomRightCalls) {
+ updateLeftTopRightBottom();
+ }
+ }
+
+ private void updateLeftTopRightBottom() {
+ view.setLeft(left);
+ view.setTop(top);
+ view.setRight(right);
+ view.setBottom(bottom);
+ topLeftCalls = 0;
+ bottomRightCalls = 0;
+ }
+ }
+}
diff --git a/java/com/android/dialershared/bubble/CheckableImageButton.java b/java/com/android/dialershared/bubble/CheckableImageButton.java
new file mode 100644
index 000000000..7a5a432a6
--- /dev/null
+++ b/java/com/android/dialershared/bubble/CheckableImageButton.java
@@ -0,0 +1,101 @@
+/*
+ * 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.dialershared.bubble;
+
+import android.content.Context;
+import android.support.v4.view.AccessibilityDelegateCompat;
+import android.support.v4.view.ViewCompat;
+import android.support.v4.view.accessibility.AccessibilityEventCompat;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.support.v7.widget.AppCompatImageButton;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.Checkable;
+
+/**
+ * An {@link android.widget.ImageButton ImageButton} that implements {@link Checkable} and
+ * propagates the checkable state
+ */
+public class CheckableImageButton extends AppCompatImageButton implements Checkable {
+
+ // Copied without modification from AppCompat library
+
+ private static final int[] DRAWABLE_STATE_CHECKED = new int[] {android.R.attr.state_checked};
+
+ private boolean mChecked;
+
+ public CheckableImageButton(Context context) {
+ this(context, null);
+ }
+
+ public CheckableImageButton(Context context, AttributeSet attrs) {
+ this(context, attrs, android.R.attr.imageButtonStyle);
+ }
+
+ public CheckableImageButton(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+
+ ViewCompat.setAccessibilityDelegate(
+ this,
+ new AccessibilityDelegateCompat() {
+ @Override
+ public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) {
+ super.onInitializeAccessibilityEvent(host, event);
+ event.setChecked(isChecked());
+ }
+
+ @Override
+ public void onInitializeAccessibilityNodeInfo(
+ View host, AccessibilityNodeInfoCompat info) {
+ super.onInitializeAccessibilityNodeInfo(host, info);
+ info.setCheckable(true);
+ info.setChecked(isChecked());
+ }
+ });
+ }
+
+ @Override
+ public void setChecked(boolean checked) {
+ if (mChecked != checked) {
+ mChecked = checked;
+ refreshDrawableState();
+ sendAccessibilityEvent(AccessibilityEventCompat.TYPE_WINDOW_CONTENT_CHANGED);
+ }
+ }
+
+ @Override
+ public boolean isChecked() {
+ return mChecked;
+ }
+
+ @Override
+ public void toggle() {
+ setChecked(!mChecked);
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ if (mChecked) {
+ return mergeDrawableStates(
+ super.onCreateDrawableState(extraSpace + DRAWABLE_STATE_CHECKED.length),
+ DRAWABLE_STATE_CHECKED);
+ } else {
+ return super.onCreateDrawableState(extraSpace);
+ }
+ }
+}
diff --git a/java/com/android/dialershared/bubble/MoveHandler.java b/java/com/android/dialershared/bubble/MoveHandler.java
new file mode 100644
index 000000000..8a21cd7e1
--- /dev/null
+++ b/java/com/android/dialershared/bubble/MoveHandler.java
@@ -0,0 +1,250 @@
+/*
+ * 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.dialershared.bubble;
+
+import android.content.Context;
+import android.graphics.Point;
+import android.support.animation.FloatPropertyCompat;
+import android.support.animation.SpringAnimation;
+import android.support.animation.SpringForce;
+import android.support.annotation.NonNull;
+import android.support.v4.math.MathUtils;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.View.OnTouchListener;
+import android.view.ViewConfiguration;
+import android.view.WindowManager;
+import android.view.WindowManager.LayoutParams;
+import android.widget.Scroller;
+
+/** Handles touches and manages moving the bubble in response */
+class MoveHandler implements OnTouchListener {
+
+ // Amount the ViewConfiguration's minFlingVelocity will be scaled by for our own minVelocity
+ private static final int MIN_FLING_VELOCITY_FACTOR = 8;
+ // The friction multiplier to control how slippery the bubble is when flung
+ private static final float SCROLL_FRICTION_MULTIPLIER = 8f;
+
+ private final Context context;
+ private final WindowManager windowManager;
+ private final Bubble bubble;
+ private final int minX;
+ private final int minY;
+ private final int maxX;
+ private final int maxY;
+ private final int bubbleSize;
+ private final int shadowPaddingSize;
+ private final float touchSlopSquared;
+
+ private boolean isMoving;
+ private float firstX;
+ private float firstY;
+
+ private SpringAnimation moveXAnimation;
+ private SpringAnimation moveYAnimation;
+ private VelocityTracker velocityTracker;
+ private Scroller scroller;
+
+ // Handles the left/right gravity conversion and centering
+ private final FloatPropertyCompat<WindowManager.LayoutParams> xProperty =
+ new FloatPropertyCompat<LayoutParams>("xProperty") {
+ @Override
+ public float getValue(LayoutParams windowParams) {
+ int realX = windowParams.x;
+ realX = realX + bubbleSize / 2;
+ realX = realX + shadowPaddingSize;
+ if (relativeToRight(windowParams)) {
+ int displayWidth = context.getResources().getDisplayMetrics().widthPixels;
+ realX = displayWidth - realX;
+ }
+ return MathUtils.clamp(realX, minX, maxX);
+ }
+
+ @Override
+ public void setValue(LayoutParams windowParams, float value) {
+ int displayWidth = context.getResources().getDisplayMetrics().widthPixels;
+ boolean onRight = value > displayWidth / 2;
+ int centeringOffset = bubbleSize / 2 + shadowPaddingSize;
+ windowParams.x =
+ (int) (onRight ? (displayWidth - value - centeringOffset) : value - centeringOffset);
+ windowParams.gravity = Gravity.TOP | (onRight ? Gravity.RIGHT : Gravity.LEFT);
+ if (bubble.isShowing()) {
+ windowManager.updateViewLayout(bubble.getRootView(), windowParams);
+ }
+ }
+ };
+
+ private final FloatPropertyCompat<WindowManager.LayoutParams> yProperty =
+ new FloatPropertyCompat<LayoutParams>("yProperty") {
+ @Override
+ public float getValue(LayoutParams object) {
+ return MathUtils.clamp(object.y + bubbleSize + shadowPaddingSize, minY, maxY);
+ }
+
+ @Override
+ public void setValue(LayoutParams object, float value) {
+ object.y = (int) value - bubbleSize - shadowPaddingSize;
+ if (bubble.isShowing()) {
+ windowManager.updateViewLayout(bubble.getRootView(), object);
+ }
+ }
+ };
+
+ public MoveHandler(@NonNull View targetView, @NonNull Bubble bubble) {
+ this.bubble = bubble;
+ context = targetView.getContext();
+ windowManager = context.getSystemService(WindowManager.class);
+
+ bubbleSize = context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
+ shadowPaddingSize =
+ context.getResources().getDimensionPixelOffset(R.dimen.bubble_shadow_padding_size);
+ minX =
+ context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_x)
+ + bubbleSize / 2;
+ minY =
+ context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_y)
+ + bubbleSize / 2;
+ maxX = context.getResources().getDisplayMetrics().widthPixels - minX;
+ maxY = context.getResources().getDisplayMetrics().heightPixels - minY;
+
+ // Squared because it will be compared against the square of the touch delta. This is more
+ // efficient than needing to take a square root.
+ touchSlopSquared = (float) Math.pow(ViewConfiguration.get(context).getScaledTouchSlop(), 2);
+
+ targetView.setOnTouchListener(this);
+ }
+
+ public boolean isMoving() {
+ return isMoving;
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ float eventX = event.getRawX();
+ float eventY = event.getRawY();
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ firstX = eventX;
+ firstY = eventY;
+ velocityTracker = VelocityTracker.obtain();
+ break;
+ case MotionEvent.ACTION_MOVE:
+ if (isMoving || hasExceededTouchSlop(event)) {
+ if (!isMoving) {
+ isMoving = true;
+ bubble.onMoveStart();
+ }
+
+ if (moveXAnimation == null) {
+ moveXAnimation = new SpringAnimation(bubble.getWindowParams(), xProperty);
+ moveXAnimation.setSpring(new SpringForce());
+ moveXAnimation.getSpring().setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY);
+ }
+ if (moveYAnimation == null) {
+ moveYAnimation = new SpringAnimation(bubble.getWindowParams(), yProperty);
+ moveYAnimation.setSpring(new SpringForce());
+ moveYAnimation.getSpring().setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY);
+ }
+
+ moveXAnimation.animateToFinalPosition(MathUtils.clamp(eventX, minX, maxX));
+ moveYAnimation.animateToFinalPosition(MathUtils.clamp(eventY, minY, maxY));
+ }
+
+ velocityTracker.addMovement(event);
+ break;
+ case MotionEvent.ACTION_UP:
+ if (isMoving) {
+ ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
+ velocityTracker.computeCurrentVelocity(
+ 1000, viewConfiguration.getScaledMaximumFlingVelocity());
+ float xVelocity = velocityTracker.getXVelocity();
+ float yVelocity = velocityTracker.getYVelocity();
+ boolean isFling = isFling(xVelocity, yVelocity);
+
+ if (isFling) {
+ Point target =
+ findTarget(
+ xVelocity,
+ yVelocity,
+ (int) xProperty.getValue(bubble.getWindowParams()),
+ (int) yProperty.getValue(bubble.getWindowParams()));
+
+ moveXAnimation.animateToFinalPosition(target.x);
+ moveYAnimation.animateToFinalPosition(target.y);
+ } else {
+ snapX();
+ }
+
+ bubble.onMoveFinish();
+ } else {
+ v.performClick();
+ bubble.primaryButtonClick();
+ }
+ isMoving = false;
+ break;
+ }
+ return true;
+ }
+
+ private Point findTarget(float xVelocity, float yVelocity, int startX, int startY) {
+ if (scroller == null) {
+ scroller = new Scroller(context);
+ scroller.setFriction(ViewConfiguration.getScrollFriction() * SCROLL_FRICTION_MULTIPLIER);
+ }
+
+ // Find where a fling would end vertically
+ scroller.fling(startX, startY, (int) xVelocity, (int) yVelocity, minX, maxX, minY, maxY);
+ int targetY = scroller.getFinalY();
+ scroller.abortAnimation();
+
+ // If the x component of the velocity is above the minimum fling velocity, use velocity to
+ // determine edge. Otherwise use its starting position
+ boolean pullRight = isFling(xVelocity, 0) ? xVelocity > 0 : isOnRightHalf(startX);
+ return new Point(pullRight ? maxX : minX, targetY);
+ }
+
+ private boolean isFling(float xVelocity, float yVelocity) {
+ int minFlingVelocity =
+ ViewConfiguration.get(context).getScaledMinimumFlingVelocity() * MIN_FLING_VELOCITY_FACTOR;
+ return getMagnitudeSquared(xVelocity, yVelocity) > minFlingVelocity * minFlingVelocity;
+ }
+
+ private boolean isOnRightHalf(float currentX) {
+ return currentX > (minX + maxX) / 2;
+ }
+
+ private void snapX() {
+ // Check if x value is closer to min or max
+ boolean pullRight = isOnRightHalf(xProperty.getValue(bubble.getWindowParams()));
+ moveXAnimation.animateToFinalPosition(pullRight ? maxX : minX);
+ }
+
+ private boolean relativeToRight(LayoutParams windowParams) {
+ return (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT;
+ }
+
+ private boolean hasExceededTouchSlop(MotionEvent event) {
+ return getMagnitudeSquared(event.getRawX() - firstX, event.getRawY() - firstY)
+ > touchSlopSquared;
+ }
+
+ private float getMagnitudeSquared(float deltaX, float deltaY) {
+ return deltaX * deltaX + deltaY * deltaY;
+ }
+}
diff --git a/java/com/android/dialershared/bubble/WindowRoot.java b/java/com/android/dialershared/bubble/WindowRoot.java
new file mode 100644
index 000000000..2c176662e
--- /dev/null
+++ b/java/com/android/dialershared/bubble/WindowRoot.java
@@ -0,0 +1,55 @@
+/*
+ * 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.dialershared.bubble;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.view.KeyEvent;
+import android.widget.FrameLayout;
+
+/**
+ * ViewGroup that handles some overlay window concerns. Allows back button events to be listened for
+ * via an interface.
+ */
+public class WindowRoot extends FrameLayout {
+
+ private OnBackPressedListener backPressedListener;
+
+ /** Callback for when the back button is pressed while this window is in focus */
+ public interface OnBackPressedListener {
+ boolean onBackPressed();
+ }
+
+ public WindowRoot(@NonNull Context context) {
+ super(context);
+ }
+
+ public void setOnBackPressedListener(OnBackPressedListener listener) {
+ backPressedListener = listener;
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ if (event.getKeyCode() == KeyEvent.KEYCODE_BACK && backPressedListener != null) {
+ if (event.getAction() == KeyEvent.ACTION_UP) {
+ return backPressedListener.onBackPressed();
+ }
+ return true;
+ }
+ return super.dispatchKeyEvent(event);
+ }
+}
diff --git a/java/com/android/dialershared/bubble/res/color/bubble_checkable_mask.xml b/java/com/android/dialershared/bubble/res/color/bubble_checkable_mask.xml
new file mode 100644
index 000000000..f9416ab57
--- /dev/null
+++ b/java/com/android/dialershared/bubble/res/color/bubble_checkable_mask.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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
+ -->
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:color="@android:color/white" android:state_checked="true"/>
+ <item android:color="@android:color/transparent"/>
+</selector>
diff --git a/java/com/android/dialershared/bubble/res/color/bubble_icon_tint_states.xml b/java/com/android/dialershared/bubble/res/color/bubble_icon_tint_states.xml
new file mode 100644
index 000000000..33ca1fdc5
--- /dev/null
+++ b/java/com/android/dialershared/bubble/res/color/bubble_icon_tint_states.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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
+ -->
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:color="#80FFFFFF" android:state_enabled="false"/>
+ <item android:color="@android:color/white"/>
+</selector>
diff --git a/java/com/android/dialershared/bubble/res/drawable/bubble_background_pill_ltr.xml b/java/com/android/dialershared/bubble/res/drawable/bubble_background_pill_ltr.xml
new file mode 100644
index 000000000..77c813a75
--- /dev/null
+++ b/java/com/android/dialershared/bubble/res/drawable/bubble_background_pill_ltr.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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
+ -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+ <corners
+ android:bottomRightRadius="@dimen/bubble_size"
+ android:topRightRadius="@dimen/bubble_size"/>
+ <solid android:color="@android:color/white"/>
+</shape>
diff --git a/java/com/android/dialershared/bubble/res/drawable/bubble_background_pill_rtl.xml b/java/com/android/dialershared/bubble/res/drawable/bubble_background_pill_rtl.xml
new file mode 100644
index 000000000..9e2542154
--- /dev/null
+++ b/java/com/android/dialershared/bubble/res/drawable/bubble_background_pill_rtl.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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
+ -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+ <corners
+ android:bottomLeftRadius="@dimen/bubble_size"
+ android:topLeftRadius="@dimen/bubble_size"/>
+ <solid android:color="@android:color/white"/>
+</shape>
diff --git a/java/com/android/dialershared/bubble/res/drawable/bubble_ripple_checkable_circle.xml b/java/com/android/dialershared/bubble/res/drawable/bubble_ripple_checkable_circle.xml
new file mode 100644
index 000000000..85e0b24f3
--- /dev/null
+++ b/java/com/android/dialershared/bubble/res/drawable/bubble_ripple_checkable_circle.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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
+ -->
+
+<ripple xmlns:android="http://schemas.android.com/apk/res/android"
+ android:color="?android:colorControlHighlight">
+ <item>
+ <shape android:shape="oval">
+ <solid android:color="@color/bubble_checkable_mask"/>
+ </shape>
+ </item>
+ <item android:id="@android:id/mask">
+ <shape android:shape="oval">
+ <solid android:color="@android:color/white"/>
+ </shape>
+ </item>
+</ripple>
diff --git a/java/com/android/dialershared/bubble/res/drawable/bubble_ripple_circle.xml b/java/com/android/dialershared/bubble/res/drawable/bubble_ripple_circle.xml
new file mode 100644
index 000000000..8d5cf0bb5
--- /dev/null
+++ b/java/com/android/dialershared/bubble/res/drawable/bubble_ripple_circle.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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
+ -->
+
+<ripple xmlns:android="http://schemas.android.com/apk/res/android"
+ android:color="?android:colorControlHighlight">
+ <item>
+ <shape>
+ <corners android:radius="@dimen/bubble_size"/>
+ <solid android:color="@android:color/white"/>
+ </shape>
+ </item>
+</ripple>
diff --git a/java/com/android/dialershared/bubble/res/layout/bubble_base.xml b/java/com/android/dialershared/bubble/res/layout/bubble_base.xml
new file mode 100644
index 000000000..3acd2af2e
--- /dev/null
+++ b/java/com/android/dialershared/bubble/res/layout/bubble_base.xml
@@ -0,0 +1,116 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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
+ -->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ tools:theme="@style/Theme.AppCompat">
+ <FrameLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="48dp"
+ android:paddingTop="@dimen/bubble_shadow_padding_size"
+ android:paddingBottom="@dimen/bubble_shadow_padding_size"
+ android:paddingEnd="@dimen/bubble_shadow_padding_size"
+ android:background="@android:color/transparent">
+
+ <LinearLayout
+ android:id="@+id/bubble_expanded_layout"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:paddingStart="32dp"
+ android:paddingEnd="12dp"
+ android:background="@drawable/bubble_background_pill_ltr"
+ android:elevation="2dp"
+ android:layoutDirection="inherit"
+ android:orientation="horizontal"
+ android:visibility="gone"
+ tools:backgroundTint="#FF0000FF"
+ tools:visibility="visible">
+ <com.android.dialershared.bubble.CheckableImageButton
+ android:id="@+id/bubble_icon_first"
+ android:layout_width="@dimen/bubble_size"
+ android:layout_height="@dimen/bubble_size"
+ android:layout_marginStart="4dp"
+ android:padding="@dimen/bubble_icon_padding"
+ android:tint="@color/bubble_icon_tint_states"
+ android:tintMode="src_in"
+ tools:background="@drawable/bubble_ripple_checkable_circle"
+ tools:src="@android:drawable/ic_lock_idle_lock"/>
+ <com.android.dialershared.bubble.CheckableImageButton
+ android:id="@+id/bubble_icon_second"
+ android:layout_width="@dimen/bubble_size"
+ android:layout_height="@dimen/bubble_size"
+ android:layout_marginStart="4dp"
+ android:padding="@dimen/bubble_icon_padding"
+ android:tint="@color/bubble_icon_tint_states"
+ android:tintMode="src_in"
+ tools:background="@drawable/bubble_ripple_checkable_circle"
+ tools:src="@android:drawable/ic_input_add"/>
+ <com.android.dialershared.bubble.CheckableImageButton
+ android:id="@+id/bubble_icon_third"
+ android:layout_width="@dimen/bubble_size"
+ android:layout_height="@dimen/bubble_size"
+ android:layout_marginStart="4dp"
+ android:padding="@dimen/bubble_icon_padding"
+ android:tint="@color/bubble_icon_tint_states"
+ android:tintMode="src_in"
+ tools:background="@drawable/bubble_ripple_checkable_circle"
+ tools:src="@android:drawable/ic_menu_call"/>
+ </LinearLayout>
+ </FrameLayout>
+ <FrameLayout
+ android:id="@+id/bubble_primary_container"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="start"
+ android:animateLayoutChanges="true"
+ android:background="@android:color/transparent"
+ android:clipChildren="false"
+ android:clipToPadding="false">
+ <ViewAnimator
+ android:id="@+id/bubble_button_primary"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="@dimen/bubble_shadow_padding_size"
+ android:background="@drawable/bubble_ripple_circle"
+ android:elevation="6dp"
+ android:measureAllChildren="false"
+ tools:backgroundTint="#FF0000AA">
+ <ImageView
+ android:id="@+id/bubble_icon_primary"
+ android:layout_width="@dimen/bubble_size"
+ android:layout_height="@dimen/bubble_size"
+ android:padding="@dimen/bubble_icon_padding"
+ android:tint="@android:color/white"
+ android:tintMode="src_in"
+ tools:src="@android:drawable/ic_btn_speak_now"/>
+ <TextView
+ android:id="@+id/bubble_text"
+ android:layout_width="wrap_content"
+ android:layout_height="@dimen/bubble_size"
+ android:paddingStart="@dimen/bubble_icon_padding"
+ android:paddingEnd="@dimen/bubble_icon_padding"
+ android:gravity="center"
+ android:minWidth="@dimen/bubble_size"
+ android:textAppearance="@style/TextAppearance.AppCompat"
+ tools:text="Call ended"/>
+ </ViewAnimator>
+ </FrameLayout>
+
+</FrameLayout>
diff --git a/java/com/android/dialershared/bubble/res/values/colors.xml b/java/com/android/dialershared/bubble/res/values/colors.xml
new file mode 100644
index 000000000..97545faf3
--- /dev/null
+++ b/java/com/android/dialershared/bubble/res/values/colors.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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
+ -->
+
+<resources>
+ <color name="bubble_primary_background_darken">#33000000</color>
+</resources>
diff --git a/java/com/android/dialershared/bubble/res/values/values.xml b/java/com/android/dialershared/bubble/res/values/values.xml
new file mode 100644
index 000000000..5b85e0d23
--- /dev/null
+++ b/java/com/android/dialershared/bubble/res/values/values.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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
+ -->
+
+<resources>
+ <dimen name="bubble_safe_margin_x">0dp</dimen>
+ <dimen name="bubble_safe_margin_y">0dp</dimen>
+ <dimen name="bubble_initial_offset_x">0dp</dimen>
+ <dimen name="bubble_initial_offset_y">120dp</dimen>
+ <dimen name="bubble_size">64dp</dimen>
+ <dimen name="bubble_icon_padding">20dp</dimen>
+ <dimen name="bubble_move_elevation_change">4dp</dimen>
+ <dimen name="bubble_shadow_padding_size">16dp</dimen>
+</resources>