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