• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License
15  */
16 
17 package com.android.dialershared.bubble;
18 
19 import android.animation.Animator;
20 import android.animation.AnimatorSet;
21 import android.animation.ObjectAnimator;
22 import android.annotation.SuppressLint;
23 import android.app.PendingIntent.CanceledException;
24 import android.content.Context;
25 import android.content.Intent;
26 import android.content.res.ColorStateList;
27 import android.graphics.PixelFormat;
28 import android.graphics.drawable.Animatable;
29 import android.graphics.drawable.Drawable;
30 import android.graphics.drawable.RippleDrawable;
31 import android.net.Uri;
32 import android.os.Build.VERSION;
33 import android.os.Build.VERSION_CODES;
34 import android.os.Handler;
35 import android.provider.Settings;
36 import android.support.annotation.ColorInt;
37 import android.support.annotation.IntDef;
38 import android.support.annotation.NonNull;
39 import android.support.annotation.Nullable;
40 import android.support.annotation.VisibleForTesting;
41 import android.support.v4.graphics.ColorUtils;
42 import android.support.v4.os.BuildCompat;
43 import android.support.v4.view.animation.FastOutLinearInInterpolator;
44 import android.support.v4.view.animation.LinearOutSlowInInterpolator;
45 import android.transition.TransitionManager;
46 import android.transition.TransitionValues;
47 import android.view.ContextThemeWrapper;
48 import android.view.Gravity;
49 import android.view.LayoutInflater;
50 import android.view.MotionEvent;
51 import android.view.View;
52 import android.view.ViewGroup;
53 import android.view.ViewGroup.MarginLayoutParams;
54 import android.view.ViewPropertyAnimator;
55 import android.view.ViewTreeObserver.OnPreDrawListener;
56 import android.view.WindowManager;
57 import android.view.WindowManager.LayoutParams;
58 import android.view.animation.AnticipateInterpolator;
59 import android.view.animation.OvershootInterpolator;
60 import android.widget.FrameLayout;
61 import android.widget.ImageView;
62 import android.widget.TextView;
63 import android.widget.ViewAnimator;
64 import com.android.dialershared.bubble.BubbleInfo.Action;
65 import java.lang.annotation.Retention;
66 import java.lang.annotation.RetentionPolicy;
67 import java.util.List;
68 
69 /**
70  * Creates and manages a bubble window from information in a {@link BubbleInfo}. Before creating, be
71  * sure to check whether bubbles may be shown using {@link #canShowBubbles(Context)} and request
72  * permission if necessary ({@link #getRequestPermissionIntent(Context)} is provided for
73  * convenience)
74  */
75 public class Bubble {
76   // This class has some odd behavior that is not immediately obvious in order to avoid jank when
77   // resizing. See http://go/bubble-resize for details.
78 
79   // How long text should show after showText(CharSequence) is called
80   private static final int SHOW_TEXT_DURATION_MILLIS = 3000;
81   // How long the new window should show before destroying the old one during resize operations.
82   // This ensures the new window has had time to draw first.
83   private static final int WINDOW_REDRAW_DELAY_MILLIS = 50;
84 
85   private static Boolean canShowBubblesForTesting = null;
86 
87   private final Context context;
88   private final WindowManager windowManager;
89 
90   private LayoutParams windowParams;
91 
92   // Initialized in factory method
93   @SuppressWarnings("NullableProblems")
94   @NonNull
95   private BubbleInfo currentInfo;
96 
97   @Visibility private int visibility;
98   private boolean expanded;
99   private boolean textShowing;
100   private boolean hideAfterText;
101   private int collapseEndAction;
102 
103   private final Handler handler = new Handler();
104 
105   private ViewHolder viewHolder;
106   private ViewPropertyAnimator collapseAnimation;
107   private Integer overrideGravity;
108   private ViewPropertyAnimator exitAnimator;
109 
110   @Retention(RetentionPolicy.SOURCE)
111   @IntDef({CollapseEnd.NOTHING, CollapseEnd.HIDE})
112   private @interface CollapseEnd {
113     int NOTHING = 0;
114     int HIDE = 1;
115   }
116 
117   @Retention(RetentionPolicy.SOURCE)
118   @IntDef({Visibility.ENTERING, Visibility.SHOWING, Visibility.EXITING, Visibility.HIDDEN})
119   private @interface Visibility {
120     int HIDDEN = 0;
121     int ENTERING = 1;
122     int SHOWING = 2;
123     int EXITING = 3;
124   }
125 
126   /**
127    * Determines whether bubbles can be shown based on permissions obtained. This should be checked
128    * before attempting to create a Bubble.
129    *
130    * @return true iff bubbles are able to be shown.
131    * @see Settings#canDrawOverlays(Context)
132    */
canShowBubbles(@onNull Context context)133   public static boolean canShowBubbles(@NonNull Context context) {
134     return canShowBubblesForTesting != null
135         ? canShowBubblesForTesting
136         : VERSION.SDK_INT < VERSION_CODES.M || Settings.canDrawOverlays(context);
137   }
138 
139   @VisibleForTesting(otherwise = VisibleForTesting.NONE)
setCanShowBubblesForTesting(boolean canShowBubbles)140   public static void setCanShowBubblesForTesting(boolean canShowBubbles) {
141     canShowBubblesForTesting = canShowBubbles;
142   }
143 
144   /** Returns an Intent to request permission to show overlays */
145   @NonNull
getRequestPermissionIntent(@onNull Context context)146   public static Intent getRequestPermissionIntent(@NonNull Context context) {
147     return new Intent(
148         Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
149         Uri.fromParts("package", context.getPackageName(), null));
150   }
151 
152   /** Creates instances of Bubble. The default implementation just calls the constructor. */
153   @VisibleForTesting
154   public interface BubbleFactory {
createBubble(@onNull Context context)155     Bubble createBubble(@NonNull Context context);
156   }
157 
158   private static BubbleFactory bubbleFactory = Bubble::new;
159 
createBubble(@onNull Context context, @NonNull BubbleInfo info)160   public static Bubble createBubble(@NonNull Context context, @NonNull BubbleInfo info) {
161     Bubble bubble = bubbleFactory.createBubble(context);
162     bubble.setBubbleInfo(info);
163     return bubble;
164   }
165 
166   @VisibleForTesting
setBubbleFactory(@onNull BubbleFactory bubbleFactory)167   public static void setBubbleFactory(@NonNull BubbleFactory bubbleFactory) {
168     Bubble.bubbleFactory = bubbleFactory;
169   }
170 
171   @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
Bubble(@onNull Context context)172   Bubble(@NonNull Context context) {
173     context = new ContextThemeWrapper(context, R.style.Theme_AppCompat);
174     this.context = context;
175     windowManager = context.getSystemService(WindowManager.class);
176 
177     viewHolder = new ViewHolder(context);
178   }
179 
180   /**
181    * Make the bubble visible. Will show a short entrance animation as it enters. If the bubble is
182    * already showing this method does nothing.
183    */
show()184   public void show() {
185     if (collapseEndAction == CollapseEnd.HIDE) {
186       // If show() was called while collapsing, make sure we don't hide after.
187       collapseEndAction = CollapseEnd.NOTHING;
188     }
189     if (visibility == Visibility.SHOWING || visibility == Visibility.ENTERING) {
190       return;
191     }
192 
193     hideAfterText = false;
194 
195     if (windowParams == null) {
196       // Apps targeting O+ must use TYPE_APPLICATION_OVERLAY, which is not available prior to O.
197       @SuppressWarnings("deprecation")
198       @SuppressLint("InlinedApi")
199       int type =
200           BuildCompat.isAtLeastO()
201               ? LayoutParams.TYPE_APPLICATION_OVERLAY
202               : LayoutParams.TYPE_PHONE;
203 
204       windowParams =
205           new LayoutParams(
206               type,
207               LayoutParams.FLAG_NOT_TOUCH_MODAL
208                   | LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
209                   | LayoutParams.FLAG_NOT_FOCUSABLE
210                   | LayoutParams.FLAG_LAYOUT_NO_LIMITS,
211               PixelFormat.TRANSLUCENT);
212       windowParams.gravity = Gravity.TOP | Gravity.LEFT;
213       windowParams.x = context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_x);
214       windowParams.y = currentInfo.getStartingYPosition();
215       windowParams.height = LayoutParams.WRAP_CONTENT;
216       windowParams.width = LayoutParams.WRAP_CONTENT;
217     }
218 
219     if (exitAnimator != null) {
220       exitAnimator.cancel();
221       exitAnimator = null;
222     } else {
223       windowManager.addView(viewHolder.getRoot(), windowParams);
224       viewHolder.getPrimaryButton().setScaleX(0);
225       viewHolder.getPrimaryButton().setScaleY(0);
226     }
227 
228     visibility = Visibility.ENTERING;
229     viewHolder
230         .getPrimaryButton()
231         .animate()
232         .setInterpolator(new OvershootInterpolator())
233         .scaleX(1)
234         .scaleY(1)
235         .withEndAction(() -> visibility = Visibility.SHOWING)
236         .start();
237 
238     updatePrimaryIconAnimation();
239   }
240 
241   /**
242    * Hide the button if visible. Will run a short exit animation before hiding. If the bubble is
243    * currently showing text, will hide after the text is done displaying. If the bubble is not
244    * visible this method does nothing.
245    */
hide()246   public void hide() {
247     if (visibility == Visibility.HIDDEN || visibility == Visibility.EXITING) {
248       return;
249     }
250 
251     if (textShowing) {
252       hideAfterText = true;
253       return;
254     }
255 
256     if (collapseAnimation != null) {
257       collapseEndAction = CollapseEnd.HIDE;
258       return;
259     }
260 
261     if (expanded) {
262       startCollapse(CollapseEnd.HIDE);
263       return;
264     }
265 
266     visibility = Visibility.EXITING;
267     exitAnimator =
268         viewHolder
269             .getPrimaryButton()
270             .animate()
271             .setInterpolator(new AnticipateInterpolator())
272             .scaleX(0)
273             .scaleY(0)
274             .withEndAction(
275                 () -> {
276                   exitAnimator = null;
277                   windowManager.removeView(viewHolder.getRoot());
278                   visibility = Visibility.HIDDEN;
279                   updatePrimaryIconAnimation();
280                 });
281     exitAnimator.start();
282   }
283 
284   /** Returns whether the bubble is currently visible */
isVisible()285   public boolean isVisible() {
286     return visibility == Visibility.SHOWING
287         || visibility == Visibility.ENTERING
288         || visibility == Visibility.EXITING;
289   }
290 
291   /**
292    * Set the info for this Bubble to display
293    *
294    * @param bubbleInfo the BubbleInfo to display in this Bubble.
295    */
setBubbleInfo(@onNull BubbleInfo bubbleInfo)296   public void setBubbleInfo(@NonNull BubbleInfo bubbleInfo) {
297     currentInfo = bubbleInfo;
298     update();
299   }
300 
301   /**
302    * Update the state and behavior of actions.
303    *
304    * @param actions the new state of the bubble's actions
305    */
updateActions(@onNull List<Action> actions)306   public void updateActions(@NonNull List<Action> actions) {
307     currentInfo = BubbleInfo.from(currentInfo).setActions(actions).build();
308     updateButtonStates();
309   }
310 
311   /** Returns the currently displayed BubbleInfo */
getBubbleInfo()312   public BubbleInfo getBubbleInfo() {
313     return currentInfo;
314   }
315 
316   /**
317    * Display text in the main bubble. The bubble's drawer is not expandable while text is showing,
318    * and the drawer will be closed if already open.
319    *
320    * @param text the text to display to the user
321    */
showText(@onNull CharSequence text)322   public void showText(@NonNull CharSequence text) {
323     textShowing = true;
324     if (expanded) {
325       startCollapse(CollapseEnd.NOTHING);
326       doShowText(text);
327     } else {
328       // Need to transition from old bounds to new bounds manually
329       ChangeOnScreenBounds transition = new ChangeOnScreenBounds();
330       // Prepare and capture start values
331       TransitionValues startValues = new TransitionValues();
332       startValues.view = viewHolder.getPrimaryButton();
333       transition.addTarget(startValues.view);
334       transition.captureStartValues(startValues);
335 
336       doResize(
337           () -> {
338             doShowText(text);
339             // Hide the text so we can animate it in
340             viewHolder.getPrimaryText().setAlpha(0);
341 
342             ViewAnimator primaryButton = viewHolder.getPrimaryButton();
343             // Cancel the automatic transition scheduled in doShowText
344             TransitionManager.endTransitions((ViewGroup) primaryButton.getParent());
345             primaryButton
346                 .getViewTreeObserver()
347                 .addOnPreDrawListener(
348                     new OnPreDrawListener() {
349                       @Override
350                       public boolean onPreDraw() {
351                         primaryButton.getViewTreeObserver().removeOnPreDrawListener(this);
352 
353                         // Prepare and capture end values
354                         TransitionValues endValues = new TransitionValues();
355                         endValues.view = primaryButton;
356                         transition.addTarget(endValues.view);
357                         transition.captureEndValues(endValues);
358 
359                         // animate the primary button bounds change
360                         Animator bounds =
361                             transition.createAnimator(primaryButton, startValues, endValues);
362 
363                         // Animate the text in
364                         Animator alpha =
365                             ObjectAnimator.ofFloat(viewHolder.getPrimaryText(), View.ALPHA, 1f);
366 
367                         AnimatorSet set = new AnimatorSet();
368                         set.play(bounds).before(alpha);
369                         set.start();
370                         return false;
371                       }
372                     });
373           });
374     }
375     handler.removeCallbacks(null);
376     handler.postDelayed(
377         () -> {
378           textShowing = false;
379           if (hideAfterText) {
380             hide();
381           } else {
382             doResize(
383                 () -> viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_ICON));
384           }
385         },
386         SHOW_TEXT_DURATION_MILLIS);
387   }
388 
389   @Nullable
getGravityOverride()390   Integer getGravityOverride() {
391     return overrideGravity;
392   }
393 
onMoveStart()394   void onMoveStart() {
395     startCollapse(CollapseEnd.NOTHING);
396     viewHolder
397         .getPrimaryButton()
398         .animate()
399         .translationZ(
400             context.getResources().getDimensionPixelOffset(R.dimen.bubble_move_elevation_change));
401   }
402 
onMoveFinish()403   void onMoveFinish() {
404     viewHolder.getPrimaryButton().animate().translationZ(0);
405     // If it's GONE, no resize is necessary. If it's VISIBLE, it will get cleaned up when the
406     // collapse animation finishes
407     if (viewHolder.getExpandedView().getVisibility() == View.INVISIBLE) {
408       doResize(null);
409     }
410   }
411 
primaryButtonClick()412   void primaryButtonClick() {
413     if (expanded || textShowing || currentInfo.getActions().isEmpty()) {
414       try {
415         currentInfo.getPrimaryIntent().send();
416       } catch (CanceledException e) {
417         throw new RuntimeException(e);
418       }
419       return;
420     }
421 
422     doResize(
423         () -> {
424           onLeftRightSwitch(isDrawingFromRight());
425           viewHolder.setDrawerVisibility(View.VISIBLE);
426         });
427     View expandedView = viewHolder.getExpandedView();
428     expandedView
429         .getViewTreeObserver()
430         .addOnPreDrawListener(
431             new OnPreDrawListener() {
432               @Override
433               public boolean onPreDraw() {
434                 expandedView.getViewTreeObserver().removeOnPreDrawListener(this);
435                 expandedView.setTranslationX(
436                     isDrawingFromRight() ? expandedView.getWidth() : -expandedView.getWidth());
437                 expandedView
438                     .animate()
439                     .setInterpolator(new LinearOutSlowInInterpolator())
440                     .translationX(0);
441                 return false;
442               }
443             });
444     setFocused(true);
445     expanded = true;
446   }
447 
onLeftRightSwitch(boolean onRight)448   void onLeftRightSwitch(boolean onRight) {
449     if (viewHolder.isMoving()) {
450       if (viewHolder.getExpandedView().getVisibility() == View.GONE) {
451         // If the drawer is not part of the layout we don't need to do anything. Layout flips will
452         // happen if necessary when opening the drawer.
453         return;
454       }
455     }
456 
457     viewHolder
458         .getRoot()
459         .setLayoutDirection(onRight ? View.LAYOUT_DIRECTION_RTL : View.LAYOUT_DIRECTION_LTR);
460     View primaryContainer = viewHolder.getRoot().findViewById(R.id.bubble_primary_container);
461     ViewGroup.LayoutParams layoutParams = primaryContainer.getLayoutParams();
462     ((FrameLayout.LayoutParams) layoutParams).gravity = onRight ? Gravity.RIGHT : Gravity.LEFT;
463     primaryContainer.setLayoutParams(layoutParams);
464 
465     viewHolder
466         .getExpandedView()
467         .setBackgroundResource(
468             onRight
469                 ? R.drawable.bubble_background_pill_rtl
470                 : R.drawable.bubble_background_pill_ltr);
471   }
472 
getWindowParams()473   LayoutParams getWindowParams() {
474     return windowParams;
475   }
476 
getRootView()477   View getRootView() {
478     return viewHolder.getRoot();
479   }
480 
update()481   private void update() {
482     RippleDrawable backgroundRipple =
483         (RippleDrawable)
484             context.getResources().getDrawable(R.drawable.bubble_ripple_circle, context.getTheme());
485     int primaryTint =
486         ColorUtils.compositeColors(
487             context.getColor(R.color.bubble_primary_background_darken),
488             currentInfo.getPrimaryColor());
489     backgroundRipple.getDrawable(0).setTint(primaryTint);
490     viewHolder.getPrimaryButton().setBackground(backgroundRipple);
491 
492     setBackgroundDrawable(viewHolder.getFirstButton(), primaryTint);
493     setBackgroundDrawable(viewHolder.getSecondButton(), primaryTint);
494     setBackgroundDrawable(viewHolder.getThirdButton(), primaryTint);
495 
496     int numButtons = currentInfo.getActions().size();
497     viewHolder.getThirdButton().setVisibility(numButtons < 3 ? View.GONE : View.VISIBLE);
498     viewHolder.getSecondButton().setVisibility(numButtons < 2 ? View.GONE : View.VISIBLE);
499 
500     viewHolder.getPrimaryIcon().setImageIcon(currentInfo.getPrimaryIcon());
501     updatePrimaryIconAnimation();
502 
503     viewHolder
504         .getExpandedView()
505         .setBackgroundTintList(ColorStateList.valueOf(currentInfo.getPrimaryColor()));
506 
507     updateButtonStates();
508   }
509 
510   private void updatePrimaryIconAnimation() {
511     Drawable drawable = viewHolder.getPrimaryIcon().getDrawable();
512     if (drawable instanceof Animatable) {
513       if (isVisible()) {
514         ((Animatable) drawable).start();
515       } else {
516         ((Animatable) drawable).stop();
517       }
518     }
519   }
520 
521   private void setBackgroundDrawable(CheckableImageButton view, @ColorInt int color) {
522     RippleDrawable itemRipple =
523         (RippleDrawable)
524             context
525                 .getResources()
526                 .getDrawable(R.drawable.bubble_ripple_checkable_circle, context.getTheme());
527     itemRipple.getDrawable(0).setTint(color);
528     view.setBackground(itemRipple);
529   }
530 
531   private void updateButtonStates() {
532     int numButtons = currentInfo.getActions().size();
533 
534     if (numButtons >= 1) {
535       configureButton(currentInfo.getActions().get(0), viewHolder.getFirstButton());
536       if (numButtons >= 2) {
537         configureButton(currentInfo.getActions().get(1), viewHolder.getSecondButton());
538         if (numButtons >= 3) {
539           configureButton(currentInfo.getActions().get(2), viewHolder.getThirdButton());
540         }
541       }
542     }
543   }
544 
doShowText(@onNull CharSequence text)545   private void doShowText(@NonNull CharSequence text) {
546     TransitionManager.beginDelayedTransition((ViewGroup) viewHolder.getPrimaryButton().getParent());
547     viewHolder.getPrimaryText().setText(text);
548     viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_TEXT);
549   }
550 
configureButton(Action action, CheckableImageButton button)551   private void configureButton(Action action, CheckableImageButton button) {
552     action
553         .getIcon()
554         .loadDrawableAsync(
555             context,
556             d -> {
557               button.setImageIcon(action.getIcon());
558               button.setContentDescription(action.getName());
559               button.setChecked(action.isChecked());
560               button.setEnabled(action.isEnabled());
561             },
562             handler);
563     button.setOnClickListener(v -> doAction(action));
564   }
565 
doAction(Action action)566   private void doAction(Action action) {
567     try {
568       action.getIntent().send();
569     } catch (CanceledException e) {
570       throw new RuntimeException(e);
571     }
572   }
573 
doResize(@ullable Runnable operation)574   private void doResize(@Nullable Runnable operation) {
575     // If we're resizing on the right side of the screen, there is an implicit move operation
576     // necessary. The WindowManager does not sync the move and resize operations, so serious jank
577     // would occur. To fix this, instead of resizing the window, we create a new one and destroy
578     // the old one. There is a short delay before destroying the old view to ensure the new one has
579     // had time to draw.
580     ViewHolder oldViewHolder = viewHolder;
581     if (isDrawingFromRight()) {
582       viewHolder = new ViewHolder(oldViewHolder.getRoot().getContext());
583       update();
584       viewHolder
585           .getPrimaryButton()
586           .setDisplayedChild(oldViewHolder.getPrimaryButton().getDisplayedChild());
587       viewHolder.getPrimaryText().setText(oldViewHolder.getPrimaryText().getText());
588     }
589 
590     if (operation != null) {
591       operation.run();
592     }
593 
594     if (isDrawingFromRight()) {
595       swapViewHolders(oldViewHolder);
596     }
597   }
598 
swapViewHolders(ViewHolder oldViewHolder)599   private void swapViewHolders(ViewHolder oldViewHolder) {
600     oldViewHolder.getShadowProvider().setVisibility(View.GONE);
601     ViewGroup root = viewHolder.getRoot();
602     windowManager.addView(root, windowParams);
603     root.getViewTreeObserver()
604         .addOnPreDrawListener(
605             new OnPreDrawListener() {
606               @Override
607               public boolean onPreDraw() {
608                 root.getViewTreeObserver().removeOnPreDrawListener(this);
609                 // Wait a bit before removing the old view; make sure the new one has drawn over it.
610                 handler.postDelayed(
611                     () -> windowManager.removeView(oldViewHolder.getRoot()),
612                     WINDOW_REDRAW_DELAY_MILLIS);
613                 return true;
614               }
615             });
616   }
617 
startCollapse(@ollapseEnd int endAction)618   private void startCollapse(@CollapseEnd int endAction) {
619     View expandedView = viewHolder.getExpandedView();
620     if (expandedView.getVisibility() != View.VISIBLE || collapseAnimation != null) {
621       // Drawer is already collapsed or animation is running.
622       return;
623     }
624 
625     overrideGravity = isDrawingFromRight() ? Gravity.RIGHT : Gravity.LEFT;
626     setFocused(false);
627 
628     if (collapseEndAction == CollapseEnd.NOTHING) {
629       collapseEndAction = endAction;
630     }
631     collapseAnimation =
632         expandedView
633             .animate()
634             .translationX(isDrawingFromRight() ? expandedView.getWidth() : -expandedView.getWidth())
635             .setInterpolator(new FastOutLinearInInterpolator())
636             .withEndAction(
637                 () -> {
638                   collapseAnimation = null;
639                   expanded = false;
640 
641                   if (textShowing) {
642                     // Will do resize once the text is done.
643                     return;
644                   }
645 
646                   // Hide the drawer and resize if possible.
647                   viewHolder.setDrawerVisibility(View.INVISIBLE);
648                   if (!viewHolder.isMoving() || !isDrawingFromRight()) {
649                     doResize(() -> viewHolder.setDrawerVisibility(View.GONE));
650                   }
651 
652                   // If this collapse was to come before a hide, do it now.
653                   if (collapseEndAction == CollapseEnd.HIDE) {
654                     hide();
655                   }
656                   collapseEndAction = CollapseEnd.NOTHING;
657 
658                   // Resume normal gravity after any resizing is done.
659                   handler.postDelayed(
660                       () -> {
661                         overrideGravity = null;
662                         if (!viewHolder.isMoving()) {
663                           viewHolder.undoGravityOverride();
664                         }
665                       },
666                       // Need to wait twice as long for resize and layout
667                       WINDOW_REDRAW_DELAY_MILLIS * 2);
668                 });
669   }
670 
isDrawingFromRight()671   private boolean isDrawingFromRight() {
672     return (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT;
673   }
674 
setFocused(boolean focused)675   private void setFocused(boolean focused) {
676     if (focused) {
677       windowParams.flags &= ~LayoutParams.FLAG_NOT_FOCUSABLE;
678     } else {
679       windowParams.flags |= LayoutParams.FLAG_NOT_FOCUSABLE;
680     }
681     windowManager.updateViewLayout(getRootView(), windowParams);
682   }
683 
684   private class ViewHolder {
685 
686     public static final int CHILD_INDEX_ICON = 0;
687     public static final int CHILD_INDEX_TEXT = 1;
688 
689     private MoveHandler moveHandler;
690     private final WindowRoot root;
691     private final ViewAnimator primaryButton;
692     private final ImageView primaryIcon;
693     private final TextView primaryText;
694 
695     private final CheckableImageButton firstButton;
696     private final CheckableImageButton secondButton;
697     private final CheckableImageButton thirdButton;
698     private final View expandedView;
699     private final View shadowProvider;
700 
ViewHolder(Context context)701     public ViewHolder(Context context) {
702       // Window root is not in the layout file so that the inflater has a view to inflate into
703       this.root = new WindowRoot(context);
704       LayoutInflater inflater = LayoutInflater.from(root.getContext());
705       View contentView = inflater.inflate(R.layout.bubble_base, root, true);
706       expandedView = contentView.findViewById(R.id.bubble_expanded_layout);
707       primaryButton = contentView.findViewById(R.id.bubble_button_primary);
708       primaryIcon = contentView.findViewById(R.id.bubble_icon_primary);
709       primaryText = contentView.findViewById(R.id.bubble_text);
710       shadowProvider = contentView.findViewById(R.id.bubble_drawer_shadow_provider);
711 
712       firstButton = contentView.findViewById(R.id.bubble_icon_first);
713       secondButton = contentView.findViewById(R.id.bubble_icon_second);
714       thirdButton = contentView.findViewById(R.id.bubble_icon_third);
715 
716       root.setOnBackPressedListener(
717           () -> {
718             if (visibility == Visibility.SHOWING && expanded) {
719               startCollapse(CollapseEnd.NOTHING);
720               return true;
721             }
722             return false;
723           });
724       root.setOnConfigurationChangedListener(
725           (configuration) -> {
726             // The values in the current MoveHandler may be stale, so replace it. Then ensure the
727             // Window is in bounds
728             moveHandler = new MoveHandler(primaryButton, Bubble.this);
729             moveHandler.snapToBounds();
730           });
731       root.setOnTouchListener(
732           (v, event) -> {
733             if (expanded && event.getActionMasked() == MotionEvent.ACTION_OUTSIDE) {
734               startCollapse(CollapseEnd.NOTHING);
735               return true;
736             }
737             return false;
738           });
739       expandedView
740           .getViewTreeObserver()
741           .addOnDrawListener(
742               () -> {
743                 int translationX = (int) expandedView.getTranslationX();
744                 int parentOffset =
745                     ((MarginLayoutParams) ((ViewGroup) expandedView.getParent()).getLayoutParams())
746                         .leftMargin;
747                 if (isDrawingFromRight()) {
748                   int maxLeft =
749                       shadowProvider.getRight()
750                           - context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
751                   shadowProvider.setLeft(
752                       Math.min(maxLeft, expandedView.getLeft() + translationX + parentOffset));
753                 } else {
754                   int minRight =
755                       shadowProvider.getLeft()
756                           + context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
757                   shadowProvider.setRight(
758                       Math.max(minRight, expandedView.getRight() + translationX + parentOffset));
759                 }
760               });
761       moveHandler = new MoveHandler(primaryButton, Bubble.this);
762     }
763 
getRoot()764     public ViewGroup getRoot() {
765       return root;
766     }
767 
getPrimaryButton()768     public ViewAnimator getPrimaryButton() {
769       return primaryButton;
770     }
771 
getPrimaryIcon()772     public ImageView getPrimaryIcon() {
773       return primaryIcon;
774     }
775 
getPrimaryText()776     public TextView getPrimaryText() {
777       return primaryText;
778     }
779 
getFirstButton()780     public CheckableImageButton getFirstButton() {
781       return firstButton;
782     }
783 
getSecondButton()784     public CheckableImageButton getSecondButton() {
785       return secondButton;
786     }
787 
getThirdButton()788     public CheckableImageButton getThirdButton() {
789       return thirdButton;
790     }
791 
getExpandedView()792     public View getExpandedView() {
793       return expandedView;
794     }
795 
getShadowProvider()796     public View getShadowProvider() {
797       return shadowProvider;
798     }
799 
setDrawerVisibility(int visibility)800     public void setDrawerVisibility(int visibility) {
801       expandedView.setVisibility(visibility);
802       shadowProvider.setVisibility(visibility);
803     }
804 
isMoving()805     public boolean isMoving() {
806       return moveHandler.isMoving();
807     }
808 
undoGravityOverride()809     public void undoGravityOverride() {
810       moveHandler.undoGravityOverride();
811     }
812   }
813 }
814