blob: e690f4be47de48d1b13469347f9843e8d3c9d3e7 [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;
Eric Erfanian938468d2017-10-24 14:05:52 -070029import android.net.Uri;
30import android.os.Handler;
31import android.provider.Settings;
Eric Erfanian938468d2017-10-24 14:05:52 -070032import android.support.annotation.IntDef;
33import android.support.annotation.NonNull;
34import android.support.annotation.Nullable;
35import android.support.annotation.VisibleForTesting;
36import android.support.v4.graphics.ColorUtils;
Eric Erfanian938468d2017-10-24 14:05:52 -070037import android.support.v4.os.BuildCompat;
38import android.support.v4.view.animation.FastOutLinearInInterpolator;
39import android.support.v4.view.animation.LinearOutSlowInInterpolator;
40import android.transition.TransitionManager;
41import android.transition.TransitionValues;
42import android.view.ContextThemeWrapper;
43import android.view.Gravity;
44import android.view.LayoutInflater;
45import android.view.MotionEvent;
46import android.view.View;
47import android.view.ViewGroup;
48import android.view.ViewPropertyAnimator;
49import android.view.ViewTreeObserver.OnPreDrawListener;
50import android.view.WindowManager;
51import android.view.WindowManager.LayoutParams;
52import android.view.animation.AnticipateInterpolator;
53import android.view.animation.OvershootInterpolator;
54import android.widget.ImageView;
55import android.widget.TextView;
56import android.widget.ViewAnimator;
yuega5a08d82017-10-31 14:11:53 -070057import com.android.dialer.util.DrawableConverter;
Eric Erfanian938468d2017-10-24 14:05:52 -070058import com.android.newbubble.NewBubbleInfo.Action;
59import java.lang.annotation.Retention;
60import java.lang.annotation.RetentionPolicy;
61import java.util.List;
62
63/**
64 * Creates and manages a bubble window from information in a {@link NewBubbleInfo}. Before creating,
65 * be sure to check whether bubbles may be shown using {@link #canShowBubbles(Context)} and request
66 * permission if necessary ({@link #getRequestPermissionIntent(Context)} is provided for
67 * convenience)
68 */
69public class NewBubble {
70 // This class has some odd behavior that is not immediately obvious in order to avoid jank when
71 // resizing. See http://go/bubble-resize for details.
72
73 // How long text should show after showText(CharSequence) is called
74 private static final int SHOW_TEXT_DURATION_MILLIS = 3000;
75 // How long the new window should show before destroying the old one during resize operations.
76 // This ensures the new window has had time to draw first.
77 private static final int WINDOW_REDRAW_DELAY_MILLIS = 50;
78
79 private static Boolean canShowBubblesForTesting = null;
80
81 private final Context context;
82 private final WindowManager windowManager;
83
84 private final Handler handler;
85 private LayoutParams windowParams;
86
87 // Initialized in factory method
88 @SuppressWarnings("NullableProblems")
89 @NonNull
90 private NewBubbleInfo currentInfo;
91
92 @Visibility private int visibility;
93 private boolean expanded;
94 private boolean textShowing;
95 private boolean hideAfterText;
96 private CharSequence textAfterShow;
97 private int collapseEndAction;
98
99 @VisibleForTesting ViewHolder viewHolder;
100 private ViewPropertyAnimator collapseAnimation;
101 private Integer overrideGravity;
102 private ViewPropertyAnimator exitAnimator;
103
104 private final Runnable collapseRunnable =
105 new Runnable() {
106 @Override
107 public void run() {
108 textShowing = false;
109 if (hideAfterText) {
110 // Always reset here since text shouldn't keep showing.
111 hideAndReset();
112 } else {
113 doResize(
yuega5a08d82017-10-31 14:11:53 -0700114 () ->
115 viewHolder
116 .getPrimaryButton()
117 .setDisplayedChild(ViewHolder.CHILD_INDEX_AVATAR_AND_ICON));
Eric Erfanian938468d2017-10-24 14:05:52 -0700118 }
119 }
120 };
121
122 private BubbleExpansionStateListener bubbleExpansionStateListener;
123
124 /** Type of action after bubble collapse */
125 @Retention(RetentionPolicy.SOURCE)
126 @IntDef({CollapseEnd.NOTHING, CollapseEnd.HIDE})
127 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
128 public @interface CollapseEnd {
129 int NOTHING = 0;
130 int HIDE = 1;
131 }
132
133 @Retention(RetentionPolicy.SOURCE)
134 @IntDef({Visibility.ENTERING, Visibility.SHOWING, Visibility.EXITING, Visibility.HIDDEN})
135 private @interface Visibility {
136 int HIDDEN = 0;
137 int ENTERING = 1;
138 int SHOWING = 2;
139 int EXITING = 3;
140 }
141
142 /** Indicate bubble expansion state. */
143 @Retention(RetentionPolicy.SOURCE)
144 @IntDef({ExpansionState.START_EXPANDING, ExpansionState.START_COLLAPSING})
145 public @interface ExpansionState {
146 // TODO(yueg): add more states when needed
147 int START_EXPANDING = 0;
148 int START_COLLAPSING = 1;
149 }
150
151 /**
152 * Determines whether bubbles can be shown based on permissions obtained. This should be checked
153 * before attempting to create a Bubble.
154 *
155 * @return true iff bubbles are able to be shown.
156 * @see Settings#canDrawOverlays(Context)
157 */
158 public static boolean canShowBubbles(@NonNull Context context) {
159 return canShowBubblesForTesting != null
160 ? canShowBubblesForTesting
161 : Settings.canDrawOverlays(context);
162 }
163
164 @VisibleForTesting(otherwise = VisibleForTesting.NONE)
165 public static void setCanShowBubblesForTesting(boolean canShowBubbles) {
166 canShowBubblesForTesting = canShowBubbles;
167 }
168
169 /** Returns an Intent to request permission to show overlays */
170 @NonNull
171 public static Intent getRequestPermissionIntent(@NonNull Context context) {
172 return new Intent(
173 Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
174 Uri.fromParts("package", context.getPackageName(), null));
175 }
176
177 /** Creates instances of Bubble. The default implementation just calls the constructor. */
178 @VisibleForTesting
179 public interface BubbleFactory {
180 NewBubble createBubble(@NonNull Context context, @NonNull Handler handler);
181 }
182
183 private static BubbleFactory bubbleFactory = NewBubble::new;
184
185 public static NewBubble createBubble(@NonNull Context context, @NonNull NewBubbleInfo info) {
186 NewBubble bubble = bubbleFactory.createBubble(context, new Handler());
187 bubble.setBubbleInfo(info);
188 return bubble;
189 }
190
191 @VisibleForTesting
192 public static void setBubbleFactory(@NonNull BubbleFactory bubbleFactory) {
193 NewBubble.bubbleFactory = bubbleFactory;
194 }
195
196 @VisibleForTesting
197 public static void resetBubbleFactory() {
198 NewBubble.bubbleFactory = NewBubble::new;
199 }
200
201 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
202 NewBubble(@NonNull Context context, @NonNull Handler handler) {
203 context = new ContextThemeWrapper(context, R.style.Theme_AppCompat);
204 this.context = context;
205 this.handler = handler;
206 windowManager = context.getSystemService(WindowManager.class);
207
208 viewHolder = new ViewHolder(context);
209 }
210
211 /** Expands the main bubble menu. */
212 public void expand(boolean isUserAction) {
213 if (bubbleExpansionStateListener != null) {
214 bubbleExpansionStateListener.onBubbleExpansionStateChanged(
215 ExpansionState.START_EXPANDING, isUserAction);
216 }
217 doResize(() -> viewHolder.setDrawerVisibility(View.VISIBLE));
218 View expandedView = viewHolder.getExpandedView();
219 expandedView
220 .getViewTreeObserver()
221 .addOnPreDrawListener(
222 new OnPreDrawListener() {
223 @Override
224 public boolean onPreDraw() {
225 // Animate expanded view to move from above primary button to its final position
226 expandedView.getViewTreeObserver().removeOnPreDrawListener(this);
227 expandedView.setTranslationY(-viewHolder.getRoot().getHeight());
228 expandedView
229 .animate()
230 .setInterpolator(new LinearOutSlowInInterpolator())
231 .translationY(0);
232 return false;
233 }
234 });
235 setFocused(true);
236 expanded = true;
237 }
238
239 /**
240 * Make the bubble visible. Will show a short entrance animation as it enters. If the bubble is
241 * already showing this method does nothing.
242 */
243 public void show() {
244 if (collapseEndAction == CollapseEnd.HIDE) {
245 // If show() was called while collapsing, make sure we don't hide after.
246 collapseEndAction = CollapseEnd.NOTHING;
247 }
248 if (visibility == Visibility.SHOWING || visibility == Visibility.ENTERING) {
249 return;
250 }
251
252 hideAfterText = false;
253
254 if (windowParams == null) {
255 // Apps targeting O+ must use TYPE_APPLICATION_OVERLAY, which is not available prior to O.
256 @SuppressWarnings("deprecation")
257 @SuppressLint("InlinedApi")
258 int type =
259 BuildCompat.isAtLeastO()
260 ? LayoutParams.TYPE_APPLICATION_OVERLAY
261 : LayoutParams.TYPE_PHONE;
262
263 windowParams =
264 new LayoutParams(
265 type,
266 LayoutParams.FLAG_NOT_TOUCH_MODAL
267 | LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
268 | LayoutParams.FLAG_NOT_FOCUSABLE
269 | LayoutParams.FLAG_LAYOUT_NO_LIMITS,
270 PixelFormat.TRANSLUCENT);
271 windowParams.gravity = Gravity.TOP | Gravity.LEFT;
272 windowParams.x =
273 context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_horizontal);
274 windowParams.y = currentInfo.getStartingYPosition();
275 windowParams.height = LayoutParams.WRAP_CONTENT;
276 windowParams.width = LayoutParams.WRAP_CONTENT;
277 }
278
279 if (exitAnimator != null) {
280 exitAnimator.cancel();
281 exitAnimator = null;
282 } else {
283 windowManager.addView(viewHolder.getRoot(), windowParams);
284 viewHolder.getPrimaryButton().setScaleX(0);
285 viewHolder.getPrimaryButton().setScaleY(0);
286 }
287
288 viewHolder.setChildClickable(true);
289 visibility = Visibility.ENTERING;
290 viewHolder
291 .getPrimaryButton()
292 .animate()
293 .setInterpolator(new OvershootInterpolator())
294 .scaleX(1)
295 .scaleY(1)
296 .withEndAction(
297 () -> {
298 visibility = Visibility.SHOWING;
299 // Show the queued up text, if available.
300 if (textAfterShow != null) {
301 showText(textAfterShow);
302 textAfterShow = null;
303 }
304 })
305 .start();
306
307 updatePrimaryIconAnimation();
308 }
309
310 /** Hide the bubble. */
311 public void hide() {
312 if (hideAfterText) {
313 // hideAndReset() will be called after showing text, do nothing here.
314 return;
315 }
316 hideHelper(this::defaultAfterHidingAnimation);
317 }
318
319 /** Hide the bubble and reset {@viewHolder} to initial state */
320 public void hideAndReset() {
321 hideHelper(
322 () -> {
323 defaultAfterHidingAnimation();
324 reset();
325 });
326 }
327
328 /** Returns whether the bubble is currently visible */
329 public boolean isVisible() {
330 return visibility == Visibility.SHOWING
331 || visibility == Visibility.ENTERING
332 || visibility == Visibility.EXITING;
333 }
334
335 /**
336 * Set the info for this Bubble to display
337 *
338 * @param bubbleInfo the BubbleInfo to display in this Bubble.
339 */
340 public void setBubbleInfo(@NonNull NewBubbleInfo bubbleInfo) {
341 currentInfo = bubbleInfo;
342 update();
343 }
344
345 /**
346 * Update the state and behavior of actions.
347 *
348 * @param actions the new state of the bubble's actions
349 */
350 public void updateActions(@NonNull List<Action> actions) {
351 currentInfo = NewBubbleInfo.from(currentInfo).setActions(actions).build();
352 updateButtonStates();
353 }
354
yuega5a08d82017-10-31 14:11:53 -0700355 /**
356 * Update the avatar from photo.
357 *
358 * @param avatar the new photo avatar in the bubble's primary button
359 */
360 public void updatePhotoAvatar(@NonNull Drawable avatar) {
361 // Make it round
362 int bubbleSize = context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
363 Drawable roundAvatar =
364 DrawableConverter.getRoundedDrawable(context, avatar, bubbleSize, bubbleSize);
365
366 updateAvatar(roundAvatar);
367 }
368
369 /**
370 * Update the avatar.
371 *
372 * @param avatar the new avatar in the bubble's primary button
373 */
374 public void updateAvatar(@NonNull Drawable avatar) {
375 if (!avatar.equals(currentInfo.getAvatar())) {
376 currentInfo = NewBubbleInfo.from(currentInfo).setAvatar(avatar).build();
377 viewHolder.getPrimaryAvatar().setImageDrawable(currentInfo.getAvatar());
378 }
379 }
380
Eric Erfanian938468d2017-10-24 14:05:52 -0700381 /** Returns the currently displayed NewBubbleInfo */
382 public NewBubbleInfo getBubbleInfo() {
383 return currentInfo;
384 }
385
386 /**
387 * Display text in the main bubble. The bubble's drawer is not expandable while text is showing,
388 * and the drawer will be closed if already open.
389 *
390 * @param text the text to display to the user
391 */
392 public void showText(@NonNull CharSequence text) {
393 textShowing = true;
394 if (expanded) {
395 startCollapse(CollapseEnd.NOTHING, false);
396 doShowText(text);
397 } else {
398 // Need to transition from old bounds to new bounds manually
399 NewChangeOnScreenBounds transition = new NewChangeOnScreenBounds();
400 // Prepare and capture start values
401 TransitionValues startValues = new TransitionValues();
402 startValues.view = viewHolder.getPrimaryButton();
403 transition.addTarget(startValues.view);
404 transition.captureStartValues(startValues);
405
406 // If our view is not laid out yet, postpone showing the text.
407 if (startValues.values.isEmpty()) {
408 textAfterShow = text;
409 return;
410 }
411
412 doResize(
413 () -> {
414 doShowText(text);
415 // Hide the text so we can animate it in
416 viewHolder.getPrimaryText().setAlpha(0);
417
418 ViewAnimator primaryButton = viewHolder.getPrimaryButton();
419 // Cancel the automatic transition scheduled in doShowText
420 TransitionManager.endTransitions((ViewGroup) primaryButton.getParent());
421 primaryButton
422 .getViewTreeObserver()
423 .addOnPreDrawListener(
424 new OnPreDrawListener() {
425 @Override
426 public boolean onPreDraw() {
427 primaryButton.getViewTreeObserver().removeOnPreDrawListener(this);
428
429 // Prepare and capture end values, always use the size of primaryText since
430 // its invisibility makes primaryButton smaller than expected
431 TransitionValues endValues = new TransitionValues();
432 endValues.values.put(
433 NewChangeOnScreenBounds.PROPNAME_WIDTH,
434 viewHolder.getPrimaryText().getWidth());
435 endValues.values.put(
436 NewChangeOnScreenBounds.PROPNAME_HEIGHT,
437 viewHolder.getPrimaryText().getHeight());
438 endValues.view = primaryButton;
439 transition.addTarget(endValues.view);
440 transition.captureEndValues(endValues);
441
442 // animate the primary button bounds change
443 Animator bounds =
444 transition.createAnimator(primaryButton, startValues, endValues);
445
446 // Animate the text in
447 Animator alpha =
448 ObjectAnimator.ofFloat(viewHolder.getPrimaryText(), View.ALPHA, 1f);
449
450 AnimatorSet set = new AnimatorSet();
451 set.play(bounds).before(alpha);
452 set.start();
453 return false;
454 }
455 });
456 });
457 }
458 handler.removeCallbacks(collapseRunnable);
459 handler.postDelayed(collapseRunnable, SHOW_TEXT_DURATION_MILLIS);
460 }
461
462 public void setBubbleExpansionStateListener(
463 BubbleExpansionStateListener bubbleExpansionStateListener) {
464 this.bubbleExpansionStateListener = bubbleExpansionStateListener;
465 }
466
467 @Nullable
468 Integer getGravityOverride() {
469 return overrideGravity;
470 }
471
472 void onMoveStart() {
473 startCollapse(CollapseEnd.NOTHING, true);
474 viewHolder
475 .getPrimaryButton()
476 .animate()
477 .translationZ(
yuegc6deafc2017-11-06 16:42:13 -0800478 context
479 .getResources()
480 .getDimensionPixelOffset(R.dimen.bubble_dragging_elevation_change));
Eric Erfanian938468d2017-10-24 14:05:52 -0700481 }
482
483 void onMoveFinish() {
484 viewHolder.getPrimaryButton().animate().translationZ(0);
485 // If it's GONE, no resize is necessary. If it's VISIBLE, it will get cleaned up when the
486 // collapse animation finishes
487 if (viewHolder.getExpandedView().getVisibility() == View.INVISIBLE) {
488 doResize(null);
489 }
490 }
491
492 void primaryButtonClick() {
493 if (textShowing || currentInfo.getActions().isEmpty()) {
494 return;
495 }
496 if (expanded) {
497 startCollapse(CollapseEnd.NOTHING, true);
498 } else {
499 expand(true);
500 }
501 }
502
503 LayoutParams getWindowParams() {
504 return windowParams;
505 }
506
507 View getRootView() {
508 return viewHolder.getRoot();
509 }
510
511 /**
512 * Hide the bubble if visible. Will run a short exit animation and before hiding, and {@code
513 * afterHiding} after hiding. If the bubble is currently showing text, will hide after the text is
514 * done displaying. If the bubble is not visible this method does nothing.
515 */
516 private void hideHelper(Runnable afterHiding) {
517 if (visibility == Visibility.HIDDEN || visibility == Visibility.EXITING) {
518 return;
519 }
520
521 // Make bubble non clickable to prevent further buggy actions
522 viewHolder.setChildClickable(false);
523
524 if (textShowing) {
525 hideAfterText = true;
526 return;
527 }
528
529 if (collapseAnimation != null) {
530 collapseEndAction = CollapseEnd.HIDE;
531 return;
532 }
533
534 if (expanded) {
535 startCollapse(CollapseEnd.HIDE, false);
536 return;
537 }
538
539 visibility = Visibility.EXITING;
540 exitAnimator =
541 viewHolder
542 .getPrimaryButton()
543 .animate()
544 .setInterpolator(new AnticipateInterpolator())
545 .scaleX(0)
546 .scaleY(0)
547 .withEndAction(afterHiding);
548 exitAnimator.start();
549 }
550
551 private void reset() {
552 viewHolder = new ViewHolder(viewHolder.getRoot().getContext());
553 update();
554 }
555
556 private void update() {
yuega5a08d82017-10-31 14:11:53 -0700557 // Whole primary button background
yueg84ac49b2017-11-01 16:22:28 -0700558 Drawable backgroundCirle =
559 context.getResources().getDrawable(R.drawable.bubble_shape_circle, context.getTheme());
Eric Erfanian938468d2017-10-24 14:05:52 -0700560 int primaryTint =
561 ColorUtils.compositeColors(
562 context.getColor(R.color.bubble_primary_background_darken),
563 currentInfo.getPrimaryColor());
yueg84ac49b2017-11-01 16:22:28 -0700564 backgroundCirle.mutate().setTint(primaryTint);
565 viewHolder.getPrimaryButton().setBackground(backgroundCirle);
Eric Erfanian938468d2017-10-24 14:05:52 -0700566
yuega5a08d82017-10-31 14:11:53 -0700567 // Small icon
yueg84ac49b2017-11-01 16:22:28 -0700568 Drawable smallIconBackgroundCircle =
569 context
570 .getResources()
571 .getDrawable(R.drawable.bubble_shape_circle_small, context.getTheme());
572 smallIconBackgroundCircle.setTint(context.getColor(R.color.bubble_button_color_blue));
573 viewHolder.getPrimaryIcon().setBackground(smallIconBackgroundCircle);
Eric Erfanian938468d2017-10-24 14:05:52 -0700574 viewHolder.getPrimaryIcon().setImageIcon(currentInfo.getPrimaryIcon());
yuega5a08d82017-10-31 14:11:53 -0700575 viewHolder.getPrimaryAvatar().setImageDrawable(currentInfo.getAvatar());
Eric Erfanian938468d2017-10-24 14:05:52 -0700576
yuega5a08d82017-10-31 14:11:53 -0700577 updatePrimaryIconAnimation();
Eric Erfanian938468d2017-10-24 14:05:52 -0700578 updateButtonStates();
579 }
580
581 private void updatePrimaryIconAnimation() {
582 Drawable drawable = viewHolder.getPrimaryIcon().getDrawable();
583 if (drawable instanceof Animatable) {
584 if (isVisible()) {
585 ((Animatable) drawable).start();
586 } else {
587 ((Animatable) drawable).stop();
588 }
589 }
590 }
591
592 private void updateButtonStates() {
yueg84ac49b2017-11-01 16:22:28 -0700593 configureButton(currentInfo.getActions().get(0), viewHolder.getFullScreenButton());
594 configureButton(currentInfo.getActions().get(1), viewHolder.getMuteButton());
595 configureButton(currentInfo.getActions().get(2), viewHolder.getAudioRouteButton());
596 configureButton(currentInfo.getActions().get(3), viewHolder.getEndCallButton());
Eric Erfanian938468d2017-10-24 14:05:52 -0700597 }
598
599 private void doShowText(@NonNull CharSequence text) {
600 TransitionManager.beginDelayedTransition((ViewGroup) viewHolder.getPrimaryButton().getParent());
601 viewHolder.getPrimaryText().setText(text);
602 viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_TEXT);
603 }
604
yueg84ac49b2017-11-01 16:22:28 -0700605 private void configureButton(Action action, NewCheckableButton button) {
606 button.setCompoundDrawablesWithIntrinsicBounds(action.getIconDrawable(), null, null, null);
Eric Erfanian938468d2017-10-24 14:05:52 -0700607 button.setChecked(action.isChecked());
608 button.setEnabled(action.isEnabled());
yueg84ac49b2017-11-01 16:22:28 -0700609 button.setText(action.getName());
Eric Erfanian938468d2017-10-24 14:05:52 -0700610 button.setOnClickListener(v -> doAction(action));
611 }
612
613 private void doAction(Action action) {
614 try {
615 action.getIntent().send();
616 } catch (CanceledException e) {
617 throw new RuntimeException(e);
618 }
619 }
620
621 private void doResize(@Nullable Runnable operation) {
622 // If we're resizing on the right side of the screen, there is an implicit move operation
623 // necessary. The WindowManager does not sync the move and resize operations, so serious jank
624 // would occur. To fix this, instead of resizing the window, we create a new one and destroy
625 // the old one. There is a short delay before destroying the old view to ensure the new one has
626 // had time to draw.
627 ViewHolder oldViewHolder = viewHolder;
628 if (isDrawingFromRight()) {
629 viewHolder = new ViewHolder(oldViewHolder.getRoot().getContext());
630 update();
631 viewHolder
632 .getPrimaryButton()
633 .setDisplayedChild(oldViewHolder.getPrimaryButton().getDisplayedChild());
634 viewHolder.getPrimaryText().setText(oldViewHolder.getPrimaryText().getText());
635 }
636
637 if (operation != null) {
638 operation.run();
639 }
640
641 if (isDrawingFromRight()) {
642 swapViewHolders(oldViewHolder);
643 }
644 }
645
646 private void swapViewHolders(ViewHolder oldViewHolder) {
647 ViewGroup root = viewHolder.getRoot();
648 windowManager.addView(root, windowParams);
649 root.getViewTreeObserver()
650 .addOnPreDrawListener(
651 new OnPreDrawListener() {
652 @Override
653 public boolean onPreDraw() {
654 root.getViewTreeObserver().removeOnPreDrawListener(this);
655 // Wait a bit before removing the old view; make sure the new one has drawn over it.
656 handler.postDelayed(
657 () -> windowManager.removeView(oldViewHolder.getRoot()),
658 WINDOW_REDRAW_DELAY_MILLIS);
659 return true;
660 }
661 });
662 }
663
664 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
665 public void startCollapse(@CollapseEnd int endAction, boolean isUserAction) {
666 View expandedView = viewHolder.getExpandedView();
667 if (expandedView.getVisibility() != View.VISIBLE || collapseAnimation != null) {
668 // Drawer is already collapsed or animation is running.
669 return;
670 }
671
672 overrideGravity = isDrawingFromRight() ? Gravity.RIGHT : Gravity.LEFT;
673 setFocused(false);
674
675 if (collapseEndAction == CollapseEnd.NOTHING) {
676 collapseEndAction = endAction;
677 }
678 if (bubbleExpansionStateListener != null && collapseEndAction == CollapseEnd.NOTHING) {
679 bubbleExpansionStateListener.onBubbleExpansionStateChanged(
680 ExpansionState.START_COLLAPSING, isUserAction);
681 }
682 // Animate expanded view to move from its position to above primary button and hide
683 collapseAnimation =
684 expandedView
685 .animate()
686 .translationY(-viewHolder.getRoot().getHeight())
687 .setInterpolator(new FastOutLinearInInterpolator())
688 .withEndAction(
689 () -> {
690 collapseAnimation = null;
691 expanded = false;
692
693 if (textShowing) {
694 // Will do resize once the text is done.
695 return;
696 }
697
698 // Hide the drawer and resize if possible.
699 viewHolder.setDrawerVisibility(View.INVISIBLE);
700 if (!viewHolder.isMoving() || !isDrawingFromRight()) {
701 doResize(() -> viewHolder.setDrawerVisibility(View.GONE));
702 }
703
704 // If this collapse was to come before a hide, do it now.
705 if (collapseEndAction == CollapseEnd.HIDE) {
706 hide();
707 }
708 collapseEndAction = CollapseEnd.NOTHING;
709
710 // Resume normal gravity after any resizing is done.
711 handler.postDelayed(
712 () -> {
713 overrideGravity = null;
714 if (!viewHolder.isMoving()) {
715 viewHolder.undoGravityOverride();
716 }
717 },
718 // Need to wait twice as long for resize and layout
719 WINDOW_REDRAW_DELAY_MILLIS * 2);
720 });
721 }
722
723 private boolean isDrawingFromRight() {
724 return (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT;
725 }
726
727 private void setFocused(boolean focused) {
728 if (focused) {
729 windowParams.flags &= ~LayoutParams.FLAG_NOT_FOCUSABLE;
730 } else {
731 windowParams.flags |= LayoutParams.FLAG_NOT_FOCUSABLE;
732 }
733 windowManager.updateViewLayout(getRootView(), windowParams);
734 }
735
736 private void defaultAfterHidingAnimation() {
737 exitAnimator = null;
738 windowManager.removeView(viewHolder.getRoot());
739 visibility = Visibility.HIDDEN;
740
741 updatePrimaryIconAnimation();
742 }
743
744 @VisibleForTesting
745 class ViewHolder {
746
yuega5a08d82017-10-31 14:11:53 -0700747 public static final int CHILD_INDEX_AVATAR_AND_ICON = 0;
Eric Erfanian938468d2017-10-24 14:05:52 -0700748 public static final int CHILD_INDEX_TEXT = 1;
749
750 private final NewMoveHandler moveHandler;
751 private final NewWindowRoot root;
752 private final ViewAnimator primaryButton;
753 private final ImageView primaryIcon;
yuega5a08d82017-10-31 14:11:53 -0700754 private final ImageView primaryAvatar;
Eric Erfanian938468d2017-10-24 14:05:52 -0700755 private final TextView primaryText;
756
757 private final NewCheckableButton fullScreenButton;
758 private final NewCheckableButton muteButton;
759 private final NewCheckableButton audioRouteButton;
760 private final NewCheckableButton endCallButton;
761 private final View expandedView;
762
763 public ViewHolder(Context context) {
764 // Window root is not in the layout file so that the inflater has a view to inflate into
765 this.root = new NewWindowRoot(context);
766 LayoutInflater inflater = LayoutInflater.from(root.getContext());
767 View contentView = inflater.inflate(R.layout.new_bubble_base, root, true);
768 expandedView = contentView.findViewById(R.id.bubble_expanded_layout);
769 primaryButton = contentView.findViewById(R.id.bubble_button_primary);
yuega5a08d82017-10-31 14:11:53 -0700770 primaryAvatar = contentView.findViewById(R.id.bubble_icon_avatar);
Eric Erfanian938468d2017-10-24 14:05:52 -0700771 primaryIcon = contentView.findViewById(R.id.bubble_icon_primary);
772 primaryText = contentView.findViewById(R.id.bubble_text);
773
774 fullScreenButton = contentView.findViewById(R.id.bubble_button_full_screen);
775 muteButton = contentView.findViewById(R.id.bubble_button_mute);
776 audioRouteButton = contentView.findViewById(R.id.bubble_button_audio_route);
777 endCallButton = contentView.findViewById(R.id.bubble_button_end_call);
778
779 root.setOnBackPressedListener(
780 () -> {
781 if (visibility == Visibility.SHOWING && expanded) {
782 startCollapse(CollapseEnd.NOTHING, true);
783 return true;
784 }
785 return false;
786 });
787 root.setOnConfigurationChangedListener(
788 (configuration) -> {
789 // The values in the current MoveHandler may be stale, so replace it. Then ensure the
790 // Window is in bounds
791 moveHandler = new NewMoveHandler(primaryButton, NewBubble.this);
792 moveHandler.snapToBounds();
793 });
794 root.setOnTouchListener(
795 (v, event) -> {
796 if (expanded && event.getActionMasked() == MotionEvent.ACTION_OUTSIDE) {
797 startCollapse(CollapseEnd.NOTHING, true);
798 return true;
799 }
800 return false;
801 });
802 moveHandler = new NewMoveHandler(primaryButton, NewBubble.this);
803 }
804
805 private void setChildClickable(boolean clickable) {
806 fullScreenButton.setClickable(clickable);
807 muteButton.setClickable(clickable);
808 audioRouteButton.setClickable(clickable);
809 endCallButton.setClickable(clickable);
810
811 // For primaryButton
812 moveHandler.setClickable(clickable);
813 }
814
815 public ViewGroup getRoot() {
816 return root;
817 }
818
819 public ViewAnimator getPrimaryButton() {
820 return primaryButton;
821 }
822
823 public ImageView getPrimaryIcon() {
824 return primaryIcon;
825 }
826
yuega5a08d82017-10-31 14:11:53 -0700827 public ImageView getPrimaryAvatar() {
828 return primaryAvatar;
829 }
830
Eric Erfanian938468d2017-10-24 14:05:52 -0700831 public TextView getPrimaryText() {
832 return primaryText;
833 }
834
835 public View getExpandedView() {
836 return expandedView;
837 }
838
839 public NewCheckableButton getFullScreenButton() {
840 return fullScreenButton;
841 }
842
843 public NewCheckableButton getMuteButton() {
844 return muteButton;
845 }
846
847 public NewCheckableButton getAudioRouteButton() {
848 return audioRouteButton;
849 }
850
851 public NewCheckableButton getEndCallButton() {
852 return endCallButton;
853 }
854
855 public void setDrawerVisibility(int visibility) {
856 expandedView.setVisibility(visibility);
857 }
858
859 public boolean isMoving() {
860 return moveHandler.isMoving();
861 }
862
863 public void undoGravityOverride() {
864 moveHandler.undoGravityOverride();
865 }
866 }
867
868 /** Listener for bubble expansion state change. */
869 public interface BubbleExpansionStateListener {
870 void onBubbleExpansionStateChanged(@ExpansionState int expansionState, boolean isUserAction);
871 }
872}