blob: 226326f3c11550f87fd4dc198a4dbad55251cd2e [file] [log] [blame]
Eric Erfanian938468d2017-10-24 14:05:52 -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
17package com.android.newbubble;
18
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.graphics.PixelFormat;
27import android.graphics.drawable.Animatable;
28import android.graphics.drawable.Drawable;
29import android.graphics.drawable.RippleDrawable;
30import android.net.Uri;
31import android.os.Handler;
32import android.provider.Settings;
33import android.support.annotation.ColorInt;
34import android.support.annotation.IntDef;
35import android.support.annotation.NonNull;
36import android.support.annotation.Nullable;
37import android.support.annotation.VisibleForTesting;
38import android.support.v4.graphics.ColorUtils;
39import android.support.v4.graphics.drawable.DrawableCompat;
40import android.support.v4.os.BuildCompat;
41import android.support.v4.view.animation.FastOutLinearInInterpolator;
42import android.support.v4.view.animation.LinearOutSlowInInterpolator;
43import android.transition.TransitionManager;
44import android.transition.TransitionValues;
45import android.view.ContextThemeWrapper;
46import android.view.Gravity;
47import android.view.LayoutInflater;
48import android.view.MotionEvent;
49import android.view.View;
50import android.view.ViewGroup;
51import android.view.ViewPropertyAnimator;
52import android.view.ViewTreeObserver.OnPreDrawListener;
53import android.view.WindowManager;
54import android.view.WindowManager.LayoutParams;
55import android.view.animation.AnticipateInterpolator;
56import android.view.animation.OvershootInterpolator;
57import android.widget.ImageView;
58import android.widget.TextView;
59import android.widget.ViewAnimator;
yuega5a08d82017-10-31 14:11:53 -070060import com.android.dialer.util.DrawableConverter;
Eric Erfanian938468d2017-10-24 14:05:52 -070061import com.android.newbubble.NewBubbleInfo.Action;
62import java.lang.annotation.Retention;
63import java.lang.annotation.RetentionPolicy;
64import java.util.List;
65
66/**
67 * Creates and manages a bubble window from information in a {@link NewBubbleInfo}. Before creating,
68 * be sure to check whether bubbles may be shown using {@link #canShowBubbles(Context)} and request
69 * permission if necessary ({@link #getRequestPermissionIntent(Context)} is provided for
70 * convenience)
71 */
72public class NewBubble {
73 // This class has some odd behavior that is not immediately obvious in order to avoid jank when
74 // resizing. See http://go/bubble-resize for details.
75
76 // How long text should show after showText(CharSequence) is called
77 private static final int SHOW_TEXT_DURATION_MILLIS = 3000;
78 // How long the new window should show before destroying the old one during resize operations.
79 // This ensures the new window has had time to draw first.
80 private static final int WINDOW_REDRAW_DELAY_MILLIS = 50;
81
82 private static Boolean canShowBubblesForTesting = null;
83
84 private final Context context;
85 private final WindowManager windowManager;
86
87 private final Handler handler;
88 private LayoutParams windowParams;
89
90 // Initialized in factory method
91 @SuppressWarnings("NullableProblems")
92 @NonNull
93 private NewBubbleInfo currentInfo;
94
95 @Visibility private int visibility;
96 private boolean expanded;
97 private boolean textShowing;
98 private boolean hideAfterText;
99 private CharSequence textAfterShow;
100 private int collapseEndAction;
101
102 @VisibleForTesting ViewHolder viewHolder;
103 private ViewPropertyAnimator collapseAnimation;
104 private Integer overrideGravity;
105 private ViewPropertyAnimator exitAnimator;
106
107 private final Runnable collapseRunnable =
108 new Runnable() {
109 @Override
110 public void run() {
111 textShowing = false;
112 if (hideAfterText) {
113 // Always reset here since text shouldn't keep showing.
114 hideAndReset();
115 } else {
116 doResize(
yuega5a08d82017-10-31 14:11:53 -0700117 () ->
118 viewHolder
119 .getPrimaryButton()
120 .setDisplayedChild(ViewHolder.CHILD_INDEX_AVATAR_AND_ICON));
Eric Erfanian938468d2017-10-24 14:05:52 -0700121 }
122 }
123 };
124
125 private BubbleExpansionStateListener bubbleExpansionStateListener;
126
127 /** Type of action after bubble collapse */
128 @Retention(RetentionPolicy.SOURCE)
129 @IntDef({CollapseEnd.NOTHING, CollapseEnd.HIDE})
130 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
131 public @interface CollapseEnd {
132 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 : 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 {
183 NewBubble createBubble(@NonNull Context context, @NonNull Handler handler);
184 }
185
186 private static BubbleFactory bubbleFactory = NewBubble::new;
187
188 public static NewBubble createBubble(@NonNull Context context, @NonNull NewBubbleInfo info) {
189 NewBubble bubble = bubbleFactory.createBubble(context, new Handler());
190 bubble.setBubbleInfo(info);
191 return bubble;
192 }
193
194 @VisibleForTesting
195 public static void setBubbleFactory(@NonNull BubbleFactory bubbleFactory) {
196 NewBubble.bubbleFactory = bubbleFactory;
197 }
198
199 @VisibleForTesting
200 public static void resetBubbleFactory() {
201 NewBubble.bubbleFactory = NewBubble::new;
202 }
203
204 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
205 NewBubble(@NonNull Context context, @NonNull Handler handler) {
206 context = new ContextThemeWrapper(context, R.style.Theme_AppCompat);
207 this.context = context;
208 this.handler = handler;
209 windowManager = context.getSystemService(WindowManager.class);
210
211 viewHolder = new ViewHolder(context);
212 }
213
214 /** Expands the main bubble menu. */
215 public void expand(boolean isUserAction) {
216 if (bubbleExpansionStateListener != null) {
217 bubbleExpansionStateListener.onBubbleExpansionStateChanged(
218 ExpansionState.START_EXPANDING, isUserAction);
219 }
220 doResize(() -> viewHolder.setDrawerVisibility(View.VISIBLE));
221 View expandedView = viewHolder.getExpandedView();
222 expandedView
223 .getViewTreeObserver()
224 .addOnPreDrawListener(
225 new OnPreDrawListener() {
226 @Override
227 public boolean onPreDraw() {
228 // Animate expanded view to move from above primary button to its final position
229 expandedView.getViewTreeObserver().removeOnPreDrawListener(this);
230 expandedView.setTranslationY(-viewHolder.getRoot().getHeight());
231 expandedView
232 .animate()
233 .setInterpolator(new LinearOutSlowInInterpolator())
234 .translationY(0);
235 return false;
236 }
237 });
238 setFocused(true);
239 expanded = true;
240 }
241
242 /**
243 * Make the bubble visible. Will show a short entrance animation as it enters. If the bubble is
244 * already showing this method does nothing.
245 */
246 public void show() {
247 if (collapseEndAction == CollapseEnd.HIDE) {
248 // If show() was called while collapsing, make sure we don't hide after.
249 collapseEndAction = CollapseEnd.NOTHING;
250 }
251 if (visibility == Visibility.SHOWING || visibility == Visibility.ENTERING) {
252 return;
253 }
254
255 hideAfterText = false;
256
257 if (windowParams == null) {
258 // Apps targeting O+ must use TYPE_APPLICATION_OVERLAY, which is not available prior to O.
259 @SuppressWarnings("deprecation")
260 @SuppressLint("InlinedApi")
261 int type =
262 BuildCompat.isAtLeastO()
263 ? LayoutParams.TYPE_APPLICATION_OVERLAY
264 : LayoutParams.TYPE_PHONE;
265
266 windowParams =
267 new LayoutParams(
268 type,
269 LayoutParams.FLAG_NOT_TOUCH_MODAL
270 | LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
271 | LayoutParams.FLAG_NOT_FOCUSABLE
272 | LayoutParams.FLAG_LAYOUT_NO_LIMITS,
273 PixelFormat.TRANSLUCENT);
274 windowParams.gravity = Gravity.TOP | Gravity.LEFT;
275 windowParams.x =
276 context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_horizontal);
277 windowParams.y = currentInfo.getStartingYPosition();
278 windowParams.height = LayoutParams.WRAP_CONTENT;
279 windowParams.width = LayoutParams.WRAP_CONTENT;
280 }
281
282 if (exitAnimator != null) {
283 exitAnimator.cancel();
284 exitAnimator = null;
285 } else {
286 windowManager.addView(viewHolder.getRoot(), windowParams);
287 viewHolder.getPrimaryButton().setScaleX(0);
288 viewHolder.getPrimaryButton().setScaleY(0);
289 }
290
291 viewHolder.setChildClickable(true);
292 visibility = Visibility.ENTERING;
293 viewHolder
294 .getPrimaryButton()
295 .animate()
296 .setInterpolator(new OvershootInterpolator())
297 .scaleX(1)
298 .scaleY(1)
299 .withEndAction(
300 () -> {
301 visibility = Visibility.SHOWING;
302 // Show the queued up text, if available.
303 if (textAfterShow != null) {
304 showText(textAfterShow);
305 textAfterShow = null;
306 }
307 })
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 NewBubbleInfo 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 = NewBubbleInfo.from(currentInfo).setActions(actions).build();
355 updateButtonStates();
356 }
357
yuega5a08d82017-10-31 14:11:53 -0700358 /**
359 * Update the avatar from photo.
360 *
361 * @param avatar the new photo avatar in the bubble's primary button
362 */
363 public void updatePhotoAvatar(@NonNull Drawable avatar) {
364 // Make it round
365 int bubbleSize = context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
366 Drawable roundAvatar =
367 DrawableConverter.getRoundedDrawable(context, avatar, bubbleSize, bubbleSize);
368
369 updateAvatar(roundAvatar);
370 }
371
372 /**
373 * Update the avatar.
374 *
375 * @param avatar the new avatar in the bubble's primary button
376 */
377 public void updateAvatar(@NonNull Drawable avatar) {
378 if (!avatar.equals(currentInfo.getAvatar())) {
379 currentInfo = NewBubbleInfo.from(currentInfo).setAvatar(avatar).build();
380 viewHolder.getPrimaryAvatar().setImageDrawable(currentInfo.getAvatar());
381 }
382 }
383
Eric Erfanian938468d2017-10-24 14:05:52 -0700384 /** Returns the currently displayed NewBubbleInfo */
385 public NewBubbleInfo getBubbleInfo() {
386 return currentInfo;
387 }
388
389 /**
390 * Display text in the main bubble. The bubble's drawer is not expandable while text is showing,
391 * and the drawer will be closed if already open.
392 *
393 * @param text the text to display to the user
394 */
395 public void showText(@NonNull CharSequence text) {
396 textShowing = true;
397 if (expanded) {
398 startCollapse(CollapseEnd.NOTHING, false);
399 doShowText(text);
400 } else {
401 // Need to transition from old bounds to new bounds manually
402 NewChangeOnScreenBounds transition = new NewChangeOnScreenBounds();
403 // Prepare and capture start values
404 TransitionValues startValues = new TransitionValues();
405 startValues.view = viewHolder.getPrimaryButton();
406 transition.addTarget(startValues.view);
407 transition.captureStartValues(startValues);
408
409 // If our view is not laid out yet, postpone showing the text.
410 if (startValues.values.isEmpty()) {
411 textAfterShow = text;
412 return;
413 }
414
415 doResize(
416 () -> {
417 doShowText(text);
418 // Hide the text so we can animate it in
419 viewHolder.getPrimaryText().setAlpha(0);
420
421 ViewAnimator primaryButton = viewHolder.getPrimaryButton();
422 // Cancel the automatic transition scheduled in doShowText
423 TransitionManager.endTransitions((ViewGroup) primaryButton.getParent());
424 primaryButton
425 .getViewTreeObserver()
426 .addOnPreDrawListener(
427 new OnPreDrawListener() {
428 @Override
429 public boolean onPreDraw() {
430 primaryButton.getViewTreeObserver().removeOnPreDrawListener(this);
431
432 // Prepare and capture end values, always use the size of primaryText since
433 // its invisibility makes primaryButton smaller than expected
434 TransitionValues endValues = new TransitionValues();
435 endValues.values.put(
436 NewChangeOnScreenBounds.PROPNAME_WIDTH,
437 viewHolder.getPrimaryText().getWidth());
438 endValues.values.put(
439 NewChangeOnScreenBounds.PROPNAME_HEIGHT,
440 viewHolder.getPrimaryText().getHeight());
441 endValues.view = primaryButton;
442 transition.addTarget(endValues.view);
443 transition.captureEndValues(endValues);
444
445 // animate the primary button bounds change
446 Animator bounds =
447 transition.createAnimator(primaryButton, startValues, endValues);
448
449 // Animate the text in
450 Animator alpha =
451 ObjectAnimator.ofFloat(viewHolder.getPrimaryText(), View.ALPHA, 1f);
452
453 AnimatorSet set = new AnimatorSet();
454 set.play(bounds).before(alpha);
455 set.start();
456 return false;
457 }
458 });
459 });
460 }
461 handler.removeCallbacks(collapseRunnable);
462 handler.postDelayed(collapseRunnable, SHOW_TEXT_DURATION_MILLIS);
463 }
464
465 public void setBubbleExpansionStateListener(
466 BubbleExpansionStateListener bubbleExpansionStateListener) {
467 this.bubbleExpansionStateListener = bubbleExpansionStateListener;
468 }
469
470 @Nullable
471 Integer getGravityOverride() {
472 return overrideGravity;
473 }
474
475 void onMoveStart() {
476 startCollapse(CollapseEnd.NOTHING, true);
477 viewHolder
478 .getPrimaryButton()
479 .animate()
480 .translationZ(
481 context.getResources().getDimensionPixelOffset(R.dimen.bubble_move_elevation_change));
482 }
483
484 void onMoveFinish() {
485 viewHolder.getPrimaryButton().animate().translationZ(0);
486 // If it's GONE, no resize is necessary. If it's VISIBLE, it will get cleaned up when the
487 // collapse animation finishes
488 if (viewHolder.getExpandedView().getVisibility() == View.INVISIBLE) {
489 doResize(null);
490 }
491 }
492
493 void primaryButtonClick() {
494 if (textShowing || currentInfo.getActions().isEmpty()) {
495 return;
496 }
497 if (expanded) {
498 startCollapse(CollapseEnd.NOTHING, true);
499 } else {
500 expand(true);
501 }
502 }
503
504 LayoutParams getWindowParams() {
505 return windowParams;
506 }
507
508 View getRootView() {
509 return viewHolder.getRoot();
510 }
511
512 /**
513 * Hide the bubble if visible. Will run a short exit animation and before hiding, and {@code
514 * afterHiding} after hiding. If the bubble is currently showing text, will hide after the text is
515 * done displaying. If the bubble is not visible this method does nothing.
516 */
517 private void hideHelper(Runnable afterHiding) {
518 if (visibility == Visibility.HIDDEN || visibility == Visibility.EXITING) {
519 return;
520 }
521
522 // Make bubble non clickable to prevent further buggy actions
523 viewHolder.setChildClickable(false);
524
525 if (textShowing) {
526 hideAfterText = true;
527 return;
528 }
529
530 if (collapseAnimation != null) {
531 collapseEndAction = CollapseEnd.HIDE;
532 return;
533 }
534
535 if (expanded) {
536 startCollapse(CollapseEnd.HIDE, false);
537 return;
538 }
539
540 visibility = Visibility.EXITING;
541 exitAnimator =
542 viewHolder
543 .getPrimaryButton()
544 .animate()
545 .setInterpolator(new AnticipateInterpolator())
546 .scaleX(0)
547 .scaleY(0)
548 .withEndAction(afterHiding);
549 exitAnimator.start();
550 }
551
552 private void reset() {
553 viewHolder = new ViewHolder(viewHolder.getRoot().getContext());
554 update();
555 }
556
557 private void update() {
yuega5a08d82017-10-31 14:11:53 -0700558 // Whole primary button background
Eric Erfanian938468d2017-10-24 14:05:52 -0700559 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());
yuega5a08d82017-10-31 14:11:53 -0700566 backgroundRipple.getDrawable(0).mutate().setTint(primaryTint);
Eric Erfanian938468d2017-10-24 14:05:52 -0700567 viewHolder.getPrimaryButton().setBackground(backgroundRipple);
568
yuega5a08d82017-10-31 14:11:53 -0700569 // Small icon
570 RippleDrawable smallIconBackgroundRipple =
571 (RippleDrawable)
572 context
573 .getResources()
574 .getDrawable(R.drawable.bubble_ripple_circle_small, context.getTheme());
575 smallIconBackgroundRipple
576 .getDrawable(0)
577 .setTint(context.getColor(R.color.bubble_button_text_color_blue));
578 viewHolder.getPrimaryIcon().setBackground(smallIconBackgroundRipple);
Eric Erfanian938468d2017-10-24 14:05:52 -0700579 viewHolder.getPrimaryIcon().setImageIcon(currentInfo.getPrimaryIcon());
yuega5a08d82017-10-31 14:11:53 -0700580 viewHolder.getPrimaryAvatar().setImageDrawable(currentInfo.getAvatar());
Eric Erfanian938468d2017-10-24 14:05:52 -0700581
yuega5a08d82017-10-31 14:11:53 -0700582 updatePrimaryIconAnimation();
Eric Erfanian938468d2017-10-24 14:05:52 -0700583 updateButtonStates();
584 }
585
586 private void updatePrimaryIconAnimation() {
587 Drawable drawable = viewHolder.getPrimaryIcon().getDrawable();
588 if (drawable instanceof Animatable) {
589 if (isVisible()) {
590 ((Animatable) drawable).start();
591 } else {
592 ((Animatable) drawable).stop();
593 }
594 }
595 }
596
597 private void updateButtonStates() {
598 int colorBlue = context.getColor(R.color.bubble_button_text_color_blue);
599 int colorWhite = context.getColor(R.color.bubble_button_text_color_white);
600
601 configureButton(currentInfo.getActions().get(0), viewHolder.getFullScreenButton(), colorBlue);
602 configureButton(currentInfo.getActions().get(1), viewHolder.getMuteButton(), colorBlue);
603 configureButton(currentInfo.getActions().get(2), viewHolder.getAudioRouteButton(), colorBlue);
604 configureButton(currentInfo.getActions().get(3), viewHolder.getEndCallButton(), colorWhite);
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, NewCheckableButton button, @ColorInt int iconColor) {
614 Drawable iconDrawable = DrawableCompat.wrap(action.getIconDrawable());
615 DrawableCompat.setTint(iconDrawable.mutate(), iconColor);
616
617 button.setCompoundDrawablesWithIntrinsicBounds(iconDrawable, null, null, null);
618 button.setChecked(action.isChecked());
619 button.setEnabled(action.isEnabled());
620 if (action.getName() != null) {
621 button.setText(action.getName());
622 }
623 button.setOnClickListener(v -> doAction(action));
624 }
625
626 private void doAction(Action action) {
627 try {
628 action.getIntent().send();
629 } catch (CanceledException e) {
630 throw new RuntimeException(e);
631 }
632 }
633
634 private void doResize(@Nullable Runnable operation) {
635 // If we're resizing on the right side of the screen, there is an implicit move operation
636 // necessary. The WindowManager does not sync the move and resize operations, so serious jank
637 // would occur. To fix this, instead of resizing the window, we create a new one and destroy
638 // the old one. There is a short delay before destroying the old view to ensure the new one has
639 // had time to draw.
640 ViewHolder oldViewHolder = viewHolder;
641 if (isDrawingFromRight()) {
642 viewHolder = new ViewHolder(oldViewHolder.getRoot().getContext());
643 update();
644 viewHolder
645 .getPrimaryButton()
646 .setDisplayedChild(oldViewHolder.getPrimaryButton().getDisplayedChild());
647 viewHolder.getPrimaryText().setText(oldViewHolder.getPrimaryText().getText());
648 }
649
650 if (operation != null) {
651 operation.run();
652 }
653
654 if (isDrawingFromRight()) {
655 swapViewHolders(oldViewHolder);
656 }
657 }
658
659 private void swapViewHolders(ViewHolder oldViewHolder) {
660 ViewGroup root = viewHolder.getRoot();
661 windowManager.addView(root, windowParams);
662 root.getViewTreeObserver()
663 .addOnPreDrawListener(
664 new OnPreDrawListener() {
665 @Override
666 public boolean onPreDraw() {
667 root.getViewTreeObserver().removeOnPreDrawListener(this);
668 // Wait a bit before removing the old view; make sure the new one has drawn over it.
669 handler.postDelayed(
670 () -> windowManager.removeView(oldViewHolder.getRoot()),
671 WINDOW_REDRAW_DELAY_MILLIS);
672 return true;
673 }
674 });
675 }
676
677 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
678 public void startCollapse(@CollapseEnd int endAction, boolean isUserAction) {
679 View expandedView = viewHolder.getExpandedView();
680 if (expandedView.getVisibility() != View.VISIBLE || collapseAnimation != null) {
681 // Drawer is already collapsed or animation is running.
682 return;
683 }
684
685 overrideGravity = isDrawingFromRight() ? Gravity.RIGHT : Gravity.LEFT;
686 setFocused(false);
687
688 if (collapseEndAction == CollapseEnd.NOTHING) {
689 collapseEndAction = endAction;
690 }
691 if (bubbleExpansionStateListener != null && collapseEndAction == CollapseEnd.NOTHING) {
692 bubbleExpansionStateListener.onBubbleExpansionStateChanged(
693 ExpansionState.START_COLLAPSING, isUserAction);
694 }
695 // Animate expanded view to move from its position to above primary button and hide
696 collapseAnimation =
697 expandedView
698 .animate()
699 .translationY(-viewHolder.getRoot().getHeight())
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
yuega5a08d82017-10-31 14:11:53 -0700760 public static final int CHILD_INDEX_AVATAR_AND_ICON = 0;
Eric Erfanian938468d2017-10-24 14:05:52 -0700761 public static final int CHILD_INDEX_TEXT = 1;
762
763 private final NewMoveHandler moveHandler;
764 private final NewWindowRoot root;
765 private final ViewAnimator primaryButton;
766 private final ImageView primaryIcon;
yuega5a08d82017-10-31 14:11:53 -0700767 private final ImageView primaryAvatar;
Eric Erfanian938468d2017-10-24 14:05:52 -0700768 private final TextView primaryText;
769
770 private final NewCheckableButton fullScreenButton;
771 private final NewCheckableButton muteButton;
772 private final NewCheckableButton audioRouteButton;
773 private final NewCheckableButton endCallButton;
774 private final View expandedView;
775
776 public ViewHolder(Context context) {
777 // Window root is not in the layout file so that the inflater has a view to inflate into
778 this.root = new NewWindowRoot(context);
779 LayoutInflater inflater = LayoutInflater.from(root.getContext());
780 View contentView = inflater.inflate(R.layout.new_bubble_base, root, true);
781 expandedView = contentView.findViewById(R.id.bubble_expanded_layout);
782 primaryButton = contentView.findViewById(R.id.bubble_button_primary);
yuega5a08d82017-10-31 14:11:53 -0700783 primaryAvatar = contentView.findViewById(R.id.bubble_icon_avatar);
Eric Erfanian938468d2017-10-24 14:05:52 -0700784 primaryIcon = contentView.findViewById(R.id.bubble_icon_primary);
785 primaryText = contentView.findViewById(R.id.bubble_text);
786
787 fullScreenButton = contentView.findViewById(R.id.bubble_button_full_screen);
788 muteButton = contentView.findViewById(R.id.bubble_button_mute);
789 audioRouteButton = contentView.findViewById(R.id.bubble_button_audio_route);
790 endCallButton = contentView.findViewById(R.id.bubble_button_end_call);
791
792 root.setOnBackPressedListener(
793 () -> {
794 if (visibility == Visibility.SHOWING && expanded) {
795 startCollapse(CollapseEnd.NOTHING, true);
796 return true;
797 }
798 return false;
799 });
800 root.setOnConfigurationChangedListener(
801 (configuration) -> {
802 // The values in the current MoveHandler may be stale, so replace it. Then ensure the
803 // Window is in bounds
804 moveHandler = new NewMoveHandler(primaryButton, NewBubble.this);
805 moveHandler.snapToBounds();
806 });
807 root.setOnTouchListener(
808 (v, event) -> {
809 if (expanded && event.getActionMasked() == MotionEvent.ACTION_OUTSIDE) {
810 startCollapse(CollapseEnd.NOTHING, true);
811 return true;
812 }
813 return false;
814 });
815 moveHandler = new NewMoveHandler(primaryButton, NewBubble.this);
816 }
817
818 private void setChildClickable(boolean clickable) {
819 fullScreenButton.setClickable(clickable);
820 muteButton.setClickable(clickable);
821 audioRouteButton.setClickable(clickable);
822 endCallButton.setClickable(clickable);
823
824 // For primaryButton
825 moveHandler.setClickable(clickable);
826 }
827
828 public ViewGroup getRoot() {
829 return root;
830 }
831
832 public ViewAnimator getPrimaryButton() {
833 return primaryButton;
834 }
835
836 public ImageView getPrimaryIcon() {
837 return primaryIcon;
838 }
839
yuega5a08d82017-10-31 14:11:53 -0700840 public ImageView getPrimaryAvatar() {
841 return primaryAvatar;
842 }
843
Eric Erfanian938468d2017-10-24 14:05:52 -0700844 public TextView getPrimaryText() {
845 return primaryText;
846 }
847
848 public View getExpandedView() {
849 return expandedView;
850 }
851
852 public NewCheckableButton getFullScreenButton() {
853 return fullScreenButton;
854 }
855
856 public NewCheckableButton getMuteButton() {
857 return muteButton;
858 }
859
860 public NewCheckableButton getAudioRouteButton() {
861 return audioRouteButton;
862 }
863
864 public NewCheckableButton getEndCallButton() {
865 return endCallButton;
866 }
867
868 public void setDrawerVisibility(int visibility) {
869 expandedView.setVisibility(visibility);
870 }
871
872 public boolean isMoving() {
873 return moveHandler.isMoving();
874 }
875
876 public void undoGravityOverride() {
877 moveHandler.undoGravityOverride();
878 }
879 }
880
881 /** Listener for bubble expansion state change. */
882 public interface BubbleExpansionStateListener {
883 void onBubbleExpansionStateChanged(@ExpansionState int expansionState, boolean isUserAction);
884 }
885}