1 /* 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 17 package com.android.dialershared.bubble; 18 19 import android.animation.Animator; 20 import android.animation.AnimatorSet; 21 import android.animation.ObjectAnimator; 22 import android.annotation.SuppressLint; 23 import android.app.PendingIntent.CanceledException; 24 import android.content.Context; 25 import android.content.Intent; 26 import android.content.res.ColorStateList; 27 import android.graphics.PixelFormat; 28 import android.graphics.drawable.Animatable; 29 import android.graphics.drawable.Drawable; 30 import android.graphics.drawable.RippleDrawable; 31 import android.net.Uri; 32 import android.os.Build.VERSION; 33 import android.os.Build.VERSION_CODES; 34 import android.os.Handler; 35 import android.provider.Settings; 36 import android.support.annotation.ColorInt; 37 import android.support.annotation.IntDef; 38 import android.support.annotation.NonNull; 39 import android.support.annotation.Nullable; 40 import android.support.annotation.VisibleForTesting; 41 import android.support.v4.graphics.ColorUtils; 42 import android.support.v4.os.BuildCompat; 43 import android.support.v4.view.animation.FastOutLinearInInterpolator; 44 import android.support.v4.view.animation.LinearOutSlowInInterpolator; 45 import android.transition.TransitionManager; 46 import android.transition.TransitionValues; 47 import android.view.ContextThemeWrapper; 48 import android.view.Gravity; 49 import android.view.LayoutInflater; 50 import android.view.MotionEvent; 51 import android.view.View; 52 import android.view.ViewGroup; 53 import android.view.ViewGroup.MarginLayoutParams; 54 import android.view.ViewPropertyAnimator; 55 import android.view.ViewTreeObserver.OnPreDrawListener; 56 import android.view.WindowManager; 57 import android.view.WindowManager.LayoutParams; 58 import android.view.animation.AnticipateInterpolator; 59 import android.view.animation.OvershootInterpolator; 60 import android.widget.FrameLayout; 61 import android.widget.ImageView; 62 import android.widget.TextView; 63 import android.widget.ViewAnimator; 64 import com.android.dialershared.bubble.BubbleInfo.Action; 65 import java.lang.annotation.Retention; 66 import java.lang.annotation.RetentionPolicy; 67 import java.util.List; 68 69 /** 70 * Creates and manages a bubble window from information in a {@link BubbleInfo}. Before creating, be 71 * sure to check whether bubbles may be shown using {@link #canShowBubbles(Context)} and request 72 * permission if necessary ({@link #getRequestPermissionIntent(Context)} is provided for 73 * convenience) 74 */ 75 public class Bubble { 76 // This class has some odd behavior that is not immediately obvious in order to avoid jank when 77 // resizing. See http://go/bubble-resize for details. 78 79 // How long text should show after showText(CharSequence) is called 80 private static final int SHOW_TEXT_DURATION_MILLIS = 3000; 81 // How long the new window should show before destroying the old one during resize operations. 82 // This ensures the new window has had time to draw first. 83 private static final int WINDOW_REDRAW_DELAY_MILLIS = 50; 84 85 private static Boolean canShowBubblesForTesting = null; 86 87 private final Context context; 88 private final WindowManager windowManager; 89 90 private LayoutParams windowParams; 91 92 // Initialized in factory method 93 @SuppressWarnings("NullableProblems") 94 @NonNull 95 private BubbleInfo currentInfo; 96 97 @Visibility private int visibility; 98 private boolean expanded; 99 private boolean textShowing; 100 private boolean hideAfterText; 101 private int collapseEndAction; 102 103 private final Handler handler = new Handler(); 104 105 private ViewHolder viewHolder; 106 private ViewPropertyAnimator collapseAnimation; 107 private Integer overrideGravity; 108 private ViewPropertyAnimator exitAnimator; 109 110 @Retention(RetentionPolicy.SOURCE) 111 @IntDef({CollapseEnd.NOTHING, CollapseEnd.HIDE}) 112 private @interface CollapseEnd { 113 int NOTHING = 0; 114 int HIDE = 1; 115 } 116 117 @Retention(RetentionPolicy.SOURCE) 118 @IntDef({Visibility.ENTERING, Visibility.SHOWING, Visibility.EXITING, Visibility.HIDDEN}) 119 private @interface Visibility { 120 int HIDDEN = 0; 121 int ENTERING = 1; 122 int SHOWING = 2; 123 int EXITING = 3; 124 } 125 126 /** 127 * Determines whether bubbles can be shown based on permissions obtained. This should be checked 128 * before attempting to create a Bubble. 129 * 130 * @return true iff bubbles are able to be shown. 131 * @see Settings#canDrawOverlays(Context) 132 */ canShowBubbles(@onNull Context context)133 public static boolean canShowBubbles(@NonNull Context context) { 134 return canShowBubblesForTesting != null 135 ? canShowBubblesForTesting 136 : VERSION.SDK_INT < VERSION_CODES.M || Settings.canDrawOverlays(context); 137 } 138 139 @VisibleForTesting(otherwise = VisibleForTesting.NONE) setCanShowBubblesForTesting(boolean canShowBubbles)140 public static void setCanShowBubblesForTesting(boolean canShowBubbles) { 141 canShowBubblesForTesting = canShowBubbles; 142 } 143 144 /** Returns an Intent to request permission to show overlays */ 145 @NonNull getRequestPermissionIntent(@onNull Context context)146 public static Intent getRequestPermissionIntent(@NonNull Context context) { 147 return new Intent( 148 Settings.ACTION_MANAGE_OVERLAY_PERMISSION, 149 Uri.fromParts("package", context.getPackageName(), null)); 150 } 151 152 /** Creates instances of Bubble. The default implementation just calls the constructor. */ 153 @VisibleForTesting 154 public interface BubbleFactory { createBubble(@onNull Context context)155 Bubble createBubble(@NonNull Context context); 156 } 157 158 private static BubbleFactory bubbleFactory = Bubble::new; 159 createBubble(@onNull Context context, @NonNull BubbleInfo info)160 public static Bubble createBubble(@NonNull Context context, @NonNull BubbleInfo info) { 161 Bubble bubble = bubbleFactory.createBubble(context); 162 bubble.setBubbleInfo(info); 163 return bubble; 164 } 165 166 @VisibleForTesting setBubbleFactory(@onNull BubbleFactory bubbleFactory)167 public static void setBubbleFactory(@NonNull BubbleFactory bubbleFactory) { 168 Bubble.bubbleFactory = bubbleFactory; 169 } 170 171 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) Bubble(@onNull Context context)172 Bubble(@NonNull Context context) { 173 context = new ContextThemeWrapper(context, R.style.Theme_AppCompat); 174 this.context = context; 175 windowManager = context.getSystemService(WindowManager.class); 176 177 viewHolder = new ViewHolder(context); 178 } 179 180 /** 181 * Make the bubble visible. Will show a short entrance animation as it enters. If the bubble is 182 * already showing this method does nothing. 183 */ show()184 public void show() { 185 if (collapseEndAction == CollapseEnd.HIDE) { 186 // If show() was called while collapsing, make sure we don't hide after. 187 collapseEndAction = CollapseEnd.NOTHING; 188 } 189 if (visibility == Visibility.SHOWING || visibility == Visibility.ENTERING) { 190 return; 191 } 192 193 hideAfterText = false; 194 195 if (windowParams == null) { 196 // Apps targeting O+ must use TYPE_APPLICATION_OVERLAY, which is not available prior to O. 197 @SuppressWarnings("deprecation") 198 @SuppressLint("InlinedApi") 199 int type = 200 BuildCompat.isAtLeastO() 201 ? LayoutParams.TYPE_APPLICATION_OVERLAY 202 : LayoutParams.TYPE_PHONE; 203 204 windowParams = 205 new LayoutParams( 206 type, 207 LayoutParams.FLAG_NOT_TOUCH_MODAL 208 | LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH 209 | LayoutParams.FLAG_NOT_FOCUSABLE 210 | LayoutParams.FLAG_LAYOUT_NO_LIMITS, 211 PixelFormat.TRANSLUCENT); 212 windowParams.gravity = Gravity.TOP | Gravity.LEFT; 213 windowParams.x = context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_x); 214 windowParams.y = currentInfo.getStartingYPosition(); 215 windowParams.height = LayoutParams.WRAP_CONTENT; 216 windowParams.width = LayoutParams.WRAP_CONTENT; 217 } 218 219 if (exitAnimator != null) { 220 exitAnimator.cancel(); 221 exitAnimator = null; 222 } else { 223 windowManager.addView(viewHolder.getRoot(), windowParams); 224 viewHolder.getPrimaryButton().setScaleX(0); 225 viewHolder.getPrimaryButton().setScaleY(0); 226 } 227 228 visibility = Visibility.ENTERING; 229 viewHolder 230 .getPrimaryButton() 231 .animate() 232 .setInterpolator(new OvershootInterpolator()) 233 .scaleX(1) 234 .scaleY(1) 235 .withEndAction(() -> visibility = Visibility.SHOWING) 236 .start(); 237 238 updatePrimaryIconAnimation(); 239 } 240 241 /** 242 * Hide the button if visible. Will run a short exit animation before hiding. If the bubble is 243 * currently showing text, will hide after the text is done displaying. If the bubble is not 244 * visible this method does nothing. 245 */ hide()246 public void hide() { 247 if (visibility == Visibility.HIDDEN || visibility == Visibility.EXITING) { 248 return; 249 } 250 251 if (textShowing) { 252 hideAfterText = true; 253 return; 254 } 255 256 if (collapseAnimation != null) { 257 collapseEndAction = CollapseEnd.HIDE; 258 return; 259 } 260 261 if (expanded) { 262 startCollapse(CollapseEnd.HIDE); 263 return; 264 } 265 266 visibility = Visibility.EXITING; 267 exitAnimator = 268 viewHolder 269 .getPrimaryButton() 270 .animate() 271 .setInterpolator(new AnticipateInterpolator()) 272 .scaleX(0) 273 .scaleY(0) 274 .withEndAction( 275 () -> { 276 exitAnimator = null; 277 windowManager.removeView(viewHolder.getRoot()); 278 visibility = Visibility.HIDDEN; 279 updatePrimaryIconAnimation(); 280 }); 281 exitAnimator.start(); 282 } 283 284 /** Returns whether the bubble is currently visible */ isVisible()285 public boolean isVisible() { 286 return visibility == Visibility.SHOWING 287 || visibility == Visibility.ENTERING 288 || visibility == Visibility.EXITING; 289 } 290 291 /** 292 * Set the info for this Bubble to display 293 * 294 * @param bubbleInfo the BubbleInfo to display in this Bubble. 295 */ setBubbleInfo(@onNull BubbleInfo bubbleInfo)296 public void setBubbleInfo(@NonNull BubbleInfo bubbleInfo) { 297 currentInfo = bubbleInfo; 298 update(); 299 } 300 301 /** 302 * Update the state and behavior of actions. 303 * 304 * @param actions the new state of the bubble's actions 305 */ updateActions(@onNull List<Action> actions)306 public void updateActions(@NonNull List<Action> actions) { 307 currentInfo = BubbleInfo.from(currentInfo).setActions(actions).build(); 308 updateButtonStates(); 309 } 310 311 /** Returns the currently displayed BubbleInfo */ getBubbleInfo()312 public BubbleInfo getBubbleInfo() { 313 return currentInfo; 314 } 315 316 /** 317 * Display text in the main bubble. The bubble's drawer is not expandable while text is showing, 318 * and the drawer will be closed if already open. 319 * 320 * @param text the text to display to the user 321 */ showText(@onNull CharSequence text)322 public void showText(@NonNull CharSequence text) { 323 textShowing = true; 324 if (expanded) { 325 startCollapse(CollapseEnd.NOTHING); 326 doShowText(text); 327 } else { 328 // Need to transition from old bounds to new bounds manually 329 ChangeOnScreenBounds transition = new ChangeOnScreenBounds(); 330 // Prepare and capture start values 331 TransitionValues startValues = new TransitionValues(); 332 startValues.view = viewHolder.getPrimaryButton(); 333 transition.addTarget(startValues.view); 334 transition.captureStartValues(startValues); 335 336 doResize( 337 () -> { 338 doShowText(text); 339 // Hide the text so we can animate it in 340 viewHolder.getPrimaryText().setAlpha(0); 341 342 ViewAnimator primaryButton = viewHolder.getPrimaryButton(); 343 // Cancel the automatic transition scheduled in doShowText 344 TransitionManager.endTransitions((ViewGroup) primaryButton.getParent()); 345 primaryButton 346 .getViewTreeObserver() 347 .addOnPreDrawListener( 348 new OnPreDrawListener() { 349 @Override 350 public boolean onPreDraw() { 351 primaryButton.getViewTreeObserver().removeOnPreDrawListener(this); 352 353 // Prepare and capture end values 354 TransitionValues endValues = new TransitionValues(); 355 endValues.view = primaryButton; 356 transition.addTarget(endValues.view); 357 transition.captureEndValues(endValues); 358 359 // animate the primary button bounds change 360 Animator bounds = 361 transition.createAnimator(primaryButton, startValues, endValues); 362 363 // Animate the text in 364 Animator alpha = 365 ObjectAnimator.ofFloat(viewHolder.getPrimaryText(), View.ALPHA, 1f); 366 367 AnimatorSet set = new AnimatorSet(); 368 set.play(bounds).before(alpha); 369 set.start(); 370 return false; 371 } 372 }); 373 }); 374 } 375 handler.removeCallbacks(null); 376 handler.postDelayed( 377 () -> { 378 textShowing = false; 379 if (hideAfterText) { 380 hide(); 381 } else { 382 doResize( 383 () -> viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_ICON)); 384 } 385 }, 386 SHOW_TEXT_DURATION_MILLIS); 387 } 388 389 @Nullable getGravityOverride()390 Integer getGravityOverride() { 391 return overrideGravity; 392 } 393 onMoveStart()394 void onMoveStart() { 395 startCollapse(CollapseEnd.NOTHING); 396 viewHolder 397 .getPrimaryButton() 398 .animate() 399 .translationZ( 400 context.getResources().getDimensionPixelOffset(R.dimen.bubble_move_elevation_change)); 401 } 402 onMoveFinish()403 void onMoveFinish() { 404 viewHolder.getPrimaryButton().animate().translationZ(0); 405 // If it's GONE, no resize is necessary. If it's VISIBLE, it will get cleaned up when the 406 // collapse animation finishes 407 if (viewHolder.getExpandedView().getVisibility() == View.INVISIBLE) { 408 doResize(null); 409 } 410 } 411 primaryButtonClick()412 void primaryButtonClick() { 413 if (expanded || textShowing || currentInfo.getActions().isEmpty()) { 414 try { 415 currentInfo.getPrimaryIntent().send(); 416 } catch (CanceledException e) { 417 throw new RuntimeException(e); 418 } 419 return; 420 } 421 422 doResize( 423 () -> { 424 onLeftRightSwitch(isDrawingFromRight()); 425 viewHolder.setDrawerVisibility(View.VISIBLE); 426 }); 427 View expandedView = viewHolder.getExpandedView(); 428 expandedView 429 .getViewTreeObserver() 430 .addOnPreDrawListener( 431 new OnPreDrawListener() { 432 @Override 433 public boolean onPreDraw() { 434 expandedView.getViewTreeObserver().removeOnPreDrawListener(this); 435 expandedView.setTranslationX( 436 isDrawingFromRight() ? expandedView.getWidth() : -expandedView.getWidth()); 437 expandedView 438 .animate() 439 .setInterpolator(new LinearOutSlowInInterpolator()) 440 .translationX(0); 441 return false; 442 } 443 }); 444 setFocused(true); 445 expanded = true; 446 } 447 onLeftRightSwitch(boolean onRight)448 void onLeftRightSwitch(boolean onRight) { 449 if (viewHolder.isMoving()) { 450 if (viewHolder.getExpandedView().getVisibility() == View.GONE) { 451 // If the drawer is not part of the layout we don't need to do anything. Layout flips will 452 // happen if necessary when opening the drawer. 453 return; 454 } 455 } 456 457 viewHolder 458 .getRoot() 459 .setLayoutDirection(onRight ? View.LAYOUT_DIRECTION_RTL : View.LAYOUT_DIRECTION_LTR); 460 View primaryContainer = viewHolder.getRoot().findViewById(R.id.bubble_primary_container); 461 ViewGroup.LayoutParams layoutParams = primaryContainer.getLayoutParams(); 462 ((FrameLayout.LayoutParams) layoutParams).gravity = onRight ? Gravity.RIGHT : Gravity.LEFT; 463 primaryContainer.setLayoutParams(layoutParams); 464 465 viewHolder 466 .getExpandedView() 467 .setBackgroundResource( 468 onRight 469 ? R.drawable.bubble_background_pill_rtl 470 : R.drawable.bubble_background_pill_ltr); 471 } 472 getWindowParams()473 LayoutParams getWindowParams() { 474 return windowParams; 475 } 476 getRootView()477 View getRootView() { 478 return viewHolder.getRoot(); 479 } 480 update()481 private void update() { 482 RippleDrawable backgroundRipple = 483 (RippleDrawable) 484 context.getResources().getDrawable(R.drawable.bubble_ripple_circle, context.getTheme()); 485 int primaryTint = 486 ColorUtils.compositeColors( 487 context.getColor(R.color.bubble_primary_background_darken), 488 currentInfo.getPrimaryColor()); 489 backgroundRipple.getDrawable(0).setTint(primaryTint); 490 viewHolder.getPrimaryButton().setBackground(backgroundRipple); 491 492 setBackgroundDrawable(viewHolder.getFirstButton(), primaryTint); 493 setBackgroundDrawable(viewHolder.getSecondButton(), primaryTint); 494 setBackgroundDrawable(viewHolder.getThirdButton(), primaryTint); 495 496 int numButtons = currentInfo.getActions().size(); 497 viewHolder.getThirdButton().setVisibility(numButtons < 3 ? View.GONE : View.VISIBLE); 498 viewHolder.getSecondButton().setVisibility(numButtons < 2 ? View.GONE : View.VISIBLE); 499 500 viewHolder.getPrimaryIcon().setImageIcon(currentInfo.getPrimaryIcon()); 501 updatePrimaryIconAnimation(); 502 503 viewHolder 504 .getExpandedView() 505 .setBackgroundTintList(ColorStateList.valueOf(currentInfo.getPrimaryColor())); 506 507 updateButtonStates(); 508 } 509 510 private void updatePrimaryIconAnimation() { 511 Drawable drawable = viewHolder.getPrimaryIcon().getDrawable(); 512 if (drawable instanceof Animatable) { 513 if (isVisible()) { 514 ((Animatable) drawable).start(); 515 } else { 516 ((Animatable) drawable).stop(); 517 } 518 } 519 } 520 521 private void setBackgroundDrawable(CheckableImageButton view, @ColorInt int color) { 522 RippleDrawable itemRipple = 523 (RippleDrawable) 524 context 525 .getResources() 526 .getDrawable(R.drawable.bubble_ripple_checkable_circle, context.getTheme()); 527 itemRipple.getDrawable(0).setTint(color); 528 view.setBackground(itemRipple); 529 } 530 531 private void updateButtonStates() { 532 int numButtons = currentInfo.getActions().size(); 533 534 if (numButtons >= 1) { 535 configureButton(currentInfo.getActions().get(0), viewHolder.getFirstButton()); 536 if (numButtons >= 2) { 537 configureButton(currentInfo.getActions().get(1), viewHolder.getSecondButton()); 538 if (numButtons >= 3) { 539 configureButton(currentInfo.getActions().get(2), viewHolder.getThirdButton()); 540 } 541 } 542 } 543 } 544 doShowText(@onNull CharSequence text)545 private void doShowText(@NonNull CharSequence text) { 546 TransitionManager.beginDelayedTransition((ViewGroup) viewHolder.getPrimaryButton().getParent()); 547 viewHolder.getPrimaryText().setText(text); 548 viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_TEXT); 549 } 550 configureButton(Action action, CheckableImageButton button)551 private void configureButton(Action action, CheckableImageButton button) { 552 action 553 .getIcon() 554 .loadDrawableAsync( 555 context, 556 d -> { 557 button.setImageIcon(action.getIcon()); 558 button.setContentDescription(action.getName()); 559 button.setChecked(action.isChecked()); 560 button.setEnabled(action.isEnabled()); 561 }, 562 handler); 563 button.setOnClickListener(v -> doAction(action)); 564 } 565 doAction(Action action)566 private void doAction(Action action) { 567 try { 568 action.getIntent().send(); 569 } catch (CanceledException e) { 570 throw new RuntimeException(e); 571 } 572 } 573 doResize(@ullable Runnable operation)574 private void doResize(@Nullable Runnable operation) { 575 // If we're resizing on the right side of the screen, there is an implicit move operation 576 // necessary. The WindowManager does not sync the move and resize operations, so serious jank 577 // would occur. To fix this, instead of resizing the window, we create a new one and destroy 578 // the old one. There is a short delay before destroying the old view to ensure the new one has 579 // had time to draw. 580 ViewHolder oldViewHolder = viewHolder; 581 if (isDrawingFromRight()) { 582 viewHolder = new ViewHolder(oldViewHolder.getRoot().getContext()); 583 update(); 584 viewHolder 585 .getPrimaryButton() 586 .setDisplayedChild(oldViewHolder.getPrimaryButton().getDisplayedChild()); 587 viewHolder.getPrimaryText().setText(oldViewHolder.getPrimaryText().getText()); 588 } 589 590 if (operation != null) { 591 operation.run(); 592 } 593 594 if (isDrawingFromRight()) { 595 swapViewHolders(oldViewHolder); 596 } 597 } 598 swapViewHolders(ViewHolder oldViewHolder)599 private void swapViewHolders(ViewHolder oldViewHolder) { 600 oldViewHolder.getShadowProvider().setVisibility(View.GONE); 601 ViewGroup root = viewHolder.getRoot(); 602 windowManager.addView(root, windowParams); 603 root.getViewTreeObserver() 604 .addOnPreDrawListener( 605 new OnPreDrawListener() { 606 @Override 607 public boolean onPreDraw() { 608 root.getViewTreeObserver().removeOnPreDrawListener(this); 609 // Wait a bit before removing the old view; make sure the new one has drawn over it. 610 handler.postDelayed( 611 () -> windowManager.removeView(oldViewHolder.getRoot()), 612 WINDOW_REDRAW_DELAY_MILLIS); 613 return true; 614 } 615 }); 616 } 617 startCollapse(@ollapseEnd int endAction)618 private void startCollapse(@CollapseEnd int endAction) { 619 View expandedView = viewHolder.getExpandedView(); 620 if (expandedView.getVisibility() != View.VISIBLE || collapseAnimation != null) { 621 // Drawer is already collapsed or animation is running. 622 return; 623 } 624 625 overrideGravity = isDrawingFromRight() ? Gravity.RIGHT : Gravity.LEFT; 626 setFocused(false); 627 628 if (collapseEndAction == CollapseEnd.NOTHING) { 629 collapseEndAction = endAction; 630 } 631 collapseAnimation = 632 expandedView 633 .animate() 634 .translationX(isDrawingFromRight() ? expandedView.getWidth() : -expandedView.getWidth()) 635 .setInterpolator(new FastOutLinearInInterpolator()) 636 .withEndAction( 637 () -> { 638 collapseAnimation = null; 639 expanded = false; 640 641 if (textShowing) { 642 // Will do resize once the text is done. 643 return; 644 } 645 646 // Hide the drawer and resize if possible. 647 viewHolder.setDrawerVisibility(View.INVISIBLE); 648 if (!viewHolder.isMoving() || !isDrawingFromRight()) { 649 doResize(() -> viewHolder.setDrawerVisibility(View.GONE)); 650 } 651 652 // If this collapse was to come before a hide, do it now. 653 if (collapseEndAction == CollapseEnd.HIDE) { 654 hide(); 655 } 656 collapseEndAction = CollapseEnd.NOTHING; 657 658 // Resume normal gravity after any resizing is done. 659 handler.postDelayed( 660 () -> { 661 overrideGravity = null; 662 if (!viewHolder.isMoving()) { 663 viewHolder.undoGravityOverride(); 664 } 665 }, 666 // Need to wait twice as long for resize and layout 667 WINDOW_REDRAW_DELAY_MILLIS * 2); 668 }); 669 } 670 isDrawingFromRight()671 private boolean isDrawingFromRight() { 672 return (windowParams.gravity & Gravity.RIGHT) == Gravity.RIGHT; 673 } 674 setFocused(boolean focused)675 private void setFocused(boolean focused) { 676 if (focused) { 677 windowParams.flags &= ~LayoutParams.FLAG_NOT_FOCUSABLE; 678 } else { 679 windowParams.flags |= LayoutParams.FLAG_NOT_FOCUSABLE; 680 } 681 windowManager.updateViewLayout(getRootView(), windowParams); 682 } 683 684 private class ViewHolder { 685 686 public static final int CHILD_INDEX_ICON = 0; 687 public static final int CHILD_INDEX_TEXT = 1; 688 689 private MoveHandler moveHandler; 690 private final WindowRoot root; 691 private final ViewAnimator primaryButton; 692 private final ImageView primaryIcon; 693 private final TextView primaryText; 694 695 private final CheckableImageButton firstButton; 696 private final CheckableImageButton secondButton; 697 private final CheckableImageButton thirdButton; 698 private final View expandedView; 699 private final View shadowProvider; 700 ViewHolder(Context context)701 public ViewHolder(Context context) { 702 // Window root is not in the layout file so that the inflater has a view to inflate into 703 this.root = new WindowRoot(context); 704 LayoutInflater inflater = LayoutInflater.from(root.getContext()); 705 View contentView = inflater.inflate(R.layout.bubble_base, root, true); 706 expandedView = contentView.findViewById(R.id.bubble_expanded_layout); 707 primaryButton = contentView.findViewById(R.id.bubble_button_primary); 708 primaryIcon = contentView.findViewById(R.id.bubble_icon_primary); 709 primaryText = contentView.findViewById(R.id.bubble_text); 710 shadowProvider = contentView.findViewById(R.id.bubble_drawer_shadow_provider); 711 712 firstButton = contentView.findViewById(R.id.bubble_icon_first); 713 secondButton = contentView.findViewById(R.id.bubble_icon_second); 714 thirdButton = contentView.findViewById(R.id.bubble_icon_third); 715 716 root.setOnBackPressedListener( 717 () -> { 718 if (visibility == Visibility.SHOWING && expanded) { 719 startCollapse(CollapseEnd.NOTHING); 720 return true; 721 } 722 return false; 723 }); 724 root.setOnConfigurationChangedListener( 725 (configuration) -> { 726 // The values in the current MoveHandler may be stale, so replace it. Then ensure the 727 // Window is in bounds 728 moveHandler = new MoveHandler(primaryButton, Bubble.this); 729 moveHandler.snapToBounds(); 730 }); 731 root.setOnTouchListener( 732 (v, event) -> { 733 if (expanded && event.getActionMasked() == MotionEvent.ACTION_OUTSIDE) { 734 startCollapse(CollapseEnd.NOTHING); 735 return true; 736 } 737 return false; 738 }); 739 expandedView 740 .getViewTreeObserver() 741 .addOnDrawListener( 742 () -> { 743 int translationX = (int) expandedView.getTranslationX(); 744 int parentOffset = 745 ((MarginLayoutParams) ((ViewGroup) expandedView.getParent()).getLayoutParams()) 746 .leftMargin; 747 if (isDrawingFromRight()) { 748 int maxLeft = 749 shadowProvider.getRight() 750 - context.getResources().getDimensionPixelSize(R.dimen.bubble_size); 751 shadowProvider.setLeft( 752 Math.min(maxLeft, expandedView.getLeft() + translationX + parentOffset)); 753 } else { 754 int minRight = 755 shadowProvider.getLeft() 756 + context.getResources().getDimensionPixelSize(R.dimen.bubble_size); 757 shadowProvider.setRight( 758 Math.max(minRight, expandedView.getRight() + translationX + parentOffset)); 759 } 760 }); 761 moveHandler = new MoveHandler(primaryButton, Bubble.this); 762 } 763 getRoot()764 public ViewGroup getRoot() { 765 return root; 766 } 767 getPrimaryButton()768 public ViewAnimator getPrimaryButton() { 769 return primaryButton; 770 } 771 getPrimaryIcon()772 public ImageView getPrimaryIcon() { 773 return primaryIcon; 774 } 775 getPrimaryText()776 public TextView getPrimaryText() { 777 return primaryText; 778 } 779 getFirstButton()780 public CheckableImageButton getFirstButton() { 781 return firstButton; 782 } 783 getSecondButton()784 public CheckableImageButton getSecondButton() { 785 return secondButton; 786 } 787 getThirdButton()788 public CheckableImageButton getThirdButton() { 789 return thirdButton; 790 } 791 getExpandedView()792 public View getExpandedView() { 793 return expandedView; 794 } 795 getShadowProvider()796 public View getShadowProvider() { 797 return shadowProvider; 798 } 799 setDrawerVisibility(int visibility)800 public void setDrawerVisibility(int visibility) { 801 expandedView.setVisibility(visibility); 802 shadowProvider.setVisibility(visibility); 803 } 804 isMoving()805 public boolean isMoving() { 806 return moveHandler.isMoving(); 807 } 808 undoGravityOverride()809 public void undoGravityOverride() { 810 moveHandler.undoGravityOverride(); 811 } 812 } 813 } 814