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