1 /* 2 * Copyright (C) 2021 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.internal.widget.floatingtoolbar; 18 19 import android.animation.Animator; 20 import android.animation.AnimatorListenerAdapter; 21 import android.animation.AnimatorSet; 22 import android.animation.ObjectAnimator; 23 import android.animation.ValueAnimator; 24 import android.annotation.Nullable; 25 import android.content.Context; 26 import android.content.res.TypedArray; 27 import android.graphics.Color; 28 import android.graphics.Point; 29 import android.graphics.Rect; 30 import android.graphics.Region; 31 import android.graphics.drawable.AnimatedVectorDrawable; 32 import android.graphics.drawable.ColorDrawable; 33 import android.graphics.drawable.Drawable; 34 import android.text.TextUtils; 35 import android.util.Size; 36 import android.view.ContextThemeWrapper; 37 import android.view.Gravity; 38 import android.view.LayoutInflater; 39 import android.view.MenuItem; 40 import android.view.MotionEvent; 41 import android.view.View; 42 import android.view.View.MeasureSpec; 43 import android.view.ViewConfiguration; 44 import android.view.ViewGroup; 45 import android.view.ViewTreeObserver; 46 import android.view.WindowManager; 47 import android.view.animation.Animation; 48 import android.view.animation.AnimationSet; 49 import android.view.animation.AnimationUtils; 50 import android.view.animation.Interpolator; 51 import android.view.animation.Transformation; 52 import android.widget.ArrayAdapter; 53 import android.widget.ImageButton; 54 import android.widget.ImageView; 55 import android.widget.LinearLayout; 56 import android.widget.ListView; 57 import android.widget.PopupWindow; 58 import android.widget.TextView; 59 60 import com.android.internal.R; 61 import com.android.internal.annotations.VisibleForTesting; 62 import com.android.internal.util.Preconditions; 63 64 import java.util.ArrayList; 65 import java.util.Collection; 66 import java.util.Iterator; 67 import java.util.LinkedHashMap; 68 import java.util.List; 69 import java.util.Map; 70 import java.util.Objects; 71 72 /** 73 * A popup window used by the floating toolbar to render menu items in the local app process. 74 * 75 * This class is responsible for the rendering/animation of the floating toolbar. 76 * It holds 2 panels (i.e. main panel and overflow panel) and an overflow button 77 * to transition between panels. 78 */ 79 public final class LocalFloatingToolbarPopup implements FloatingToolbarPopup { 80 81 /* Minimum and maximum number of items allowed in the overflow. */ 82 private static final int MIN_OVERFLOW_SIZE = 2; 83 private static final int MAX_OVERFLOW_SIZE = 4; 84 85 private final Context mContext; 86 private final View mParent; // Parent for the popup window. 87 private final PopupWindow mPopupWindow; 88 89 /* Margins between the popup window and its content. */ 90 private final int mMarginHorizontal; 91 private final int mMarginVertical; 92 93 /* View components */ 94 private final ViewGroup mContentContainer; // holds all contents. 95 private final ViewGroup mMainPanel; // holds menu items that are initially displayed. 96 // holds menu items hidden in the overflow. 97 private final OverflowPanel mOverflowPanel; 98 private final ImageButton mOverflowButton; // opens/closes the overflow. 99 /* overflow button drawables. */ 100 private final Drawable mArrow; 101 private final Drawable mOverflow; 102 private final AnimatedVectorDrawable mToArrow; 103 private final AnimatedVectorDrawable mToOverflow; 104 105 private final OverflowPanelViewHelper mOverflowPanelViewHelper; 106 107 /* Animation interpolators. */ 108 private final Interpolator mLogAccelerateInterpolator; 109 private final Interpolator mFastOutSlowInInterpolator; 110 private final Interpolator mLinearOutSlowInInterpolator; 111 private final Interpolator mFastOutLinearInInterpolator; 112 113 /* Animations. */ 114 private final AnimatorSet mShowAnimation; 115 private final AnimatorSet mDismissAnimation; 116 private final AnimatorSet mHideAnimation; 117 private final AnimationSet mOpenOverflowAnimation; 118 private final AnimationSet mCloseOverflowAnimation; 119 private final Animation.AnimationListener mOverflowAnimationListener; 120 121 private final Rect mViewPortOnScreen = new Rect(); // portion of screen we can draw in. 122 private final Point mCoordsOnWindow = new Point(); // popup window coordinates. 123 /* Temporary data holders. Reset values before using. */ 124 private final int[] mTmpCoords = new int[2]; 125 126 private final Region mTouchableRegion = new Region(); 127 private final ViewTreeObserver.OnComputeInternalInsetsListener mInsetsComputer = 128 info -> { 129 info.contentInsets.setEmpty(); 130 info.visibleInsets.setEmpty(); 131 info.touchableRegion.set(mTouchableRegion); 132 info.setTouchableInsets( 133 ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION); 134 }; 135 136 private final int mLineHeight; 137 private final int mIconTextSpacing; 138 139 /** 140 * @see OverflowPanelViewHelper#preparePopupContent(). 141 */ 142 private final Runnable mPreparePopupContentRTLHelper = new Runnable() { 143 @Override 144 public void run() { 145 setPanelsStatesAtRestingPosition(); 146 setContentAreaAsTouchableSurface(); 147 mContentContainer.setAlpha(1); 148 } 149 }; 150 151 private boolean mDismissed = true; // tracks whether this popup is dismissed or dismissing. 152 private boolean mHidden; // tracks whether this popup is hidden or hiding. 153 154 /* Calculated sizes for panels and overflow button. */ 155 private final Size mOverflowButtonSize; 156 private Size mOverflowPanelSize; // Should be null when there is no overflow. 157 private Size mMainPanelSize; 158 159 /* Menu items and click listeners */ 160 private final Map<MenuItemRepr, MenuItem> mMenuItems = new LinkedHashMap<>(); 161 private MenuItem.OnMenuItemClickListener mOnMenuItemClickListener; 162 private final View.OnClickListener mMenuItemButtonOnClickListener = 163 new View.OnClickListener() { 164 @Override 165 public void onClick(View v) { 166 if (mOnMenuItemClickListener == null) { 167 return; 168 } 169 final Object tag = v.getTag(); 170 if (!(tag instanceof MenuItemRepr)) { 171 return; 172 } 173 final MenuItem menuItem = mMenuItems.get((MenuItemRepr) tag); 174 if (menuItem == null) { 175 return; 176 } 177 mOnMenuItemClickListener.onMenuItemClick(menuItem); 178 } 179 }; 180 181 private boolean mOpenOverflowUpwards; // Whether the overflow opens upwards or downwards. 182 private boolean mIsOverflowOpen; 183 184 private int mTransitionDurationScale; // Used to scale the toolbar transition duration. 185 186 private final Rect mPreviousContentRect = new Rect(); 187 private int mSuggestedWidth; 188 private boolean mWidthChanged = true; 189 190 /** 191 * Initializes a new floating toolbar popup. 192 * 193 * @param parent A parent view to get the {@link android.view.View#getWindowToken()} token 194 * from. 195 */ LocalFloatingToolbarPopup(Context context, View parent)196 public LocalFloatingToolbarPopup(Context context, View parent) { 197 mParent = Objects.requireNonNull(parent); 198 mContext = applyDefaultTheme(context); 199 mContentContainer = createContentContainer(mContext); 200 mPopupWindow = createPopupWindow(mContentContainer); 201 mMarginHorizontal = parent.getResources() 202 .getDimensionPixelSize(R.dimen.floating_toolbar_horizontal_margin); 203 mMarginVertical = parent.getResources() 204 .getDimensionPixelSize(R.dimen.floating_toolbar_vertical_margin); 205 mLineHeight = context.getResources() 206 .getDimensionPixelSize(R.dimen.floating_toolbar_height); 207 mIconTextSpacing = context.getResources() 208 .getDimensionPixelSize(R.dimen.floating_toolbar_icon_text_spacing); 209 210 // Interpolators 211 mLogAccelerateInterpolator = new LogAccelerateInterpolator(); 212 mFastOutSlowInInterpolator = AnimationUtils.loadInterpolator( 213 mContext, android.R.interpolator.fast_out_slow_in); 214 mLinearOutSlowInInterpolator = AnimationUtils.loadInterpolator( 215 mContext, android.R.interpolator.linear_out_slow_in); 216 mFastOutLinearInInterpolator = AnimationUtils.loadInterpolator( 217 mContext, android.R.interpolator.fast_out_linear_in); 218 219 // Drawables. Needed for views. 220 mArrow = mContext.getResources() 221 .getDrawable(R.drawable.ft_avd_tooverflow, mContext.getTheme()); 222 mArrow.setAutoMirrored(true); 223 mOverflow = mContext.getResources() 224 .getDrawable(R.drawable.ft_avd_toarrow, mContext.getTheme()); 225 mOverflow.setAutoMirrored(true); 226 mToArrow = (AnimatedVectorDrawable) mContext.getResources() 227 .getDrawable(R.drawable.ft_avd_toarrow_animation, mContext.getTheme()); 228 mToArrow.setAutoMirrored(true); 229 mToOverflow = (AnimatedVectorDrawable) mContext.getResources() 230 .getDrawable(R.drawable.ft_avd_tooverflow_animation, mContext.getTheme()); 231 mToOverflow.setAutoMirrored(true); 232 233 // Views 234 mOverflowButton = createOverflowButton(); 235 mOverflowButtonSize = measure(mOverflowButton); 236 mMainPanel = createMainPanel(); 237 mOverflowPanelViewHelper = new OverflowPanelViewHelper(mContext, mIconTextSpacing); 238 mOverflowPanel = createOverflowPanel(); 239 240 // Animation. Need views. 241 mOverflowAnimationListener = createOverflowAnimationListener(); 242 mOpenOverflowAnimation = new AnimationSet(true); 243 mOpenOverflowAnimation.setAnimationListener(mOverflowAnimationListener); 244 mCloseOverflowAnimation = new AnimationSet(true); 245 mCloseOverflowAnimation.setAnimationListener(mOverflowAnimationListener); 246 mShowAnimation = createEnterAnimation(mContentContainer); 247 mDismissAnimation = createExitAnimation( 248 mContentContainer, 249 150, // startDelay 250 new AnimatorListenerAdapter() { 251 @Override 252 public void onAnimationEnd(Animator animation) { 253 mPopupWindow.dismiss(); 254 mContentContainer.removeAllViews(); 255 } 256 }); 257 mHideAnimation = createExitAnimation( 258 mContentContainer, 259 0, // startDelay 260 new AnimatorListenerAdapter() { 261 @Override 262 public void onAnimationEnd(Animator animation) { 263 mPopupWindow.dismiss(); 264 } 265 }); 266 } 267 268 @Override setOutsideTouchable( boolean outsideTouchable, @Nullable PopupWindow.OnDismissListener onDismiss)269 public boolean setOutsideTouchable( 270 boolean outsideTouchable, @Nullable PopupWindow.OnDismissListener onDismiss) { 271 boolean ret = false; 272 if (mPopupWindow.isOutsideTouchable() ^ outsideTouchable) { 273 mPopupWindow.setOutsideTouchable(outsideTouchable); 274 mPopupWindow.setFocusable(!outsideTouchable); 275 mPopupWindow.update(); 276 ret = true; 277 } 278 mPopupWindow.setOnDismissListener(onDismiss); 279 return ret; 280 } 281 282 /** 283 * Lays out buttons for the specified menu items. 284 * Requires a subsequent call to {@link FloatingToolbar#show()} to show the items. 285 */ layoutMenuItems( List<MenuItem> menuItems, MenuItem.OnMenuItemClickListener menuItemClickListener, int suggestedWidth)286 private void layoutMenuItems( 287 List<MenuItem> menuItems, 288 MenuItem.OnMenuItemClickListener menuItemClickListener, 289 int suggestedWidth) { 290 cancelOverflowAnimations(); 291 clearPanels(); 292 updateMenuItems(menuItems, menuItemClickListener); 293 menuItems = layoutMainPanelItems(menuItems, getAdjustedToolbarWidth(suggestedWidth)); 294 if (!menuItems.isEmpty()) { 295 // Add remaining items to the overflow. 296 layoutOverflowPanelItems(menuItems); 297 } 298 updatePopupSize(); 299 } 300 301 /** 302 * Updates the popup's menu items without rebuilding the widget. 303 * Use in place of layoutMenuItems() when the popup's views need not be reconstructed. 304 * 305 * @see #isLayoutRequired(List<MenuItem>) 306 */ updateMenuItems( List<MenuItem> menuItems, MenuItem.OnMenuItemClickListener menuItemClickListener)307 private void updateMenuItems( 308 List<MenuItem> menuItems, MenuItem.OnMenuItemClickListener menuItemClickListener) { 309 mMenuItems.clear(); 310 for (MenuItem menuItem : menuItems) { 311 mMenuItems.put(MenuItemRepr.of(menuItem), menuItem); 312 } 313 mOnMenuItemClickListener = menuItemClickListener; 314 } 315 316 /** 317 * Returns true if this popup needs a relayout to properly render the specified menu items. 318 */ isLayoutRequired(List<MenuItem> menuItems)319 private boolean isLayoutRequired(List<MenuItem> menuItems) { 320 return !MenuItemRepr.reprEquals(menuItems, mMenuItems.values()); 321 } 322 323 @Override setWidthChanged(boolean widthChanged)324 public void setWidthChanged(boolean widthChanged) { 325 mWidthChanged = widthChanged; 326 } 327 328 @Override setSuggestedWidth(int suggestedWidth)329 public void setSuggestedWidth(int suggestedWidth) { 330 // Check if there's been a substantial width spec change. 331 int difference = Math.abs(suggestedWidth - mSuggestedWidth); 332 mWidthChanged = difference > (mSuggestedWidth * 0.2); 333 mSuggestedWidth = suggestedWidth; 334 } 335 336 @Override show(List<MenuItem> menuItems, MenuItem.OnMenuItemClickListener menuItemClickListener, Rect contentRect)337 public void show(List<MenuItem> menuItems, 338 MenuItem.OnMenuItemClickListener menuItemClickListener, Rect contentRect) { 339 if (isLayoutRequired(menuItems) || mWidthChanged) { 340 dismiss(); 341 layoutMenuItems(menuItems, menuItemClickListener, mSuggestedWidth); 342 } else { 343 updateMenuItems(menuItems, menuItemClickListener); 344 } 345 if (!isShowing()) { 346 show(contentRect); 347 } else if (!mPreviousContentRect.equals(contentRect)) { 348 updateCoordinates(contentRect); 349 } 350 mWidthChanged = false; 351 mPreviousContentRect.set(contentRect); 352 } 353 show(Rect contentRectOnScreen)354 private void show(Rect contentRectOnScreen) { 355 Objects.requireNonNull(contentRectOnScreen); 356 357 if (isShowing()) { 358 return; 359 } 360 361 mHidden = false; 362 mDismissed = false; 363 cancelDismissAndHideAnimations(); 364 cancelOverflowAnimations(); 365 366 refreshCoordinatesAndOverflowDirection(contentRectOnScreen); 367 preparePopupContent(); 368 // We need to specify the position in window coordinates. 369 // TODO: Consider to use PopupWindow.setIsLaidOutInScreen(true) so that we can 370 // specify the popup position in screen coordinates. 371 mPopupWindow.showAtLocation( 372 mParent, Gravity.NO_GRAVITY, mCoordsOnWindow.x, mCoordsOnWindow.y); 373 setTouchableSurfaceInsetsComputer(); 374 runShowAnimation(); 375 } 376 377 @Override dismiss()378 public void dismiss() { 379 if (mDismissed) { 380 return; 381 } 382 383 mHidden = false; 384 mDismissed = true; 385 mHideAnimation.cancel(); 386 387 runDismissAnimation(); 388 setZeroTouchableSurface(); 389 } 390 391 @Override hide()392 public void hide() { 393 if (!isShowing()) { 394 return; 395 } 396 397 mHidden = true; 398 runHideAnimation(); 399 setZeroTouchableSurface(); 400 } 401 402 @Override isShowing()403 public boolean isShowing() { 404 return !mDismissed && !mHidden; 405 } 406 407 @Override isHidden()408 public boolean isHidden() { 409 return mHidden; 410 } 411 412 /** 413 * Updates the coordinates of this popup. 414 * The specified coordinates may be adjusted to make sure the popup is entirely on-screen. 415 * This is a no-op if this popup is not showing. 416 */ updateCoordinates(Rect contentRectOnScreen)417 private void updateCoordinates(Rect contentRectOnScreen) { 418 Objects.requireNonNull(contentRectOnScreen); 419 420 if (!isShowing() || !mPopupWindow.isShowing()) { 421 return; 422 } 423 424 cancelOverflowAnimations(); 425 refreshCoordinatesAndOverflowDirection(contentRectOnScreen); 426 preparePopupContent(); 427 // We need to specify the position in window coordinates. 428 // TODO: Consider to use PopupWindow.setIsLaidOutInScreen(true) so that we can 429 // specify the popup position in screen coordinates. 430 mPopupWindow.update( 431 mCoordsOnWindow.x, mCoordsOnWindow.y, 432 mPopupWindow.getWidth(), mPopupWindow.getHeight()); 433 } 434 refreshCoordinatesAndOverflowDirection(Rect contentRectOnScreen)435 private void refreshCoordinatesAndOverflowDirection(Rect contentRectOnScreen) { 436 refreshViewPort(); 437 438 final int x; 439 if (mPopupWindow.getWidth() > mViewPortOnScreen.width()) { 440 // Not enough space - prefer to position as far left as possible 441 x = mViewPortOnScreen.left; 442 } else { 443 // Initialize x ensuring that the toolbar isn't rendered behind the system bar insets 444 x = Math.clamp(contentRectOnScreen.centerX() - mPopupWindow.getWidth() / 2, 445 mViewPortOnScreen.left, mViewPortOnScreen.right - mPopupWindow.getWidth()); 446 } 447 448 final int y; 449 450 final int availableHeightAboveContent = 451 contentRectOnScreen.top - mViewPortOnScreen.top; 452 final int availableHeightBelowContent = 453 mViewPortOnScreen.bottom - contentRectOnScreen.bottom; 454 455 final int margin = 2 * mMarginVertical; 456 final int toolbarHeightWithVerticalMargin = mLineHeight + margin; 457 458 if (!hasOverflow()) { 459 if (availableHeightAboveContent >= toolbarHeightWithVerticalMargin) { 460 // There is enough space at the top of the content. 461 y = contentRectOnScreen.top - toolbarHeightWithVerticalMargin; 462 } else if (availableHeightBelowContent >= toolbarHeightWithVerticalMargin) { 463 // There is enough space at the bottom of the content. 464 y = contentRectOnScreen.bottom; 465 } else if (availableHeightBelowContent >= mLineHeight) { 466 // Just enough space to fit the toolbar with no vertical margins. 467 y = contentRectOnScreen.bottom - mMarginVertical; 468 } else { 469 // Not enough space. Prefer to position as high as possible. 470 y = Math.max( 471 mViewPortOnScreen.top, 472 contentRectOnScreen.top - toolbarHeightWithVerticalMargin); 473 } 474 } else { 475 // Has an overflow. 476 final int minimumOverflowHeightWithMargin = 477 calculateOverflowHeight(MIN_OVERFLOW_SIZE) + margin; 478 final int availableHeightThroughContentDown = 479 mViewPortOnScreen.bottom - contentRectOnScreen.top 480 + toolbarHeightWithVerticalMargin; 481 final int availableHeightThroughContentUp = 482 contentRectOnScreen.bottom - mViewPortOnScreen.top 483 + toolbarHeightWithVerticalMargin; 484 485 if (availableHeightAboveContent >= minimumOverflowHeightWithMargin) { 486 // There is enough space at the top of the content rect for the overflow. 487 // Position above and open upwards. 488 updateOverflowHeight(availableHeightAboveContent - margin); 489 y = contentRectOnScreen.top - mPopupWindow.getHeight(); 490 mOpenOverflowUpwards = true; 491 } else if (availableHeightAboveContent >= toolbarHeightWithVerticalMargin 492 && availableHeightThroughContentDown >= minimumOverflowHeightWithMargin) { 493 // There is enough space at the top of the content rect for the main panel 494 // but not the overflow. 495 // Position above but open downwards. 496 updateOverflowHeight(availableHeightThroughContentDown - margin); 497 y = contentRectOnScreen.top - toolbarHeightWithVerticalMargin; 498 mOpenOverflowUpwards = false; 499 } else if (availableHeightBelowContent >= minimumOverflowHeightWithMargin) { 500 // There is enough space at the bottom of the content rect for the overflow. 501 // Position below and open downwards. 502 updateOverflowHeight(availableHeightBelowContent - margin); 503 y = contentRectOnScreen.bottom; 504 mOpenOverflowUpwards = false; 505 } else if (availableHeightBelowContent >= toolbarHeightWithVerticalMargin 506 && mViewPortOnScreen.height() >= minimumOverflowHeightWithMargin) { 507 // There is enough space at the bottom of the content rect for the main panel 508 // but not the overflow. 509 // Position below but open upwards. 510 updateOverflowHeight(availableHeightThroughContentUp - margin); 511 y = contentRectOnScreen.bottom + toolbarHeightWithVerticalMargin 512 - mPopupWindow.getHeight(); 513 mOpenOverflowUpwards = true; 514 } else { 515 // Not enough space. 516 // Position at the top of the view port and open downwards. 517 updateOverflowHeight(mViewPortOnScreen.height() - margin); 518 y = mViewPortOnScreen.top; 519 mOpenOverflowUpwards = false; 520 } 521 } 522 523 // We later specify the location of PopupWindow relative to the attached window. 524 // The idea here is that 1) we can get the location of a View in both window coordinates 525 // and screen coordinates, where the offset between them should be equal to the window 526 // origin, and 2) we can use an arbitrary for this calculation while calculating the 527 // location of the rootview is supposed to be least expensive. 528 // TODO: Consider to use PopupWindow.setIsLaidOutInScreen(true) so that we can avoid 529 // the following calculation. 530 mParent.getRootView().getLocationOnScreen(mTmpCoords); 531 int rootViewLeftOnScreen = mTmpCoords[0]; 532 int rootViewTopOnScreen = mTmpCoords[1]; 533 mParent.getRootView().getLocationInWindow(mTmpCoords); 534 int rootViewLeftOnWindow = mTmpCoords[0]; 535 int rootViewTopOnWindow = mTmpCoords[1]; 536 int windowLeftOnScreen = rootViewLeftOnScreen - rootViewLeftOnWindow; 537 int windowTopOnScreen = rootViewTopOnScreen - rootViewTopOnWindow; 538 // In some cases, app can have specific Window for Android UI components such as EditText. 539 // In this case, Window bounds != App bounds. Hence, instead of ensuring non-negative 540 // PopupWindow coords, app bounds should be used to limit the coords. For instance, 541 // ____ <- | 542 // | | |W1 & App bounds 543 // |___| | 544 // |W2 | | W2 has smaller bounds and contain EditText where PopupWindow will be opened. 545 // ---- <-| 546 // Here, we'll open PopupWindow upwards, but as PopupWindow is anchored based on W2, it 547 // will have negative Y coords. This negative Y is safe to use because it's still within app 548 // bounds. However, if it gets out of app bounds, we should clamp it to 0. 549 Rect appBounds = mContext 550 .getResources().getConfiguration().windowConfiguration.getAppBounds(); 551 mCoordsOnWindow.set(x - windowLeftOnScreen, y - windowTopOnScreen); 552 if (rootViewLeftOnScreen + mCoordsOnWindow.x < appBounds.left) { 553 mCoordsOnWindow.x = 0; 554 } 555 if (rootViewTopOnScreen + mCoordsOnWindow.y < appBounds.top) { 556 mCoordsOnWindow.y = 0; 557 } 558 } 559 560 /** 561 * Performs the "show" animation on the floating popup. 562 */ runShowAnimation()563 private void runShowAnimation() { 564 mShowAnimation.start(); 565 } 566 567 /** 568 * Performs the "dismiss" animation on the floating popup. 569 */ runDismissAnimation()570 private void runDismissAnimation() { 571 mDismissAnimation.start(); 572 } 573 574 /** 575 * Performs the "hide" animation on the floating popup. 576 */ runHideAnimation()577 private void runHideAnimation() { 578 mHideAnimation.start(); 579 } 580 cancelDismissAndHideAnimations()581 private void cancelDismissAndHideAnimations() { 582 mDismissAnimation.cancel(); 583 mHideAnimation.cancel(); 584 } 585 cancelOverflowAnimations()586 private void cancelOverflowAnimations() { 587 mContentContainer.clearAnimation(); 588 mMainPanel.animate().cancel(); 589 mOverflowPanel.animate().cancel(); 590 mToArrow.stop(); 591 mToOverflow.stop(); 592 } 593 openOverflow()594 private void openOverflow() { 595 final int targetWidth = mOverflowPanelSize.getWidth(); 596 final int targetHeight = mOverflowPanelSize.getHeight(); 597 final int startWidth = mContentContainer.getWidth(); 598 final int startHeight = mContentContainer.getHeight(); 599 final float startY = mContentContainer.getY(); 600 final float left = mContentContainer.getX(); 601 final float right = left + mContentContainer.getWidth(); 602 Animation widthAnimation = new Animation() { 603 @Override 604 protected void applyTransformation(float interpolatedTime, Transformation t) { 605 int deltaWidth = (int) (interpolatedTime * (targetWidth - startWidth)); 606 setWidth(mContentContainer, startWidth + deltaWidth); 607 if (isInRTLMode()) { 608 mContentContainer.setX(left); 609 610 // Lock the panels in place. 611 mMainPanel.setX(0); 612 mOverflowPanel.setX(0); 613 } else { 614 mContentContainer.setX(right - mContentContainer.getWidth()); 615 616 // Offset the panels' positions so they look like they're locked in place 617 // on the screen. 618 mMainPanel.setX(mContentContainer.getWidth() - startWidth); 619 mOverflowPanel.setX(mContentContainer.getWidth() - targetWidth); 620 } 621 } 622 }; 623 Animation heightAnimation = new Animation() { 624 @Override 625 protected void applyTransformation(float interpolatedTime, Transformation t) { 626 int deltaHeight = (int) (interpolatedTime * (targetHeight - startHeight)); 627 setHeight(mContentContainer, startHeight + deltaHeight); 628 if (mOpenOverflowUpwards) { 629 mContentContainer.setY( 630 startY - (mContentContainer.getHeight() - startHeight)); 631 positionContentYCoordinatesIfOpeningOverflowUpwards(); 632 } 633 } 634 }; 635 final float overflowButtonStartX = mOverflowButton.getX(); 636 final float overflowButtonTargetX = 637 isInRTLMode() ? overflowButtonStartX + targetWidth - mOverflowButton.getWidth() 638 : overflowButtonStartX - targetWidth + mOverflowButton.getWidth(); 639 Animation overflowButtonAnimation = new Animation() { 640 @Override 641 protected void applyTransformation(float interpolatedTime, Transformation t) { 642 float overflowButtonX = overflowButtonStartX 643 + interpolatedTime * (overflowButtonTargetX - overflowButtonStartX); 644 float deltaContainerWidth = 645 isInRTLMode() ? 0 : mContentContainer.getWidth() - startWidth; 646 float actualOverflowButtonX = overflowButtonX + deltaContainerWidth; 647 mOverflowButton.setX(actualOverflowButtonX); 648 } 649 }; 650 widthAnimation.setInterpolator(mLogAccelerateInterpolator); 651 widthAnimation.setDuration(getAdjustedDuration(250)); 652 heightAnimation.setInterpolator(mFastOutSlowInInterpolator); 653 heightAnimation.setDuration(getAdjustedDuration(250)); 654 overflowButtonAnimation.setInterpolator(mFastOutSlowInInterpolator); 655 overflowButtonAnimation.setDuration(getAdjustedDuration(250)); 656 mOpenOverflowAnimation.getAnimations().clear(); 657 mOpenOverflowAnimation.getAnimations().clear(); 658 mOpenOverflowAnimation.addAnimation(widthAnimation); 659 mOpenOverflowAnimation.addAnimation(heightAnimation); 660 mOpenOverflowAnimation.addAnimation(overflowButtonAnimation); 661 mContentContainer.startAnimation(mOpenOverflowAnimation); 662 mIsOverflowOpen = true; 663 mMainPanel.animate() 664 .alpha(0).withLayer() 665 .setInterpolator(mLinearOutSlowInInterpolator) 666 .setDuration(250) 667 .start(); 668 mOverflowPanel.setAlpha(1); // fadeIn in 0ms. 669 } 670 closeOverflow()671 private void closeOverflow() { 672 final int targetWidth = mMainPanelSize.getWidth(); 673 final int startWidth = mContentContainer.getWidth(); 674 final float left = mContentContainer.getX(); 675 final float right = left + mContentContainer.getWidth(); 676 Animation widthAnimation = new Animation() { 677 @Override 678 protected void applyTransformation(float interpolatedTime, Transformation t) { 679 int deltaWidth = (int) (interpolatedTime * (targetWidth - startWidth)); 680 setWidth(mContentContainer, startWidth + deltaWidth); 681 if (isInRTLMode()) { 682 mContentContainer.setX(left); 683 684 // Lock the panels in place. 685 mMainPanel.setX(0); 686 mOverflowPanel.setX(0); 687 } else { 688 mContentContainer.setX(right - mContentContainer.getWidth()); 689 690 // Offset the panels' positions so they look like they're locked in place 691 // on the screen. 692 mMainPanel.setX(mContentContainer.getWidth() - targetWidth); 693 mOverflowPanel.setX(mContentContainer.getWidth() - startWidth); 694 } 695 } 696 }; 697 final int targetHeight = mMainPanelSize.getHeight(); 698 final int startHeight = mContentContainer.getHeight(); 699 final float bottom = mContentContainer.getY() + mContentContainer.getHeight(); 700 Animation heightAnimation = new Animation() { 701 @Override 702 protected void applyTransformation(float interpolatedTime, Transformation t) { 703 int deltaHeight = (int) (interpolatedTime * (targetHeight - startHeight)); 704 setHeight(mContentContainer, startHeight + deltaHeight); 705 if (mOpenOverflowUpwards) { 706 mContentContainer.setY(bottom - mContentContainer.getHeight()); 707 positionContentYCoordinatesIfOpeningOverflowUpwards(); 708 } 709 } 710 }; 711 final float overflowButtonStartX = mOverflowButton.getX(); 712 final float overflowButtonTargetX = 713 isInRTLMode() ? overflowButtonStartX - startWidth + mOverflowButton.getWidth() 714 : overflowButtonStartX + startWidth - mOverflowButton.getWidth(); 715 Animation overflowButtonAnimation = new Animation() { 716 @Override 717 protected void applyTransformation(float interpolatedTime, Transformation t) { 718 float overflowButtonX = overflowButtonStartX 719 + interpolatedTime * (overflowButtonTargetX - overflowButtonStartX); 720 float deltaContainerWidth = 721 isInRTLMode() ? 0 : mContentContainer.getWidth() - startWidth; 722 float actualOverflowButtonX = overflowButtonX + deltaContainerWidth; 723 mOverflowButton.setX(actualOverflowButtonX); 724 } 725 }; 726 widthAnimation.setInterpolator(mFastOutSlowInInterpolator); 727 widthAnimation.setDuration(getAdjustedDuration(250)); 728 heightAnimation.setInterpolator(mLogAccelerateInterpolator); 729 heightAnimation.setDuration(getAdjustedDuration(250)); 730 overflowButtonAnimation.setInterpolator(mFastOutSlowInInterpolator); 731 overflowButtonAnimation.setDuration(getAdjustedDuration(250)); 732 mCloseOverflowAnimation.getAnimations().clear(); 733 mCloseOverflowAnimation.addAnimation(widthAnimation); 734 mCloseOverflowAnimation.addAnimation(heightAnimation); 735 mCloseOverflowAnimation.addAnimation(overflowButtonAnimation); 736 mContentContainer.startAnimation(mCloseOverflowAnimation); 737 mIsOverflowOpen = false; 738 mMainPanel.animate() 739 .alpha(1).withLayer() 740 .setInterpolator(mFastOutLinearInInterpolator) 741 .setDuration(100) 742 .start(); 743 mOverflowPanel.animate() 744 .alpha(0).withLayer() 745 .setInterpolator(mLinearOutSlowInInterpolator) 746 .setDuration(150) 747 .start(); 748 } 749 750 /** 751 * Defines the position of the floating toolbar popup panels when transition animation has 752 * stopped. 753 */ setPanelsStatesAtRestingPosition()754 private void setPanelsStatesAtRestingPosition() { 755 mOverflowButton.setEnabled(true); 756 mOverflowPanel.awakenScrollBars(); 757 758 if (mIsOverflowOpen) { 759 // Set open state. 760 final Size containerSize = mOverflowPanelSize; 761 setSize(mContentContainer, containerSize); 762 mMainPanel.setAlpha(0); 763 mMainPanel.setVisibility(View.INVISIBLE); 764 mOverflowPanel.setAlpha(1); 765 mOverflowPanel.setVisibility(View.VISIBLE); 766 mOverflowButton.setImageDrawable(mArrow); 767 mOverflowButton.setContentDescription(mContext.getString( 768 R.string.floating_toolbar_close_overflow_description)); 769 770 // Update x-coordinates depending on RTL state. 771 if (isInRTLMode()) { 772 mContentContainer.setX(mMarginHorizontal); // align left 773 mMainPanel.setX(0); // align left 774 mOverflowButton.setX(// align right 775 containerSize.getWidth() - mOverflowButtonSize.getWidth()); 776 mOverflowPanel.setX(0); // align left 777 } else { 778 mContentContainer.setX(// align right 779 mPopupWindow.getWidth() - containerSize.getWidth() - mMarginHorizontal); 780 mMainPanel.setX(-mContentContainer.getX()); // align right 781 mOverflowButton.setX(0); // align left 782 mOverflowPanel.setX(0); // align left 783 } 784 785 // Update y-coordinates depending on overflow's open direction. 786 if (mOpenOverflowUpwards) { 787 mContentContainer.setY(mMarginVertical); // align top 788 mMainPanel.setY(// align bottom 789 containerSize.getHeight() - mContentContainer.getHeight()); 790 mOverflowButton.setY(// align bottom 791 containerSize.getHeight() - mOverflowButtonSize.getHeight()); 792 mOverflowPanel.setY(0); // align top 793 } else { 794 // opens downwards. 795 mContentContainer.setY(mMarginVertical); // align top 796 mMainPanel.setY(0); // align top 797 mOverflowButton.setY(0); // align top 798 mOverflowPanel.setY(mOverflowButtonSize.getHeight()); // align bottom 799 } 800 } else { 801 // Overflow not open. Set closed state. 802 final Size containerSize = mMainPanelSize; 803 setSize(mContentContainer, containerSize); 804 mMainPanel.setAlpha(1); 805 mMainPanel.setVisibility(View.VISIBLE); 806 mOverflowPanel.setAlpha(0); 807 mOverflowPanel.setVisibility(View.INVISIBLE); 808 mOverflowButton.setImageDrawable(mOverflow); 809 mOverflowButton.setContentDescription(mContext.getString( 810 R.string.floating_toolbar_open_overflow_description)); 811 812 if (hasOverflow()) { 813 // Update x-coordinates depending on RTL state. 814 if (isInRTLMode()) { 815 mContentContainer.setX(mMarginHorizontal); // align left 816 mMainPanel.setX(0); // align left 817 mOverflowButton.setX(0); // align left 818 mOverflowPanel.setX(0); // align left 819 } else { 820 mContentContainer.setX(// align right 821 mPopupWindow.getWidth() - containerSize.getWidth() - mMarginHorizontal); 822 mMainPanel.setX(0); // align left 823 mOverflowButton.setX(// align right 824 containerSize.getWidth() - mOverflowButtonSize.getWidth()); 825 mOverflowPanel.setX(// align right 826 containerSize.getWidth() - mOverflowPanelSize.getWidth()); 827 } 828 829 // Update y-coordinates depending on overflow's open direction. 830 if (mOpenOverflowUpwards) { 831 mContentContainer.setY(// align bottom 832 mMarginVertical + mOverflowPanelSize.getHeight() 833 - containerSize.getHeight()); 834 mMainPanel.setY(0); // align top 835 mOverflowButton.setY(0); // align top 836 mOverflowPanel.setY(// align bottom 837 containerSize.getHeight() - mOverflowPanelSize.getHeight()); 838 } else { 839 // opens downwards. 840 mContentContainer.setY(mMarginVertical); // align top 841 mMainPanel.setY(0); // align top 842 mOverflowButton.setY(0); // align top 843 mOverflowPanel.setY(mOverflowButtonSize.getHeight()); // align bottom 844 } 845 } else { 846 // No overflow. 847 mContentContainer.setX(mMarginHorizontal); // align left 848 mContentContainer.setY(mMarginVertical); // align top 849 mMainPanel.setX(0); // align left 850 mMainPanel.setY(0); // align top 851 } 852 } 853 } 854 updateOverflowHeight(int suggestedHeight)855 private void updateOverflowHeight(int suggestedHeight) { 856 if (hasOverflow()) { 857 final int maxItemSize = 858 (suggestedHeight - mOverflowButtonSize.getHeight()) / mLineHeight; 859 final int newHeight = calculateOverflowHeight(maxItemSize); 860 if (mOverflowPanelSize.getHeight() != newHeight) { 861 mOverflowPanelSize = new Size(mOverflowPanelSize.getWidth(), newHeight); 862 } 863 setSize(mOverflowPanel, mOverflowPanelSize); 864 if (mIsOverflowOpen) { 865 setSize(mContentContainer, mOverflowPanelSize); 866 if (mOpenOverflowUpwards) { 867 final int deltaHeight = mOverflowPanelSize.getHeight() - newHeight; 868 mContentContainer.setY(mContentContainer.getY() + deltaHeight); 869 mOverflowButton.setY(mOverflowButton.getY() - deltaHeight); 870 } 871 } else { 872 setSize(mContentContainer, mMainPanelSize); 873 } 874 updatePopupSize(); 875 } 876 } 877 updatePopupSize()878 private void updatePopupSize() { 879 int width = 0; 880 int height = 0; 881 if (mMainPanelSize != null) { 882 width = Math.max(width, mMainPanelSize.getWidth()); 883 height = Math.max(height, mMainPanelSize.getHeight()); 884 } 885 if (mOverflowPanelSize != null) { 886 width = Math.max(width, mOverflowPanelSize.getWidth()); 887 height = Math.max(height, mOverflowPanelSize.getHeight()); 888 } 889 mPopupWindow.setWidth(width + mMarginHorizontal * 2); 890 mPopupWindow.setHeight(height + mMarginVertical * 2); 891 maybeComputeTransitionDurationScale(); 892 } 893 refreshViewPort()894 private void refreshViewPort() { 895 mParent.getWindowVisibleDisplayFrame(mViewPortOnScreen); 896 } 897 getAdjustedToolbarWidth(int suggestedWidth)898 private int getAdjustedToolbarWidth(int suggestedWidth) { 899 int width = suggestedWidth; 900 refreshViewPort(); 901 int maximumWidth = mViewPortOnScreen.width() - 2 * mParent.getResources() 902 .getDimensionPixelSize(R.dimen.floating_toolbar_horizontal_margin); 903 if (width <= 0) { 904 width = mParent.getResources() 905 .getDimensionPixelSize(R.dimen.floating_toolbar_preferred_width); 906 } 907 return Math.min(width, maximumWidth); 908 } 909 910 /** 911 * Sets the touchable region of this popup to be zero. This means that all touch events on 912 * this popup will go through to the surface behind it. 913 */ setZeroTouchableSurface()914 private void setZeroTouchableSurface() { 915 mTouchableRegion.setEmpty(); 916 } 917 918 /** 919 * Sets the touchable region of this popup to be the area occupied by its content. 920 */ setContentAreaAsTouchableSurface()921 private void setContentAreaAsTouchableSurface() { 922 Objects.requireNonNull(mMainPanelSize); 923 final int width; 924 final int height; 925 if (mIsOverflowOpen) { 926 Objects.requireNonNull(mOverflowPanelSize); 927 width = mOverflowPanelSize.getWidth(); 928 height = mOverflowPanelSize.getHeight(); 929 } else { 930 width = mMainPanelSize.getWidth(); 931 height = mMainPanelSize.getHeight(); 932 } 933 mTouchableRegion.set( 934 (int) mContentContainer.getX(), 935 (int) mContentContainer.getY(), 936 (int) mContentContainer.getX() + width, 937 (int) mContentContainer.getY() + height); 938 } 939 940 /** 941 * Make the touchable area of this popup be the area specified by mTouchableRegion. 942 * This should be called after the popup window has been dismissed (dismiss/hide) 943 * and is probably being re-shown with a new content root view. 944 */ setTouchableSurfaceInsetsComputer()945 private void setTouchableSurfaceInsetsComputer() { 946 ViewTreeObserver viewTreeObserver = mPopupWindow.getContentView() 947 .getRootView() 948 .getViewTreeObserver(); 949 viewTreeObserver.removeOnComputeInternalInsetsListener(mInsetsComputer); 950 viewTreeObserver.addOnComputeInternalInsetsListener(mInsetsComputer); 951 } 952 isInRTLMode()953 private boolean isInRTLMode() { 954 return mContext.getApplicationInfo().hasRtlSupport() 955 && mContext.getResources().getConfiguration().getLayoutDirection() 956 == View.LAYOUT_DIRECTION_RTL; 957 } 958 hasOverflow()959 private boolean hasOverflow() { 960 return mOverflowPanelSize != null; 961 } 962 963 /** 964 * Fits as many menu items in the main panel and returns a list of the menu items that 965 * were not fit in. 966 * 967 * @return The menu items that are not included in this main panel. 968 */ layoutMainPanelItems( List<MenuItem> menuItems, final int toolbarWidth)969 public List<MenuItem> layoutMainPanelItems( 970 List<MenuItem> menuItems, final int toolbarWidth) { 971 Objects.requireNonNull(menuItems); 972 973 int availableWidth = toolbarWidth; 974 975 final ArrayList<MenuItem> remainingMenuItems = new ArrayList<>(); 976 // add the overflow menu items to the end of the remainingMenuItems list. 977 final ArrayList<MenuItem> overflowMenuItems = new ArrayList<>(); 978 for (MenuItem menuItem : menuItems) { 979 if (menuItem.getItemId() != android.R.id.textAssist 980 && menuItem.requiresOverflow()) { 981 overflowMenuItems.add(menuItem); 982 } else { 983 remainingMenuItems.add(menuItem); 984 } 985 } 986 remainingMenuItems.addAll(overflowMenuItems); 987 988 mMainPanel.removeAllViews(); 989 mMainPanel.setPaddingRelative(0, 0, 0, 0); 990 991 int lastGroupId = -1; 992 boolean isFirstItem = true; 993 while (!remainingMenuItems.isEmpty()) { 994 final MenuItem menuItem = remainingMenuItems.get(0); 995 996 // if this is the first item, regardless of requiresOverflow(), it should be 997 // displayed on the main panel. Otherwise all items including this one will be 998 // overflow items, and should be displayed in overflow panel. 999 if (!isFirstItem && menuItem.requiresOverflow()) { 1000 break; 1001 } 1002 1003 final boolean showIcon = isFirstItem && menuItem.getItemId() == R.id.textAssist; 1004 final View menuItemButton = createMenuItemButton( 1005 mContext, menuItem, mIconTextSpacing, showIcon); 1006 if (!showIcon && menuItemButton instanceof LinearLayout) { 1007 ((LinearLayout) menuItemButton).setGravity(Gravity.CENTER); 1008 } 1009 1010 // Adding additional start padding for the first button to even out button spacing. 1011 if (isFirstItem) { 1012 menuItemButton.setPaddingRelative( 1013 (int) (1.5 * menuItemButton.getPaddingStart()), 1014 menuItemButton.getPaddingTop(), 1015 menuItemButton.getPaddingEnd(), 1016 menuItemButton.getPaddingBottom()); 1017 } 1018 1019 // Adding additional end padding for the last button to even out button spacing. 1020 boolean isLastItem = remainingMenuItems.size() == 1; 1021 if (isLastItem) { 1022 menuItemButton.setPaddingRelative( 1023 menuItemButton.getPaddingStart(), 1024 menuItemButton.getPaddingTop(), 1025 (int) (1.5 * menuItemButton.getPaddingEnd()), 1026 menuItemButton.getPaddingBottom()); 1027 } 1028 1029 menuItemButton.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); 1030 final int menuItemButtonWidth = Math.min( 1031 menuItemButton.getMeasuredWidth(), toolbarWidth); 1032 1033 // Check if we can fit an item while reserving space for the overflowButton. 1034 final boolean canFitWithOverflow = 1035 menuItemButtonWidth <= availableWidth - mOverflowButtonSize.getWidth(); 1036 final boolean canFitNoOverflow = 1037 isLastItem && menuItemButtonWidth <= availableWidth; 1038 if (canFitWithOverflow || canFitNoOverflow) { 1039 setButtonTagAndClickListener(menuItemButton, menuItem); 1040 // Set tooltips for main panel items, but not overflow items (b/35726766). 1041 menuItemButton.setTooltipText(menuItem.getTooltipText()); 1042 mMainPanel.addView(menuItemButton); 1043 final ViewGroup.LayoutParams params = menuItemButton.getLayoutParams(); 1044 params.width = menuItemButtonWidth; 1045 menuItemButton.setLayoutParams(params); 1046 availableWidth -= menuItemButtonWidth; 1047 remainingMenuItems.remove(0); 1048 } else { 1049 break; 1050 } 1051 lastGroupId = menuItem.getGroupId(); 1052 isFirstItem = false; 1053 } 1054 1055 if (!remainingMenuItems.isEmpty()) { 1056 // Reserve space for overflowButton. 1057 mMainPanel.setPaddingRelative(0, 0, mOverflowButtonSize.getWidth(), 0); 1058 } 1059 1060 mMainPanelSize = measure(mMainPanel); 1061 return remainingMenuItems; 1062 } 1063 layoutOverflowPanelItems(List<MenuItem> menuItems)1064 private void layoutOverflowPanelItems(List<MenuItem> menuItems) { 1065 ArrayAdapter<MenuItem> overflowPanelAdapter = 1066 (ArrayAdapter<MenuItem>) mOverflowPanel.getAdapter(); 1067 overflowPanelAdapter.clear(); 1068 final int size = menuItems.size(); 1069 for (int i = 0; i < size; i++) { 1070 overflowPanelAdapter.add(menuItems.get(i)); 1071 } 1072 mOverflowPanel.setAdapter(overflowPanelAdapter); 1073 if (mOpenOverflowUpwards) { 1074 mOverflowPanel.setY(0); 1075 } else { 1076 mOverflowPanel.setY(mOverflowButtonSize.getHeight()); 1077 } 1078 1079 int width = Math.max(getOverflowWidth(), mOverflowButtonSize.getWidth()); 1080 int height = calculateOverflowHeight(MAX_OVERFLOW_SIZE); 1081 mOverflowPanelSize = new Size(width, height); 1082 setSize(mOverflowPanel, mOverflowPanelSize); 1083 } 1084 1085 /** 1086 * Resets the content container and appropriately position it's panels. 1087 */ preparePopupContent()1088 private void preparePopupContent() { 1089 mContentContainer.removeAllViews(); 1090 1091 // Add views in the specified order so they stack up as expected. 1092 // Order: overflowPanel, mainPanel, overflowButton. 1093 if (hasOverflow()) { 1094 mContentContainer.addView(mOverflowPanel); 1095 } 1096 mContentContainer.addView(mMainPanel); 1097 if (hasOverflow()) { 1098 mContentContainer.addView(mOverflowButton); 1099 } 1100 setPanelsStatesAtRestingPosition(); 1101 setContentAreaAsTouchableSurface(); 1102 1103 // The positioning of contents in RTL is wrong when the view is first rendered. 1104 // Hide the view and post a runnable to recalculate positions and render the view. 1105 // TODO: Investigate why this happens and fix. 1106 if (isInRTLMode()) { 1107 mContentContainer.setAlpha(0); 1108 mContentContainer.post(mPreparePopupContentRTLHelper); 1109 } 1110 } 1111 1112 /** 1113 * Clears out the panels and their container. Resets their calculated sizes. 1114 */ clearPanels()1115 private void clearPanels() { 1116 mOverflowPanelSize = null; 1117 mMainPanelSize = null; 1118 mIsOverflowOpen = false; 1119 mMainPanel.removeAllViews(); 1120 ArrayAdapter<MenuItem> overflowPanelAdapter = 1121 (ArrayAdapter<MenuItem>) mOverflowPanel.getAdapter(); 1122 overflowPanelAdapter.clear(); 1123 mOverflowPanel.setAdapter(overflowPanelAdapter); 1124 mContentContainer.removeAllViews(); 1125 } 1126 positionContentYCoordinatesIfOpeningOverflowUpwards()1127 private void positionContentYCoordinatesIfOpeningOverflowUpwards() { 1128 if (mOpenOverflowUpwards) { 1129 mMainPanel.setY(mContentContainer.getHeight() - mMainPanelSize.getHeight()); 1130 mOverflowButton.setY(mContentContainer.getHeight() - mOverflowButton.getHeight()); 1131 mOverflowPanel.setY(mContentContainer.getHeight() - mOverflowPanelSize.getHeight()); 1132 } 1133 } 1134 getOverflowWidth()1135 private int getOverflowWidth() { 1136 int overflowWidth = 0; 1137 final int count = mOverflowPanel.getAdapter().getCount(); 1138 for (int i = 0; i < count; i++) { 1139 MenuItem menuItem = (MenuItem) mOverflowPanel.getAdapter().getItem(i); 1140 overflowWidth = 1141 Math.max(mOverflowPanelViewHelper.calculateWidth(menuItem), overflowWidth); 1142 } 1143 return overflowWidth; 1144 } 1145 calculateOverflowHeight(int maxItemSize)1146 private int calculateOverflowHeight(int maxItemSize) { 1147 // Maximum of 4 items, minimum of 2 if the overflow has to scroll. 1148 int actualSize = Math.min( 1149 MAX_OVERFLOW_SIZE, 1150 Math.min( 1151 Math.max(MIN_OVERFLOW_SIZE, maxItemSize), 1152 mOverflowPanel.getCount())); 1153 int extension = 0; 1154 if (actualSize < mOverflowPanel.getCount()) { 1155 // The overflow will require scrolling to get to all the items. 1156 // Extend the height so that part of the hidden items is displayed. 1157 extension = (int) (mLineHeight * 0.5f); 1158 } 1159 return actualSize * mLineHeight 1160 + mOverflowButtonSize.getHeight() 1161 + extension; 1162 } 1163 setButtonTagAndClickListener(View menuItemButton, MenuItem menuItem)1164 private void setButtonTagAndClickListener(View menuItemButton, MenuItem menuItem) { 1165 menuItemButton.setTag(MenuItemRepr.of(menuItem)); 1166 menuItemButton.setOnClickListener(mMenuItemButtonOnClickListener); 1167 } 1168 1169 /** 1170 * NOTE: Use only in android.view.animation.* animations. Do not use in android.animation.* 1171 * animations. See comment about this in the code. 1172 */ getAdjustedDuration(int originalDuration)1173 private int getAdjustedDuration(int originalDuration) { 1174 if (mTransitionDurationScale < 150) { 1175 // For smaller transition, decrease the time. 1176 return Math.max(originalDuration - 50, 0); 1177 } else if (mTransitionDurationScale > 300) { 1178 // For bigger transition, increase the time. 1179 return originalDuration + 50; 1180 } 1181 1182 // Scale the animation duration with getDurationScale(). This allows 1183 // android.view.animation.* animations to scale just like android.animation.* animations 1184 // when animator duration scale is adjusted in "Developer Options". 1185 // For this reason, do not use this method for android.animation.* animations. 1186 return (int) (originalDuration * ValueAnimator.getDurationScale()); 1187 } 1188 maybeComputeTransitionDurationScale()1189 private void maybeComputeTransitionDurationScale() { 1190 if (mMainPanelSize != null && mOverflowPanelSize != null) { 1191 int w = mMainPanelSize.getWidth() - mOverflowPanelSize.getWidth(); 1192 int h = mOverflowPanelSize.getHeight() - mMainPanelSize.getHeight(); 1193 mTransitionDurationScale = (int) (Math.sqrt(w * w + h * h) 1194 / mContentContainer.getContext().getResources().getDisplayMetrics().density); 1195 } 1196 } 1197 createMainPanel()1198 private ViewGroup createMainPanel() { 1199 ViewGroup mainPanel = new LinearLayout(mContext) { 1200 @Override 1201 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 1202 if (isOverflowAnimating()) { 1203 // Update widthMeasureSpec to make sure that this view is not clipped 1204 // as we offset its coordinates with respect to its parent. 1205 widthMeasureSpec = MeasureSpec.makeMeasureSpec( 1206 mMainPanelSize.getWidth(), 1207 MeasureSpec.EXACTLY); 1208 } 1209 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 1210 } 1211 1212 @Override 1213 public boolean onInterceptTouchEvent(MotionEvent ev) { 1214 // Intercept the touch event while the overflow is animating. 1215 return isOverflowAnimating(); 1216 } 1217 }; 1218 return mainPanel; 1219 } 1220 createOverflowButton()1221 private ImageButton createOverflowButton() { 1222 final ImageButton overflowButton = (ImageButton) LayoutInflater.from(mContext) 1223 .inflate(R.layout.floating_popup_overflow_button, null); 1224 overflowButton.setImageDrawable(mOverflow); 1225 overflowButton.setOnClickListener(v -> { 1226 if (mIsOverflowOpen) { 1227 overflowButton.setImageDrawable(mToOverflow); 1228 mToOverflow.start(); 1229 closeOverflow(); 1230 } else { 1231 overflowButton.setImageDrawable(mToArrow); 1232 mToArrow.start(); 1233 openOverflow(); 1234 } 1235 }); 1236 return overflowButton; 1237 } 1238 createOverflowPanel()1239 private OverflowPanel createOverflowPanel() { 1240 final OverflowPanel overflowPanel = new OverflowPanel(this); 1241 overflowPanel.setLayoutParams(new ViewGroup.LayoutParams( 1242 ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); 1243 overflowPanel.setDivider(null); 1244 overflowPanel.setDividerHeight(0); 1245 1246 final ArrayAdapter adapter = 1247 new ArrayAdapter<MenuItem>(mContext, 0) { 1248 @Override 1249 public View getView(int position, View convertView, ViewGroup parent) { 1250 return mOverflowPanelViewHelper.getView( 1251 getItem(position), mOverflowPanelSize.getWidth(), convertView); 1252 } 1253 }; 1254 overflowPanel.setAdapter(adapter); 1255 1256 overflowPanel.setOnItemClickListener((parent, view, position, id) -> { 1257 MenuItem menuItem = (MenuItem) overflowPanel.getAdapter().getItem(position); 1258 if (mOnMenuItemClickListener != null) { 1259 mOnMenuItemClickListener.onMenuItemClick(menuItem); 1260 } 1261 }); 1262 1263 return overflowPanel; 1264 } 1265 isOverflowAnimating()1266 private boolean isOverflowAnimating() { 1267 final boolean overflowOpening = mOpenOverflowAnimation.hasStarted() 1268 && !mOpenOverflowAnimation.hasEnded(); 1269 final boolean overflowClosing = mCloseOverflowAnimation.hasStarted() 1270 && !mCloseOverflowAnimation.hasEnded(); 1271 return overflowOpening || overflowClosing; 1272 } 1273 createOverflowAnimationListener()1274 private Animation.AnimationListener createOverflowAnimationListener() { 1275 Animation.AnimationListener listener = new Animation.AnimationListener() { 1276 @Override 1277 public void onAnimationStart(Animation animation) { 1278 // Disable the overflow button while it's animating. 1279 // It will be re-enabled when the animation stops. 1280 mOverflowButton.setEnabled(false); 1281 // Ensure both panels have visibility turned on when the overflow animation 1282 // starts. 1283 mMainPanel.setVisibility(View.VISIBLE); 1284 mOverflowPanel.setVisibility(View.VISIBLE); 1285 } 1286 1287 @Override 1288 public void onAnimationEnd(Animation animation) { 1289 // Posting this because it seems like this is called before the animation 1290 // actually ends. 1291 mContentContainer.post(() -> { 1292 setPanelsStatesAtRestingPosition(); 1293 setContentAreaAsTouchableSurface(); 1294 }); 1295 } 1296 1297 @Override 1298 public void onAnimationRepeat(Animation animation) { 1299 } 1300 }; 1301 return listener; 1302 } 1303 measure(View view)1304 private static Size measure(View view) { 1305 Preconditions.checkState(view.getParent() == null); 1306 view.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); 1307 return new Size(view.getMeasuredWidth(), view.getMeasuredHeight()); 1308 } 1309 setSize(View view, int width, int height)1310 private static void setSize(View view, int width, int height) { 1311 view.setMinimumWidth(width); 1312 view.setMinimumHeight(height); 1313 ViewGroup.LayoutParams params = view.getLayoutParams(); 1314 params = (params == null) ? new ViewGroup.LayoutParams(0, 0) : params; 1315 params.width = width; 1316 params.height = height; 1317 view.setLayoutParams(params); 1318 } 1319 setSize(View view, Size size)1320 private static void setSize(View view, Size size) { 1321 setSize(view, size.getWidth(), size.getHeight()); 1322 } 1323 setWidth(View view, int width)1324 private static void setWidth(View view, int width) { 1325 ViewGroup.LayoutParams params = view.getLayoutParams(); 1326 setSize(view, width, params.height); 1327 } 1328 setHeight(View view, int height)1329 private static void setHeight(View view, int height) { 1330 ViewGroup.LayoutParams params = view.getLayoutParams(); 1331 setSize(view, params.width, height); 1332 } 1333 1334 /** 1335 * A custom ListView for the overflow panel. 1336 */ 1337 private static final class OverflowPanel extends ListView { 1338 1339 private final LocalFloatingToolbarPopup mPopup; 1340 OverflowPanel(LocalFloatingToolbarPopup popup)1341 OverflowPanel(LocalFloatingToolbarPopup popup) { 1342 super(Objects.requireNonNull(popup).mContext); 1343 this.mPopup = popup; 1344 setScrollBarDefaultDelayBeforeFade(ViewConfiguration.getScrollDefaultDelay() * 3); 1345 setScrollIndicators(View.SCROLL_INDICATOR_TOP | View.SCROLL_INDICATOR_BOTTOM); 1346 } 1347 1348 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)1349 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 1350 // Update heightMeasureSpec to make sure that this view is not clipped 1351 // as we offset it's coordinates with respect to its parent. 1352 int height = mPopup.mOverflowPanelSize.getHeight() 1353 - mPopup.mOverflowButtonSize.getHeight(); 1354 heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY); 1355 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 1356 } 1357 1358 @Override dispatchTouchEvent(MotionEvent ev)1359 public boolean dispatchTouchEvent(MotionEvent ev) { 1360 if (mPopup.isOverflowAnimating()) { 1361 // Eat the touch event. 1362 return true; 1363 } 1364 return super.dispatchTouchEvent(ev); 1365 } 1366 1367 @Override awakenScrollBars()1368 protected boolean awakenScrollBars() { 1369 return super.awakenScrollBars(); 1370 } 1371 } 1372 1373 /** 1374 * A custom interpolator used for various floating toolbar animations. 1375 */ 1376 private static final class LogAccelerateInterpolator implements Interpolator { 1377 1378 private static final int BASE = 100; 1379 private static final float LOGS_SCALE = 1f / computeLog(1, BASE); 1380 computeLog(float t, int base)1381 private static float computeLog(float t, int base) { 1382 return (float) (1 - Math.pow(base, -t)); 1383 } 1384 1385 @Override getInterpolation(float t)1386 public float getInterpolation(float t) { 1387 return 1 - computeLog(1 - t, BASE) * LOGS_SCALE; 1388 } 1389 } 1390 1391 /** 1392 * A helper for generating views for the overflow panel. 1393 */ 1394 private static final class OverflowPanelViewHelper { 1395 1396 private final View mCalculator; 1397 private final int mIconTextSpacing; 1398 private final int mSidePadding; 1399 1400 private final Context mContext; 1401 OverflowPanelViewHelper(Context context, int iconTextSpacing)1402 OverflowPanelViewHelper(Context context, int iconTextSpacing) { 1403 mContext = Objects.requireNonNull(context); 1404 mIconTextSpacing = iconTextSpacing; 1405 mSidePadding = context.getResources() 1406 .getDimensionPixelSize(R.dimen.floating_toolbar_overflow_side_padding); 1407 mCalculator = createMenuButton(null); 1408 } 1409 getView(MenuItem menuItem, int minimumWidth, View convertView)1410 public View getView(MenuItem menuItem, int minimumWidth, View convertView) { 1411 Objects.requireNonNull(menuItem); 1412 if (convertView != null) { 1413 updateMenuItemButton( 1414 convertView, menuItem, mIconTextSpacing, shouldShowIcon(menuItem)); 1415 } else { 1416 convertView = createMenuButton(menuItem); 1417 } 1418 convertView.setMinimumWidth(minimumWidth); 1419 return convertView; 1420 } 1421 calculateWidth(MenuItem menuItem)1422 public int calculateWidth(MenuItem menuItem) { 1423 updateMenuItemButton( 1424 mCalculator, menuItem, mIconTextSpacing, shouldShowIcon(menuItem)); 1425 mCalculator.measure( 1426 View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); 1427 return mCalculator.getMeasuredWidth(); 1428 } 1429 createMenuButton(MenuItem menuItem)1430 private View createMenuButton(MenuItem menuItem) { 1431 View button = createMenuItemButton( 1432 mContext, menuItem, mIconTextSpacing, shouldShowIcon(menuItem)); 1433 button.setPadding(mSidePadding, 0, mSidePadding, 0); 1434 return button; 1435 } 1436 shouldShowIcon(MenuItem menuItem)1437 private boolean shouldShowIcon(MenuItem menuItem) { 1438 if (menuItem != null) { 1439 return menuItem.getGroupId() == android.R.id.textAssist; 1440 } 1441 return false; 1442 } 1443 } 1444 1445 /** 1446 * Creates and returns a menu button for the specified menu item. 1447 */ createMenuItemButton( Context context, MenuItem menuItem, int iconTextSpacing, boolean showIcon)1448 private static View createMenuItemButton( 1449 Context context, MenuItem menuItem, int iconTextSpacing, boolean showIcon) { 1450 final View menuItemButton = LayoutInflater.from(context) 1451 .inflate(R.layout.floating_popup_menu_button, null); 1452 if (menuItem != null) { 1453 updateMenuItemButton(menuItemButton, menuItem, iconTextSpacing, showIcon); 1454 } 1455 return menuItemButton; 1456 } 1457 1458 /** 1459 * Updates the specified menu item button with the specified menu item data. 1460 */ updateMenuItemButton( View menuItemButton, MenuItem menuItem, int iconTextSpacing, boolean showIcon)1461 private static void updateMenuItemButton( 1462 View menuItemButton, MenuItem menuItem, int iconTextSpacing, boolean showIcon) { 1463 final TextView buttonText = menuItemButton.findViewById( 1464 R.id.floating_toolbar_menu_item_text); 1465 buttonText.setEllipsize(null); 1466 if (TextUtils.isEmpty(menuItem.getTitle())) { 1467 buttonText.setVisibility(View.GONE); 1468 } else { 1469 buttonText.setVisibility(View.VISIBLE); 1470 buttonText.setText(menuItem.getTitle()); 1471 } 1472 final ImageView buttonIcon = menuItemButton.findViewById( 1473 R.id.floating_toolbar_menu_item_image); 1474 if (menuItem.getIcon() == null || !showIcon) { 1475 buttonIcon.setVisibility(View.GONE); 1476 if (buttonText != null) { 1477 buttonText.setPaddingRelative(0, 0, 0, 0); 1478 } 1479 } else { 1480 buttonIcon.setVisibility(View.VISIBLE); 1481 buttonIcon.setImageDrawable(menuItem.getIcon()); 1482 if (buttonText != null) { 1483 buttonText.setPaddingRelative(iconTextSpacing, 0, 0, 0); 1484 } 1485 } 1486 final CharSequence contentDescription = menuItem.getContentDescription(); 1487 if (TextUtils.isEmpty(contentDescription)) { 1488 menuItemButton.setContentDescription(menuItem.getTitle()); 1489 } else { 1490 menuItemButton.setContentDescription(contentDescription); 1491 } 1492 } 1493 createContentContainer(Context context)1494 private static ViewGroup createContentContainer(Context context) { 1495 ViewGroup contentContainer = (ViewGroup) LayoutInflater.from(context) 1496 .inflate(R.layout.floating_popup_container, null); 1497 contentContainer.setLayoutParams(new ViewGroup.LayoutParams( 1498 ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); 1499 contentContainer.setTag(FloatingToolbar.FLOATING_TOOLBAR_TAG); 1500 contentContainer.setClipToOutline(true); 1501 return contentContainer; 1502 } 1503 createPopupWindow(ViewGroup content)1504 private static PopupWindow createPopupWindow(ViewGroup content) { 1505 ViewGroup popupContentHolder = new LinearLayout(content.getContext()); 1506 PopupWindow popupWindow = new PopupWindow(popupContentHolder); 1507 // TODO: Use .setIsLaidOutInScreen(true) instead of .setClippingEnabled(false) 1508 // unless FLAG_LAYOUT_IN_SCREEN has any unintentional side-effects. 1509 popupWindow.setClippingEnabled(false); 1510 popupWindow.setWindowLayoutType( 1511 WindowManager.LayoutParams.TYPE_APPLICATION_ABOVE_SUB_PANEL); 1512 popupWindow.setAnimationStyle(0); 1513 popupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); 1514 content.setLayoutParams(new ViewGroup.LayoutParams( 1515 ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); 1516 popupContentHolder.addView(content); 1517 return popupWindow; 1518 } 1519 1520 /** 1521 * Creates an "appear" animation for the specified view. 1522 * 1523 * @param view The view to animate 1524 */ createEnterAnimation(View view)1525 private static AnimatorSet createEnterAnimation(View view) { 1526 AnimatorSet animation = new AnimatorSet(); 1527 animation.playTogether( 1528 ObjectAnimator.ofFloat(view, View.ALPHA, 0, 1).setDuration(150)); 1529 return animation; 1530 } 1531 1532 /** 1533 * Creates a "disappear" animation for the specified view. 1534 * 1535 * @param view The view to animate 1536 * @param startDelay The start delay of the animation 1537 * @param listener The animation listener 1538 */ createExitAnimation( View view, int startDelay, Animator.AnimatorListener listener)1539 private static AnimatorSet createExitAnimation( 1540 View view, int startDelay, Animator.AnimatorListener listener) { 1541 AnimatorSet animation = new AnimatorSet(); 1542 animation.playTogether( 1543 ObjectAnimator.ofFloat(view, View.ALPHA, 1, 0).setDuration(100)); 1544 animation.setStartDelay(startDelay); 1545 animation.addListener(listener); 1546 return animation; 1547 } 1548 1549 /** 1550 * Returns a re-themed context with controlled look and feel for views. 1551 */ applyDefaultTheme(Context originalContext)1552 private static Context applyDefaultTheme(Context originalContext) { 1553 TypedArray a = originalContext.obtainStyledAttributes(new int[]{R.attr.isLightTheme}); 1554 boolean isLightTheme = a.getBoolean(0, true); 1555 int themeId = 1556 isLightTheme ? R.style.Theme_DeviceDefault_Light : R.style.Theme_DeviceDefault; 1557 a.recycle(); 1558 return new ContextThemeWrapper(originalContext, themeId); 1559 } 1560 1561 /** 1562 * Represents the identity of a MenuItem that is rendered in a FloatingToolbarPopup. 1563 */ 1564 @VisibleForTesting 1565 public static final class MenuItemRepr { 1566 1567 public final int itemId; 1568 public final int groupId; 1569 @Nullable public final String title; 1570 @Nullable private final Drawable mIcon; 1571 MenuItemRepr( int itemId, int groupId, @Nullable CharSequence title, @Nullable Drawable icon)1572 private MenuItemRepr( 1573 int itemId, int groupId, @Nullable CharSequence title, @Nullable Drawable icon) { 1574 this.itemId = itemId; 1575 this.groupId = groupId; 1576 this.title = (title == null) ? null : title.toString(); 1577 mIcon = icon; 1578 } 1579 1580 /** 1581 * Creates an instance of MenuItemRepr for the specified menu item. 1582 */ of(MenuItem menuItem)1583 public static MenuItemRepr of(MenuItem menuItem) { 1584 return new MenuItemRepr( 1585 menuItem.getItemId(), 1586 menuItem.getGroupId(), 1587 menuItem.getTitle(), 1588 menuItem.getIcon()); 1589 } 1590 1591 /** 1592 * Returns this object's hashcode. 1593 */ 1594 @Override hashCode()1595 public int hashCode() { 1596 return Objects.hash(itemId, groupId, title, mIcon); 1597 } 1598 1599 /** 1600 * Returns true if this object is the same as the specified object. 1601 */ 1602 @Override equals(Object o)1603 public boolean equals(Object o) { 1604 if (o == this) { 1605 return true; 1606 } 1607 if (!(o instanceof MenuItemRepr)) { 1608 return false; 1609 } 1610 final MenuItemRepr other = (MenuItemRepr) o; 1611 return itemId == other.itemId 1612 && groupId == other.groupId 1613 && TextUtils.equals(title, other.title) 1614 // Many Drawables (icons) do not implement equals(). Using equals() here instead 1615 // of reference comparisons in case a Drawable subclass implements equals(). 1616 && Objects.equals(mIcon, other.mIcon); 1617 } 1618 1619 /** 1620 * Returns true if the two menu item collections are the same based on MenuItemRepr. 1621 */ reprEquals( Collection<MenuItem> menuItems1, Collection<MenuItem> menuItems2)1622 public static boolean reprEquals( 1623 Collection<MenuItem> menuItems1, Collection<MenuItem> menuItems2) { 1624 if (menuItems1.size() != menuItems2.size()) { 1625 return false; 1626 } 1627 1628 final Iterator<MenuItem> menuItems2Iter = menuItems2.iterator(); 1629 for (MenuItem menuItem1 : menuItems1) { 1630 final MenuItem menuItem2 = menuItems2Iter.next(); 1631 if (!MenuItemRepr.of(menuItem1).equals(MenuItemRepr.of(menuItem2))) { 1632 return false; 1633 } 1634 } 1635 1636 return true; 1637 } 1638 } 1639 } 1640