blob: a25316f5ba4166d70d9d5e591d43f0b5a6c6408c [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;
Android Dialer25f45be2017-12-07 10:57:50 -080067import java.util.Arrays;
Eric Erfanian2ca43182017-08-31 06:57:16 -070068import java.util.List;
69
70/**
71 * Creates and manages a bubble window from information in a {@link BubbleInfo}. Before creating, be
72 * sure to check whether bubbles may be shown using {@link #canShowBubbles(Context)} and request
73 * permission if necessary ({@link #getRequestPermissionIntent(Context)} is provided for
74 * convenience)
75 */
76public class Bubble {
77 // This class has some odd behavior that is not immediately obvious in order to avoid jank when
78 // resizing. See http://go/bubble-resize for details.
79
80 // How long text should show after showText(CharSequence) is called
81 private static final int SHOW_TEXT_DURATION_MILLIS = 3000;
82 // How long the new window should show before destroying the old one during resize operations.
83 // This ensures the new window has had time to draw first.
84 private static final int WINDOW_REDRAW_DELAY_MILLIS = 50;
85
86 private static Boolean canShowBubblesForTesting = null;
87
88 private final Context context;
89 private final WindowManager windowManager;
90
sail3bcea982017-09-03 13:57:22 -070091 private final Handler handler;
Eric Erfanian2ca43182017-08-31 06:57:16 -070092 private LayoutParams windowParams;
93
94 // Initialized in factory method
95 @SuppressWarnings("NullableProblems")
96 @NonNull
97 private BubbleInfo currentInfo;
98
99 @Visibility private int visibility;
Android Dialer25f45be2017-12-07 10:57:50 -0800100 @VisibleForTesting boolean expanded;
Eric Erfanian2ca43182017-08-31 06:57:16 -0700101 private boolean textShowing;
102 private boolean hideAfterText;
yueg7f78e9a2017-09-12 11:10:45 -0700103 private CharSequence textAfterShow;
Eric Erfanian2ca43182017-08-31 06:57:16 -0700104 private int collapseEndAction;
105
106 @VisibleForTesting ViewHolder viewHolder;
107 private ViewPropertyAnimator collapseAnimation;
Android Dialer25f45be2017-12-07 10:57:50 -0800108 @VisibleForTesting Integer overrideGravity;
Eric Erfanian2ca43182017-08-31 06:57:16 -0700109 private ViewPropertyAnimator exitAnimator;
110
sail3bcea982017-09-03 13:57:22 -0700111 private final Runnable collapseRunnable =
112 new Runnable() {
113 @Override
114 public void run() {
115 textShowing = false;
116 if (hideAfterText) {
117 // Always reset here since text shouldn't keep showing.
118 hideAndReset();
119 } else {
120 doResize(
121 () -> viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_ICON));
122 }
123 }
124 };
125
Eric Erfanian2ca43182017-08-31 06:57:16 -0700126 private BubbleExpansionStateListener bubbleExpansionStateListener;
127
yueg5287a232017-09-18 15:01:31 -0700128 /** Type of action after bubble collapse */
Eric Erfanian2ca43182017-08-31 06:57:16 -0700129 @Retention(RetentionPolicy.SOURCE)
130 @IntDef({CollapseEnd.NOTHING, CollapseEnd.HIDE})
yueg5287a232017-09-18 15:01:31 -0700131 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
132 public @interface CollapseEnd {
Eric Erfanian2ca43182017-08-31 06:57:16 -0700133 int NOTHING = 0;
134 int HIDE = 1;
135 }
136
137 @Retention(RetentionPolicy.SOURCE)
138 @IntDef({Visibility.ENTERING, Visibility.SHOWING, Visibility.EXITING, Visibility.HIDDEN})
139 private @interface Visibility {
140 int HIDDEN = 0;
141 int ENTERING = 1;
142 int SHOWING = 2;
143 int EXITING = 3;
144 }
145
146 /** Indicate bubble expansion state. */
147 @Retention(RetentionPolicy.SOURCE)
148 @IntDef({ExpansionState.START_EXPANDING, ExpansionState.START_COLLAPSING})
149 public @interface ExpansionState {
150 // TODO(yueg): add more states when needed
151 int START_EXPANDING = 0;
152 int START_COLLAPSING = 1;
153 }
154
155 /**
156 * Determines whether bubbles can be shown based on permissions obtained. This should be checked
157 * before attempting to create a Bubble.
158 *
159 * @return true iff bubbles are able to be shown.
160 * @see Settings#canDrawOverlays(Context)
161 */
162 public static boolean canShowBubbles(@NonNull Context context) {
163 return canShowBubblesForTesting != null
164 ? canShowBubblesForTesting
165 : VERSION.SDK_INT < VERSION_CODES.M || Settings.canDrawOverlays(context);
166 }
167
168 @VisibleForTesting(otherwise = VisibleForTesting.NONE)
169 public static void setCanShowBubblesForTesting(boolean canShowBubbles) {
170 canShowBubblesForTesting = canShowBubbles;
171 }
172
173 /** Returns an Intent to request permission to show overlays */
174 @NonNull
175 public static Intent getRequestPermissionIntent(@NonNull Context context) {
176 return new Intent(
177 Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
178 Uri.fromParts("package", context.getPackageName(), null));
179 }
180
181 /** Creates instances of Bubble. The default implementation just calls the constructor. */
182 @VisibleForTesting
183 public interface BubbleFactory {
sail3bcea982017-09-03 13:57:22 -0700184 Bubble createBubble(@NonNull Context context, @NonNull Handler handler);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700185 }
186
187 private static BubbleFactory bubbleFactory = Bubble::new;
188
189 public static Bubble createBubble(@NonNull Context context, @NonNull BubbleInfo info) {
sail3bcea982017-09-03 13:57:22 -0700190 Bubble bubble = bubbleFactory.createBubble(context, new Handler());
Eric Erfanian2ca43182017-08-31 06:57:16 -0700191 bubble.setBubbleInfo(info);
192 return bubble;
193 }
194
195 @VisibleForTesting
196 public static void setBubbleFactory(@NonNull BubbleFactory bubbleFactory) {
197 Bubble.bubbleFactory = bubbleFactory;
198 }
199
200 @VisibleForTesting
201 public static void resetBubbleFactory() {
202 Bubble.bubbleFactory = Bubble::new;
203 }
204
205 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
sail3bcea982017-09-03 13:57:22 -0700206 Bubble(@NonNull Context context, @NonNull Handler handler) {
Eric Erfanian2ca43182017-08-31 06:57:16 -0700207 context = new ContextThemeWrapper(context, R.style.Theme_AppCompat);
208 this.context = context;
sail3bcea982017-09-03 13:57:22 -0700209 this.handler = handler;
Eric Erfanian2ca43182017-08-31 06:57:16 -0700210 windowManager = context.getSystemService(WindowManager.class);
211
212 viewHolder = new ViewHolder(context);
213 }
214
sail3bcea982017-09-03 13:57:22 -0700215 /** Expands the main bubble menu. */
yueg5287a232017-09-18 15:01:31 -0700216 public void expand(boolean isUserAction) {
sail3bcea982017-09-03 13:57:22 -0700217 if (bubbleExpansionStateListener != null) {
yueg5287a232017-09-18 15:01:31 -0700218 bubbleExpansionStateListener.onBubbleExpansionStateChanged(
219 ExpansionState.START_EXPANDING, isUserAction);
sail3bcea982017-09-03 13:57:22 -0700220 }
221 doResize(
222 () -> {
223 onLeftRightSwitch(isDrawingFromRight());
224 viewHolder.setDrawerVisibility(View.VISIBLE);
225 });
226 View expandedView = viewHolder.getExpandedView();
227 expandedView
228 .getViewTreeObserver()
229 .addOnPreDrawListener(
230 new OnPreDrawListener() {
231 @Override
232 public boolean onPreDraw() {
233 expandedView.getViewTreeObserver().removeOnPreDrawListener(this);
234 expandedView.setTranslationX(
235 isDrawingFromRight() ? expandedView.getWidth() : -expandedView.getWidth());
236 expandedView
237 .animate()
238 .setInterpolator(new LinearOutSlowInInterpolator())
239 .translationX(0);
240 return false;
241 }
242 });
243 setFocused(true);
244 expanded = true;
245 }
246
Eric Erfanian2ca43182017-08-31 06:57:16 -0700247 /**
248 * Make the bubble visible. Will show a short entrance animation as it enters. If the bubble is
249 * already showing this method does nothing.
250 */
251 public void show() {
252 if (collapseEndAction == CollapseEnd.HIDE) {
253 // If show() was called while collapsing, make sure we don't hide after.
254 collapseEndAction = CollapseEnd.NOTHING;
255 }
256 if (visibility == Visibility.SHOWING || visibility == Visibility.ENTERING) {
257 return;
258 }
259
260 hideAfterText = false;
261
262 if (windowParams == null) {
263 // Apps targeting O+ must use TYPE_APPLICATION_OVERLAY, which is not available prior to O.
264 @SuppressWarnings("deprecation")
265 @SuppressLint("InlinedApi")
266 int type =
267 BuildCompat.isAtLeastO()
268 ? LayoutParams.TYPE_APPLICATION_OVERLAY
269 : LayoutParams.TYPE_PHONE;
270
271 windowParams =
272 new LayoutParams(
273 type,
274 LayoutParams.FLAG_NOT_TOUCH_MODAL
275 | LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
276 | LayoutParams.FLAG_NOT_FOCUSABLE
277 | LayoutParams.FLAG_LAYOUT_NO_LIMITS,
278 PixelFormat.TRANSLUCENT);
279 windowParams.gravity = Gravity.TOP | Gravity.LEFT;
280 windowParams.x = context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_x);
281 windowParams.y = currentInfo.getStartingYPosition();
282 windowParams.height = LayoutParams.WRAP_CONTENT;
283 windowParams.width = LayoutParams.WRAP_CONTENT;
284 }
285
286 if (exitAnimator != null) {
287 exitAnimator.cancel();
288 exitAnimator = null;
289 } else {
290 windowManager.addView(viewHolder.getRoot(), windowParams);
291 viewHolder.getPrimaryButton().setScaleX(0);
292 viewHolder.getPrimaryButton().setScaleY(0);
293 }
294
yueg34f18862017-09-01 15:44:58 -0700295 viewHolder.setChildClickable(true);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700296 visibility = Visibility.ENTERING;
297 viewHolder
298 .getPrimaryButton()
299 .animate()
300 .setInterpolator(new OvershootInterpolator())
301 .scaleX(1)
302 .scaleY(1)
yueg7f78e9a2017-09-12 11:10:45 -0700303 .withEndAction(
304 () -> {
305 visibility = Visibility.SHOWING;
306 // Show the queued up text, if available.
307 if (textAfterShow != null) {
308 showText(textAfterShow);
309 textAfterShow = null;
310 }
311 })
Eric Erfanian2ca43182017-08-31 06:57:16 -0700312 .start();
313
314 updatePrimaryIconAnimation();
315 }
316
317 /** Hide the bubble. */
318 public void hide() {
319 if (hideAfterText) {
320 // hideAndReset() will be called after showing text, do nothing here.
321 return;
322 }
323 hideHelper(this::defaultAfterHidingAnimation);
324 }
325
326 /** Hide the bubble and reset {@viewHolder} to initial state */
327 public void hideAndReset() {
328 hideHelper(
329 () -> {
330 defaultAfterHidingAnimation();
331 reset();
332 });
333 }
334
335 /** Returns whether the bubble is currently visible */
336 public boolean isVisible() {
337 return visibility == Visibility.SHOWING
338 || visibility == Visibility.ENTERING
339 || visibility == Visibility.EXITING;
340 }
341
342 /**
343 * Set the info for this Bubble to display
344 *
345 * @param bubbleInfo the BubbleInfo to display in this Bubble.
346 */
347 public void setBubbleInfo(@NonNull BubbleInfo bubbleInfo) {
348 currentInfo = bubbleInfo;
349 update();
350 }
351
352 /**
353 * Update the state and behavior of actions.
354 *
355 * @param actions the new state of the bubble's actions
356 */
357 public void updateActions(@NonNull List<Action> actions) {
358 currentInfo = BubbleInfo.from(currentInfo).setActions(actions).build();
359 updateButtonStates();
360 }
361
362 /** Returns the currently displayed BubbleInfo */
363 public BubbleInfo getBubbleInfo() {
364 return currentInfo;
365 }
366
367 /**
368 * Display text in the main bubble. The bubble's drawer is not expandable while text is showing,
369 * and the drawer will be closed if already open.
370 *
371 * @param text the text to display to the user
372 */
373 public void showText(@NonNull CharSequence text) {
374 textShowing = true;
375 if (expanded) {
yueg5287a232017-09-18 15:01:31 -0700376 startCollapse(CollapseEnd.NOTHING, false);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700377 doShowText(text);
378 } else {
379 // Need to transition from old bounds to new bounds manually
380 ChangeOnScreenBounds transition = new ChangeOnScreenBounds();
381 // Prepare and capture start values
382 TransitionValues startValues = new TransitionValues();
383 startValues.view = viewHolder.getPrimaryButton();
384 transition.addTarget(startValues.view);
385 transition.captureStartValues(startValues);
386
yueg7f78e9a2017-09-12 11:10:45 -0700387 // If our view is not laid out yet, postpone showing the text.
388 if (startValues.values.isEmpty()) {
389 textAfterShow = text;
390 return;
391 }
392
Eric Erfanian2ca43182017-08-31 06:57:16 -0700393 doResize(
394 () -> {
395 doShowText(text);
396 // Hide the text so we can animate it in
397 viewHolder.getPrimaryText().setAlpha(0);
398
399 ViewAnimator primaryButton = viewHolder.getPrimaryButton();
400 // Cancel the automatic transition scheduled in doShowText
401 TransitionManager.endTransitions((ViewGroup) primaryButton.getParent());
402 primaryButton
403 .getViewTreeObserver()
404 .addOnPreDrawListener(
405 new OnPreDrawListener() {
406 @Override
407 public boolean onPreDraw() {
408 primaryButton.getViewTreeObserver().removeOnPreDrawListener(this);
409
410 // Prepare and capture end values, always use the size of primaryText since
411 // its invisibility makes primaryButton smaller than expected
412 TransitionValues endValues = new TransitionValues();
413 endValues.values.put(
414 ChangeOnScreenBounds.PROPNAME_WIDTH,
415 viewHolder.getPrimaryText().getWidth());
416 endValues.values.put(
417 ChangeOnScreenBounds.PROPNAME_HEIGHT,
418 viewHolder.getPrimaryText().getHeight());
419 endValues.view = primaryButton;
420 transition.addTarget(endValues.view);
421 transition.captureEndValues(endValues);
422
423 // animate the primary button bounds change
424 Animator bounds =
425 transition.createAnimator(primaryButton, startValues, endValues);
426
427 // Animate the text in
428 Animator alpha =
429 ObjectAnimator.ofFloat(viewHolder.getPrimaryText(), View.ALPHA, 1f);
430
431 AnimatorSet set = new AnimatorSet();
432 set.play(bounds).before(alpha);
433 set.start();
434 return false;
435 }
436 });
437 });
438 }
sail3bcea982017-09-03 13:57:22 -0700439 handler.removeCallbacks(collapseRunnable);
440 handler.postDelayed(collapseRunnable, SHOW_TEXT_DURATION_MILLIS);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700441 }
442
443 public void setBubbleExpansionStateListener(
444 BubbleExpansionStateListener bubbleExpansionStateListener) {
445 this.bubbleExpansionStateListener = bubbleExpansionStateListener;
446 }
447
448 @Nullable
449 Integer getGravityOverride() {
450 return overrideGravity;
451 }
452
453 void onMoveStart() {
yueg5287a232017-09-18 15:01:31 -0700454 startCollapse(CollapseEnd.NOTHING, true);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700455 viewHolder
456 .getPrimaryButton()
457 .animate()
458 .translationZ(
459 context.getResources().getDimensionPixelOffset(R.dimen.bubble_move_elevation_change));
460 }
461
462 void onMoveFinish() {
463 viewHolder.getPrimaryButton().animate().translationZ(0);
464 // If it's GONE, no resize is necessary. If it's VISIBLE, it will get cleaned up when the
465 // collapse animation finishes
466 if (viewHolder.getExpandedView().getVisibility() == View.INVISIBLE) {
467 doResize(null);
468 }
469 }
470
471 void primaryButtonClick() {
yueg5287a232017-09-18 15:01:31 -0700472 // Send primary intent if not to expand.
473 if (expanded || textShowing || currentInfo.getActions().isEmpty()) {
474 try {
475 currentInfo.getPrimaryIntent().send();
476 } catch (CanceledException e) {
477 throw new RuntimeException(e);
478 }
479 return;
480 }
481
482 expand(true);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700483 }
484
485 void onLeftRightSwitch(boolean onRight) {
486 if (viewHolder.isMoving()) {
487 if (viewHolder.getExpandedView().getVisibility() == View.GONE) {
488 // If the drawer is not part of the layout we don't need to do anything. Layout flips will
489 // happen if necessary when opening the drawer.
490 return;
491 }
492 }
493
494 viewHolder
495 .getRoot()
496 .setLayoutDirection(onRight ? View.LAYOUT_DIRECTION_RTL : View.LAYOUT_DIRECTION_LTR);
497 View primaryContainer = viewHolder.getRoot().findViewById(R.id.bubble_primary_container);
498 ViewGroup.LayoutParams layoutParams = primaryContainer.getLayoutParams();
499 ((FrameLayout.LayoutParams) layoutParams).gravity = onRight ? Gravity.RIGHT : Gravity.LEFT;
500 primaryContainer.setLayoutParams(layoutParams);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700501 }
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
yueg34f18862017-09-01 15:44:58 -0700521 // Make bubble non clickable to prevent further buggy actions
522 viewHolder.setChildClickable(false);
523
Eric Erfanian2ca43182017-08-31 06:57:16 -0700524 if (textShowing) {
525 hideAfterText = true;
526 return;
527 }
528
529 if (collapseAnimation != null) {
530 collapseEndAction = CollapseEnd.HIDE;
531 return;
532 }
533
534 if (expanded) {
yueg5287a232017-09-18 15:01:31 -0700535 startCollapse(CollapseEnd.HIDE, false);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700536 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() {
557 RippleDrawable backgroundRipple =
558 (RippleDrawable)
559 context.getResources().getDrawable(R.drawable.bubble_ripple_circle, context.getTheme());
560 int primaryTint =
561 ColorUtils.compositeColors(
562 context.getColor(R.color.bubble_primary_background_darken),
563 currentInfo.getPrimaryColor());
564 backgroundRipple.getDrawable(0).setTint(primaryTint);
565 viewHolder.getPrimaryButton().setBackground(backgroundRipple);
566
Android Dialer25f45be2017-12-07 10:57:50 -0800567 for (CheckableImageButton button : viewHolder.getActionButtons()) {
568 setBackgroundDrawable(button, primaryTint);
569 }
Eric Erfanian2ca43182017-08-31 06:57:16 -0700570
571 int numButtons = currentInfo.getActions().size();
Android Dialer25f45be2017-12-07 10:57:50 -0800572 for (CheckableImageButton button : viewHolder.getThirdButtons()) {
573 button.setVisibility(numButtons < 3 ? View.GONE : View.VISIBLE);
574 }
575 for (CheckableImageButton button : viewHolder.getSecondButtons()) {
576 button.setVisibility(numButtons < 2 ? View.GONE : View.VISIBLE);
577 }
Eric Erfanian2ca43182017-08-31 06:57:16 -0700578
579 viewHolder.getPrimaryIcon().setImageIcon(currentInfo.getPrimaryIcon());
580 updatePrimaryIconAnimation();
Android Dialer25f45be2017-12-07 10:57:50 -0800581 for (View expandedView : viewHolder.getExpandedViews()) {
582 expandedView.setBackgroundTintList(ColorStateList.valueOf(currentInfo.getPrimaryColor()));
583 }
Eric Erfanian2ca43182017-08-31 06:57:16 -0700584
585 updateButtonStates();
586 }
587
588 private void updatePrimaryIconAnimation() {
589 Drawable drawable = viewHolder.getPrimaryIcon().getDrawable();
590 if (drawable instanceof Animatable) {
591 if (isVisible()) {
592 ((Animatable) drawable).start();
593 } else {
594 ((Animatable) drawable).stop();
595 }
596 }
597 }
598
599 private void setBackgroundDrawable(CheckableImageButton view, @ColorInt int color) {
600 RippleDrawable itemRipple =
601 (RippleDrawable)
602 context
603 .getResources()
604 .getDrawable(R.drawable.bubble_ripple_checkable_circle, context.getTheme());
605 itemRipple.getDrawable(0).setTint(color);
606 view.setBackground(itemRipple);
607 }
608
609 private void updateButtonStates() {
610 int numButtons = currentInfo.getActions().size();
611
612 if (numButtons >= 1) {
Android Dialer25f45be2017-12-07 10:57:50 -0800613 for (CheckableImageButton button : viewHolder.getFirstButtons()) {
614 configureButton(currentInfo.getActions().get(0), button);
615 }
Eric Erfanian2ca43182017-08-31 06:57:16 -0700616 if (numButtons >= 2) {
Android Dialer25f45be2017-12-07 10:57:50 -0800617 for (CheckableImageButton button : viewHolder.getSecondButtons()) {
618 configureButton(currentInfo.getActions().get(1), button);
619 }
Eric Erfanian2ca43182017-08-31 06:57:16 -0700620 if (numButtons >= 3) {
Android Dialer25f45be2017-12-07 10:57:50 -0800621 for (CheckableImageButton button : viewHolder.getThirdButtons()) {
622 configureButton(currentInfo.getActions().get(2), button);
623 }
Eric Erfanian2ca43182017-08-31 06:57:16 -0700624 }
625 }
626 }
627 }
628
629 private void doShowText(@NonNull CharSequence text) {
630 TransitionManager.beginDelayedTransition((ViewGroup) viewHolder.getPrimaryButton().getParent());
631 viewHolder.getPrimaryText().setText(text);
632 viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_TEXT);
633 }
634
635 private void configureButton(Action action, CheckableImageButton button) {
636 action
637 .getIcon()
638 .loadDrawableAsync(
639 context,
640 d -> {
641 button.setImageIcon(action.getIcon());
642 button.setContentDescription(action.getName());
643 button.setChecked(action.isChecked());
644 button.setEnabled(action.isEnabled());
645 },
646 handler);
647 button.setOnClickListener(v -> doAction(action));
648 }
649
650 private void doAction(Action action) {
651 try {
652 action.getIntent().send();
653 } catch (CanceledException e) {
654 throw new RuntimeException(e);
655 }
656 }
657
658 private void doResize(@Nullable Runnable operation) {
659 // If we're resizing on the right side of the screen, there is an implicit move operation
660 // necessary. The WindowManager does not sync the move and resize operations, so serious jank
661 // would occur. To fix this, instead of resizing the window, we create a new one and destroy
662 // the old one. There is a short delay before destroying the old view to ensure the new one has
663 // had time to draw.
664 ViewHolder oldViewHolder = viewHolder;
665 if (isDrawingFromRight()) {
666 viewHolder = new ViewHolder(oldViewHolder.getRoot().getContext());
667 update();
668 viewHolder
669 .getPrimaryButton()
670 .setDisplayedChild(oldViewHolder.getPrimaryButton().getDisplayedChild());
671 viewHolder.getPrimaryText().setText(oldViewHolder.getPrimaryText().getText());
672 }
673
674 if (operation != null) {
675 operation.run();
676 }
677
678 if (isDrawingFromRight()) {
679 swapViewHolders(oldViewHolder);
680 }
681 }
682
683 private void swapViewHolders(ViewHolder oldViewHolder) {
684 oldViewHolder.getShadowProvider().setVisibility(View.GONE);
685 ViewGroup root = viewHolder.getRoot();
686 windowManager.addView(root, windowParams);
687 root.getViewTreeObserver()
688 .addOnPreDrawListener(
689 new OnPreDrawListener() {
690 @Override
691 public boolean onPreDraw() {
692 root.getViewTreeObserver().removeOnPreDrawListener(this);
693 // Wait a bit before removing the old view; make sure the new one has drawn over it.
694 handler.postDelayed(
695 () -> windowManager.removeView(oldViewHolder.getRoot()),
696 WINDOW_REDRAW_DELAY_MILLIS);
697 return true;
698 }
699 });
700 }
701
yueg5287a232017-09-18 15:01:31 -0700702 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
703 public void startCollapse(@CollapseEnd int endAction, boolean isUserAction) {
Eric Erfanian2ca43182017-08-31 06:57:16 -0700704 View expandedView = viewHolder.getExpandedView();
705 if (expandedView.getVisibility() != View.VISIBLE || collapseAnimation != null) {
706 // Drawer is already collapsed or animation is running.
707 return;
708 }
709
710 overrideGravity = isDrawingFromRight() ? Gravity.RIGHT : Gravity.LEFT;
711 setFocused(false);
712
713 if (collapseEndAction == CollapseEnd.NOTHING) {
714 collapseEndAction = endAction;
715 }
716 if (bubbleExpansionStateListener != null && collapseEndAction == CollapseEnd.NOTHING) {
yueg5287a232017-09-18 15:01:31 -0700717 bubbleExpansionStateListener.onBubbleExpansionStateChanged(
718 ExpansionState.START_COLLAPSING, isUserAction);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700719 }
720 collapseAnimation =
721 expandedView
722 .animate()
723 .translationX(isDrawingFromRight() ? expandedView.getWidth() : -expandedView.getWidth())
724 .setInterpolator(new FastOutLinearInInterpolator())
725 .withEndAction(
726 () -> {
727 collapseAnimation = null;
728 expanded = false;
729
730 if (textShowing) {
731 // Will do resize once the text is done.
732 return;
733 }
734
735 // Hide the drawer and resize if possible.
736 viewHolder.setDrawerVisibility(View.INVISIBLE);
737 if (!viewHolder.isMoving() || !isDrawingFromRight()) {
738 doResize(() -> viewHolder.setDrawerVisibility(View.GONE));
739 }
740
741 // If this collapse was to come before a hide, do it now.
742 if (collapseEndAction == CollapseEnd.HIDE) {
743 hide();
744 }
745 collapseEndAction = CollapseEnd.NOTHING;
746
747 // Resume normal gravity after any resizing is done.
748 handler.postDelayed(
749 () -> {
750 overrideGravity = null;
751 if (!viewHolder.isMoving()) {
752 viewHolder.undoGravityOverride();
753 }
754 },
755 // Need to wait twice as long for resize and layout
756 WINDOW_REDRAW_DELAY_MILLIS * 2);
757 });
758 }
759
760 private boolean isDrawingFromRight() {
761 return (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT;
762 }
763
764 private void setFocused(boolean focused) {
765 if (focused) {
766 windowParams.flags &= ~LayoutParams.FLAG_NOT_FOCUSABLE;
767 } else {
768 windowParams.flags |= LayoutParams.FLAG_NOT_FOCUSABLE;
769 }
770 windowManager.updateViewLayout(getRootView(), windowParams);
771 }
772
773 private void defaultAfterHidingAnimation() {
774 exitAnimator = null;
775 windowManager.removeView(viewHolder.getRoot());
776 visibility = Visibility.HIDDEN;
777
778 updatePrimaryIconAnimation();
779 }
780
781 @VisibleForTesting
782 class ViewHolder {
783
784 public static final int CHILD_INDEX_ICON = 0;
785 public static final int CHILD_INDEX_TEXT = 1;
786
787 private MoveHandler moveHandler;
788 private final WindowRoot root;
789 private final ViewAnimator primaryButton;
790 private final ImageView primaryIcon;
791 private final TextView primaryText;
792
793 private final CheckableImageButton firstButton;
Android Dialer25f45be2017-12-07 10:57:50 -0800794 private final CheckableImageButton firstButtonRtl;
Eric Erfanian2ca43182017-08-31 06:57:16 -0700795 private final CheckableImageButton secondButton;
Android Dialer25f45be2017-12-07 10:57:50 -0800796 private final CheckableImageButton secondButtonRtl;
Eric Erfanian2ca43182017-08-31 06:57:16 -0700797 private final CheckableImageButton thirdButton;
Android Dialer25f45be2017-12-07 10:57:50 -0800798 private final CheckableImageButton thirdButtonRtl;
Eric Erfanian2ca43182017-08-31 06:57:16 -0700799 private final View expandedView;
Android Dialer25f45be2017-12-07 10:57:50 -0800800 private final View expandedViewRtl;
Eric Erfanian2ca43182017-08-31 06:57:16 -0700801 private final View shadowProvider;
Android Dialer25f45be2017-12-07 10:57:50 -0800802 private final View shadowProviderRtl;
Eric Erfanian2ca43182017-08-31 06:57:16 -0700803
804 public ViewHolder(Context context) {
805 // Window root is not in the layout file so that the inflater has a view to inflate into
806 this.root = new WindowRoot(context);
807 LayoutInflater inflater = LayoutInflater.from(root.getContext());
808 View contentView = inflater.inflate(R.layout.bubble_base, root, true);
809 expandedView = contentView.findViewById(R.id.bubble_expanded_layout);
Android Dialer25f45be2017-12-07 10:57:50 -0800810 expandedViewRtl = contentView.findViewById(R.id.bubble_expanded_layout_rtl);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700811 primaryButton = contentView.findViewById(R.id.bubble_button_primary);
812 primaryIcon = contentView.findViewById(R.id.bubble_icon_primary);
813 primaryText = contentView.findViewById(R.id.bubble_text);
814 shadowProvider = contentView.findViewById(R.id.bubble_drawer_shadow_provider);
Android Dialer25f45be2017-12-07 10:57:50 -0800815 shadowProviderRtl = contentView.findViewById(R.id.bubble_drawer_shadow_provider_rtl);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700816
817 firstButton = contentView.findViewById(R.id.bubble_icon_first);
Android Dialer25f45be2017-12-07 10:57:50 -0800818 firstButtonRtl = contentView.findViewById(R.id.bubble_icon_first_rtl);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700819 secondButton = contentView.findViewById(R.id.bubble_icon_second);
Android Dialer25f45be2017-12-07 10:57:50 -0800820 secondButtonRtl = contentView.findViewById(R.id.bubble_icon_second_rtl);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700821 thirdButton = contentView.findViewById(R.id.bubble_icon_third);
Android Dialer25f45be2017-12-07 10:57:50 -0800822 thirdButtonRtl = contentView.findViewById(R.id.bubble_icon_third_rtl);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700823
824 root.setOnBackPressedListener(
825 () -> {
826 if (visibility == Visibility.SHOWING && expanded) {
yueg5287a232017-09-18 15:01:31 -0700827 startCollapse(CollapseEnd.NOTHING, true);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700828 return true;
829 }
830 return false;
831 });
832 root.setOnConfigurationChangedListener(
833 (configuration) -> {
834 // The values in the current MoveHandler may be stale, so replace it. Then ensure the
835 // Window is in bounds
836 moveHandler = new MoveHandler(primaryButton, Bubble.this);
837 moveHandler.snapToBounds();
838 });
839 root.setOnTouchListener(
840 (v, event) -> {
841 if (expanded && event.getActionMasked() == MotionEvent.ACTION_OUTSIDE) {
yueg5287a232017-09-18 15:01:31 -0700842 startCollapse(CollapseEnd.NOTHING, true);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700843 return true;
844 }
845 return false;
846 });
847 expandedView
848 .getViewTreeObserver()
849 .addOnDrawListener(
850 () -> {
851 int translationX = (int) expandedView.getTranslationX();
852 int parentOffset =
853 ((MarginLayoutParams) ((ViewGroup) expandedView.getParent()).getLayoutParams())
854 .leftMargin;
Android Dialer25f45be2017-12-07 10:57:50 -0800855 int minRight =
856 shadowProvider.getLeft()
857 + context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
858 shadowProvider.setRight(
859 Math.max(minRight, expandedView.getRight() + translationX + parentOffset));
860 });
861 expandedViewRtl
862 .getViewTreeObserver()
863 .addOnDrawListener(
864 () -> {
865 int translationX = (int) expandedViewRtl.getTranslationX();
866 int parentOffset =
867 ((MarginLayoutParams)
868 ((ViewGroup) expandedViewRtl.getParent()).getLayoutParams())
869 .leftMargin;
870 int maxLeft =
871 shadowProviderRtl.getRight()
872 - context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
873 shadowProviderRtl.setLeft(
874 Math.min(maxLeft, expandedViewRtl.getLeft() + translationX + parentOffset));
Eric Erfanian2ca43182017-08-31 06:57:16 -0700875 });
876 moveHandler = new MoveHandler(primaryButton, Bubble.this);
877 }
878
yueg34f18862017-09-01 15:44:58 -0700879 private void setChildClickable(boolean clickable) {
Android Dialer25f45be2017-12-07 10:57:50 -0800880 for (CheckableImageButton button : getActionButtons()) {
881 button.setClickable(clickable);
882 }
yueg34f18862017-09-01 15:44:58 -0700883
884 primaryButton.setOnTouchListener(clickable ? moveHandler : null);
885 }
886
Eric Erfanian2ca43182017-08-31 06:57:16 -0700887 public ViewGroup getRoot() {
888 return root;
889 }
890
891 public ViewAnimator getPrimaryButton() {
892 return primaryButton;
893 }
894
895 public ImageView getPrimaryIcon() {
896 return primaryIcon;
897 }
898
899 public TextView getPrimaryText() {
900 return primaryText;
901 }
902
Android Dialer25f45be2017-12-07 10:57:50 -0800903 /** Get list of all the action buttons from both LTR/RTL drawers. */
904 public List<CheckableImageButton> getActionButtons() {
905 return Arrays.asList(
906 firstButton, firstButtonRtl, secondButton, secondButtonRtl, thirdButton, thirdButtonRtl);
907 }
908
909 /** Get the first action button used in the current orientation drawer. */
Eric Erfanian2ca43182017-08-31 06:57:16 -0700910 public CheckableImageButton getFirstButton() {
Android Dialer25f45be2017-12-07 10:57:50 -0800911 return isDrawingFromRight() ? firstButtonRtl : firstButton;
Eric Erfanian2ca43182017-08-31 06:57:16 -0700912 }
913
Android Dialer25f45be2017-12-07 10:57:50 -0800914 /** Get both of the first action buttons from both LTR/RTL drawers. */
915 public List<CheckableImageButton> getFirstButtons() {
916 return Arrays.asList(firstButton, firstButtonRtl);
917 }
918
919 /** Get the second action button used in the current orientation drawer. */
Eric Erfanian2ca43182017-08-31 06:57:16 -0700920 public CheckableImageButton getSecondButton() {
Android Dialer25f45be2017-12-07 10:57:50 -0800921 return isDrawingFromRight() ? secondButtonRtl : secondButton;
Eric Erfanian2ca43182017-08-31 06:57:16 -0700922 }
923
Android Dialer25f45be2017-12-07 10:57:50 -0800924 /** Get both of the second action buttons from both LTR/RTL drawers. */
925 public List<CheckableImageButton> getSecondButtons() {
926 return Arrays.asList(secondButton, secondButtonRtl);
927 }
928
929 /** Get the third action button used in the current orientation drawer. */
Eric Erfanian2ca43182017-08-31 06:57:16 -0700930 public CheckableImageButton getThirdButton() {
Android Dialer25f45be2017-12-07 10:57:50 -0800931 return isDrawingFromRight() ? thirdButtonRtl : thirdButton;
Eric Erfanian2ca43182017-08-31 06:57:16 -0700932 }
933
Android Dialer25f45be2017-12-07 10:57:50 -0800934 /** Get both of the third action buttons from both LTR/RTL drawers. */
935 public List<CheckableImageButton> getThirdButtons() {
936 return Arrays.asList(thirdButton, thirdButtonRtl);
937 }
938
939 /** Get the correct expanded view used in current bubble orientation. */
Eric Erfanian2ca43182017-08-31 06:57:16 -0700940 public View getExpandedView() {
Android Dialer25f45be2017-12-07 10:57:50 -0800941 return isDrawingFromRight() ? expandedViewRtl : expandedView;
Eric Erfanian2ca43182017-08-31 06:57:16 -0700942 }
943
Android Dialer25f45be2017-12-07 10:57:50 -0800944 /** Get both views of the LTR and RTL drawers. */
945 public List<View> getExpandedViews() {
946 return Arrays.asList(expandedView, expandedViewRtl);
947 }
948
949 /** Get the correct shadow provider view used in current bubble orientation. */
Eric Erfanian2ca43182017-08-31 06:57:16 -0700950 public View getShadowProvider() {
Android Dialer25f45be2017-12-07 10:57:50 -0800951 return isDrawingFromRight() ? shadowProviderRtl : shadowProvider;
Eric Erfanian2ca43182017-08-31 06:57:16 -0700952 }
953
954 public void setDrawerVisibility(int visibility) {
Android Dialer25f45be2017-12-07 10:57:50 -0800955 if (isDrawingFromRight()) {
956 expandedViewRtl.setVisibility(visibility);
957 shadowProviderRtl.setVisibility(visibility);
958 } else {
959 expandedView.setVisibility(visibility);
960 shadowProvider.setVisibility(visibility);
961 }
Eric Erfanian2ca43182017-08-31 06:57:16 -0700962 }
963
964 public boolean isMoving() {
965 return moveHandler.isMoving();
966 }
967
968 public void undoGravityOverride() {
969 moveHandler.undoGravityOverride();
970 }
971 }
972
973 /** Listener for bubble expansion state change. */
974 public interface BubbleExpansionStateListener {
yueg5287a232017-09-18 15:01:31 -0700975 void onBubbleExpansionStateChanged(@ExpansionState int expansionState, boolean isUserAction);
Eric Erfanian2ca43182017-08-31 06:57:16 -0700976 }
977}