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