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 // Initialize x ensuring that the toolbar isn't rendered behind the nav bar in 439 // landscape. 440 final int x = Math.min( 441 contentRectOnScreen.centerX() - mPopupWindow.getWidth() / 2, 442 mViewPortOnScreen.right - mPopupWindow.getWidth()); 443 444 final int y; 445 446 final int availableHeightAboveContent = 447 contentRectOnScreen.top - mViewPortOnScreen.top; 448 final int availableHeightBelowContent = 449 mViewPortOnScreen.bottom - contentRectOnScreen.bottom; 450 451 final int margin = 2 * mMarginVertical; 452 final int toolbarHeightWithVerticalMargin = mLineHeight + margin; 453 454 if (!hasOverflow()) { 455 if (availableHeightAboveContent >= toolbarHeightWithVerticalMargin) { 456 // There is enough space at the top of the content. 457 y = contentRectOnScreen.top - toolbarHeightWithVerticalMargin; 458 } else if (availableHeightBelowContent >= toolbarHeightWithVerticalMargin) { 459 // There is enough space at the bottom of the content. 460 y = contentRectOnScreen.bottom; 461 } else if (availableHeightBelowContent >= mLineHeight) { 462 // Just enough space to fit the toolbar with no vertical margins. 463 y = contentRectOnScreen.bottom - mMarginVertical; 464 } else { 465 // Not enough space. Prefer to position as high as possible. 466 y = Math.max( 467 mViewPortOnScreen.top, 468 contentRectOnScreen.top - toolbarHeightWithVerticalMargin); 469 } 470 } else { 471 // Has an overflow. 472 final int minimumOverflowHeightWithMargin = 473 calculateOverflowHeight(MIN_OVERFLOW_SIZE) + margin; 474 final int availableHeightThroughContentDown = 475 mViewPortOnScreen.bottom - contentRectOnScreen.top 476 + toolbarHeightWithVerticalMargin; 477 final int availableHeightThroughContentUp = 478 contentRectOnScreen.bottom - mViewPortOnScreen.top 479 + toolbarHeightWithVerticalMargin; 480 481 if (availableHeightAboveContent >= minimumOverflowHeightWithMargin) { 482 // There is enough space at the top of the content rect for the overflow. 483 // Position above and open upwards. 484 updateOverflowHeight(availableHeightAboveContent - margin); 485 y = contentRectOnScreen.top - mPopupWindow.getHeight(); 486 mOpenOverflowUpwards = true; 487 } else if (availableHeightAboveContent >= toolbarHeightWithVerticalMargin 488 && availableHeightThroughContentDown >= minimumOverflowHeightWithMargin) { 489 // There is enough space at the top of the content rect for the main panel 490 // but not the overflow. 491 // Position above but open downwards. 492 updateOverflowHeight(availableHeightThroughContentDown - margin); 493 y = contentRectOnScreen.top - toolbarHeightWithVerticalMargin; 494 mOpenOverflowUpwards = false; 495 } else if (availableHeightBelowContent >= minimumOverflowHeightWithMargin) { 496 // There is enough space at the bottom of the content rect for the overflow. 497 // Position below and open downwards. 498 updateOverflowHeight(availableHeightBelowContent - margin); 499 y = contentRectOnScreen.bottom; 500 mOpenOverflowUpwards = false; 501 } else if (availableHeightBelowContent >= toolbarHeightWithVerticalMargin 502 && mViewPortOnScreen.height() >= minimumOverflowHeightWithMargin) { 503 // There is enough space at the bottom of the content rect for the main panel 504 // but not the overflow. 505 // Position below but open upwards. 506 updateOverflowHeight(availableHeightThroughContentUp - margin); 507 y = contentRectOnScreen.bottom + toolbarHeightWithVerticalMargin 508 - mPopupWindow.getHeight(); 509 mOpenOverflowUpwards = true; 510 } else { 511 // Not enough space. 512 // Position at the top of the view port and open downwards. 513 updateOverflowHeight(mViewPortOnScreen.height() - margin); 514 y = mViewPortOnScreen.top; 515 mOpenOverflowUpwards = false; 516 } 517 } 518 519 // We later specify the location of PopupWindow relative to the attached window. 520 // The idea here is that 1) we can get the location of a View in both window coordinates 521 // and screen coordinates, where the offset between them should be equal to the window 522 // origin, and 2) we can use an arbitrary for this calculation while calculating the 523 // location of the rootview is supposed to be least expensive. 524 // TODO: Consider to use PopupWindow.setIsLaidOutInScreen(true) so that we can avoid 525 // the following calculation. 526 mParent.getRootView().getLocationOnScreen(mTmpCoords); 527 int rootViewLeftOnScreen = mTmpCoords[0]; 528 int rootViewTopOnScreen = mTmpCoords[1]; 529 mParent.getRootView().getLocationInWindow(mTmpCoords); 530 int rootViewLeftOnWindow = mTmpCoords[0]; 531 int rootViewTopOnWindow = mTmpCoords[1]; 532 int windowLeftOnScreen = rootViewLeftOnScreen - rootViewLeftOnWindow; 533 int windowTopOnScreen = rootViewTopOnScreen - rootViewTopOnWindow; 534 mCoordsOnWindow.set( 535 Math.max(0, x - windowLeftOnScreen), Math.max(0, y - windowTopOnScreen)); 536 } 537 538 /** 539 * Performs the "show" animation on the floating popup. 540 */ runShowAnimation()541 private void runShowAnimation() { 542 mShowAnimation.start(); 543 } 544 545 /** 546 * Performs the "dismiss" animation on the floating popup. 547 */ runDismissAnimation()548 private void runDismissAnimation() { 549 mDismissAnimation.start(); 550 } 551 552 /** 553 * Performs the "hide" animation on the floating popup. 554 */ runHideAnimation()555 private void runHideAnimation() { 556 mHideAnimation.start(); 557 } 558 cancelDismissAndHideAnimations()559 private void cancelDismissAndHideAnimations() { 560 mDismissAnimation.cancel(); 561 mHideAnimation.cancel(); 562 } 563 cancelOverflowAnimations()564 private void cancelOverflowAnimations() { 565 mContentContainer.clearAnimation(); 566 mMainPanel.animate().cancel(); 567 mOverflowPanel.animate().cancel(); 568 mToArrow.stop(); 569 mToOverflow.stop(); 570 } 571 openOverflow()572 private void openOverflow() { 573 final int targetWidth = mOverflowPanelSize.getWidth(); 574 final int targetHeight = mOverflowPanelSize.getHeight(); 575 final int startWidth = mContentContainer.getWidth(); 576 final int startHeight = mContentContainer.getHeight(); 577 final float startY = mContentContainer.getY(); 578 final float left = mContentContainer.getX(); 579 final float right = left + mContentContainer.getWidth(); 580 Animation widthAnimation = new Animation() { 581 @Override 582 protected void applyTransformation(float interpolatedTime, Transformation t) { 583 int deltaWidth = (int) (interpolatedTime * (targetWidth - startWidth)); 584 setWidth(mContentContainer, startWidth + deltaWidth); 585 if (isInRTLMode()) { 586 mContentContainer.setX(left); 587 588 // Lock the panels in place. 589 mMainPanel.setX(0); 590 mOverflowPanel.setX(0); 591 } else { 592 mContentContainer.setX(right - mContentContainer.getWidth()); 593 594 // Offset the panels' positions so they look like they're locked in place 595 // on the screen. 596 mMainPanel.setX(mContentContainer.getWidth() - startWidth); 597 mOverflowPanel.setX(mContentContainer.getWidth() - targetWidth); 598 } 599 } 600 }; 601 Animation heightAnimation = new Animation() { 602 @Override 603 protected void applyTransformation(float interpolatedTime, Transformation t) { 604 int deltaHeight = (int) (interpolatedTime * (targetHeight - startHeight)); 605 setHeight(mContentContainer, startHeight + deltaHeight); 606 if (mOpenOverflowUpwards) { 607 mContentContainer.setY( 608 startY - (mContentContainer.getHeight() - startHeight)); 609 positionContentYCoordinatesIfOpeningOverflowUpwards(); 610 } 611 } 612 }; 613 final float overflowButtonStartX = mOverflowButton.getX(); 614 final float overflowButtonTargetX = 615 isInRTLMode() ? overflowButtonStartX + targetWidth - mOverflowButton.getWidth() 616 : overflowButtonStartX - targetWidth + mOverflowButton.getWidth(); 617 Animation overflowButtonAnimation = new Animation() { 618 @Override 619 protected void applyTransformation(float interpolatedTime, Transformation t) { 620 float overflowButtonX = overflowButtonStartX 621 + interpolatedTime * (overflowButtonTargetX - overflowButtonStartX); 622 float deltaContainerWidth = 623 isInRTLMode() ? 0 : mContentContainer.getWidth() - startWidth; 624 float actualOverflowButtonX = overflowButtonX + deltaContainerWidth; 625 mOverflowButton.setX(actualOverflowButtonX); 626 } 627 }; 628 widthAnimation.setInterpolator(mLogAccelerateInterpolator); 629 widthAnimation.setDuration(getAdjustedDuration(250)); 630 heightAnimation.setInterpolator(mFastOutSlowInInterpolator); 631 heightAnimation.setDuration(getAdjustedDuration(250)); 632 overflowButtonAnimation.setInterpolator(mFastOutSlowInInterpolator); 633 overflowButtonAnimation.setDuration(getAdjustedDuration(250)); 634 mOpenOverflowAnimation.getAnimations().clear(); 635 mOpenOverflowAnimation.getAnimations().clear(); 636 mOpenOverflowAnimation.addAnimation(widthAnimation); 637 mOpenOverflowAnimation.addAnimation(heightAnimation); 638 mOpenOverflowAnimation.addAnimation(overflowButtonAnimation); 639 mContentContainer.startAnimation(mOpenOverflowAnimation); 640 mIsOverflowOpen = true; 641 mMainPanel.animate() 642 .alpha(0).withLayer() 643 .setInterpolator(mLinearOutSlowInInterpolator) 644 .setDuration(250) 645 .start(); 646 mOverflowPanel.setAlpha(1); // fadeIn in 0ms. 647 } 648 closeOverflow()649 private void closeOverflow() { 650 final int targetWidth = mMainPanelSize.getWidth(); 651 final int startWidth = mContentContainer.getWidth(); 652 final float left = mContentContainer.getX(); 653 final float right = left + mContentContainer.getWidth(); 654 Animation widthAnimation = new Animation() { 655 @Override 656 protected void applyTransformation(float interpolatedTime, Transformation t) { 657 int deltaWidth = (int) (interpolatedTime * (targetWidth - startWidth)); 658 setWidth(mContentContainer, startWidth + deltaWidth); 659 if (isInRTLMode()) { 660 mContentContainer.setX(left); 661 662 // Lock the panels in place. 663 mMainPanel.setX(0); 664 mOverflowPanel.setX(0); 665 } else { 666 mContentContainer.setX(right - mContentContainer.getWidth()); 667 668 // Offset the panels' positions so they look like they're locked in place 669 // on the screen. 670 mMainPanel.setX(mContentContainer.getWidth() - targetWidth); 671 mOverflowPanel.setX(mContentContainer.getWidth() - startWidth); 672 } 673 } 674 }; 675 final int targetHeight = mMainPanelSize.getHeight(); 676 final int startHeight = mContentContainer.getHeight(); 677 final float bottom = mContentContainer.getY() + mContentContainer.getHeight(); 678 Animation heightAnimation = new Animation() { 679 @Override 680 protected void applyTransformation(float interpolatedTime, Transformation t) { 681 int deltaHeight = (int) (interpolatedTime * (targetHeight - startHeight)); 682 setHeight(mContentContainer, startHeight + deltaHeight); 683 if (mOpenOverflowUpwards) { 684 mContentContainer.setY(bottom - mContentContainer.getHeight()); 685 positionContentYCoordinatesIfOpeningOverflowUpwards(); 686 } 687 } 688 }; 689 final float overflowButtonStartX = mOverflowButton.getX(); 690 final float overflowButtonTargetX = 691 isInRTLMode() ? overflowButtonStartX - startWidth + mOverflowButton.getWidth() 692 : overflowButtonStartX + startWidth - mOverflowButton.getWidth(); 693 Animation overflowButtonAnimation = new Animation() { 694 @Override 695 protected void applyTransformation(float interpolatedTime, Transformation t) { 696 float overflowButtonX = overflowButtonStartX 697 + interpolatedTime * (overflowButtonTargetX - overflowButtonStartX); 698 float deltaContainerWidth = 699 isInRTLMode() ? 0 : mContentContainer.getWidth() - startWidth; 700 float actualOverflowButtonX = overflowButtonX + deltaContainerWidth; 701 mOverflowButton.setX(actualOverflowButtonX); 702 } 703 }; 704 widthAnimation.setInterpolator(mFastOutSlowInInterpolator); 705 widthAnimation.setDuration(getAdjustedDuration(250)); 706 heightAnimation.setInterpolator(mLogAccelerateInterpolator); 707 heightAnimation.setDuration(getAdjustedDuration(250)); 708 overflowButtonAnimation.setInterpolator(mFastOutSlowInInterpolator); 709 overflowButtonAnimation.setDuration(getAdjustedDuration(250)); 710 mCloseOverflowAnimation.getAnimations().clear(); 711 mCloseOverflowAnimation.addAnimation(widthAnimation); 712 mCloseOverflowAnimation.addAnimation(heightAnimation); 713 mCloseOverflowAnimation.addAnimation(overflowButtonAnimation); 714 mContentContainer.startAnimation(mCloseOverflowAnimation); 715 mIsOverflowOpen = false; 716 mMainPanel.animate() 717 .alpha(1).withLayer() 718 .setInterpolator(mFastOutLinearInInterpolator) 719 .setDuration(100) 720 .start(); 721 mOverflowPanel.animate() 722 .alpha(0).withLayer() 723 .setInterpolator(mLinearOutSlowInInterpolator) 724 .setDuration(150) 725 .start(); 726 } 727 728 /** 729 * Defines the position of the floating toolbar popup panels when transition animation has 730 * stopped. 731 */ setPanelsStatesAtRestingPosition()732 private void setPanelsStatesAtRestingPosition() { 733 mOverflowButton.setEnabled(true); 734 mOverflowPanel.awakenScrollBars(); 735 736 if (mIsOverflowOpen) { 737 // Set open state. 738 final Size containerSize = mOverflowPanelSize; 739 setSize(mContentContainer, containerSize); 740 mMainPanel.setAlpha(0); 741 mMainPanel.setVisibility(View.INVISIBLE); 742 mOverflowPanel.setAlpha(1); 743 mOverflowPanel.setVisibility(View.VISIBLE); 744 mOverflowButton.setImageDrawable(mArrow); 745 mOverflowButton.setContentDescription(mContext.getString( 746 R.string.floating_toolbar_close_overflow_description)); 747 748 // Update x-coordinates depending on RTL state. 749 if (isInRTLMode()) { 750 mContentContainer.setX(mMarginHorizontal); // align left 751 mMainPanel.setX(0); // align left 752 mOverflowButton.setX(// align right 753 containerSize.getWidth() - mOverflowButtonSize.getWidth()); 754 mOverflowPanel.setX(0); // align left 755 } else { 756 mContentContainer.setX(// align right 757 mPopupWindow.getWidth() - containerSize.getWidth() - mMarginHorizontal); 758 mMainPanel.setX(-mContentContainer.getX()); // align right 759 mOverflowButton.setX(0); // align left 760 mOverflowPanel.setX(0); // align left 761 } 762 763 // Update y-coordinates depending on overflow's open direction. 764 if (mOpenOverflowUpwards) { 765 mContentContainer.setY(mMarginVertical); // align top 766 mMainPanel.setY(// align bottom 767 containerSize.getHeight() - mContentContainer.getHeight()); 768 mOverflowButton.setY(// align bottom 769 containerSize.getHeight() - mOverflowButtonSize.getHeight()); 770 mOverflowPanel.setY(0); // align top 771 } else { 772 // opens downwards. 773 mContentContainer.setY(mMarginVertical); // align top 774 mMainPanel.setY(0); // align top 775 mOverflowButton.setY(0); // align top 776 mOverflowPanel.setY(mOverflowButtonSize.getHeight()); // align bottom 777 } 778 } else { 779 // Overflow not open. Set closed state. 780 final Size containerSize = mMainPanelSize; 781 setSize(mContentContainer, containerSize); 782 mMainPanel.setAlpha(1); 783 mMainPanel.setVisibility(View.VISIBLE); 784 mOverflowPanel.setAlpha(0); 785 mOverflowPanel.setVisibility(View.INVISIBLE); 786 mOverflowButton.setImageDrawable(mOverflow); 787 mOverflowButton.setContentDescription(mContext.getString( 788 R.string.floating_toolbar_open_overflow_description)); 789 790 if (hasOverflow()) { 791 // Update x-coordinates depending on RTL state. 792 if (isInRTLMode()) { 793 mContentContainer.setX(mMarginHorizontal); // align left 794 mMainPanel.setX(0); // align left 795 mOverflowButton.setX(0); // align left 796 mOverflowPanel.setX(0); // align left 797 } else { 798 mContentContainer.setX(// align right 799 mPopupWindow.getWidth() - containerSize.getWidth() - mMarginHorizontal); 800 mMainPanel.setX(0); // align left 801 mOverflowButton.setX(// align right 802 containerSize.getWidth() - mOverflowButtonSize.getWidth()); 803 mOverflowPanel.setX(// align right 804 containerSize.getWidth() - mOverflowPanelSize.getWidth()); 805 } 806 807 // Update y-coordinates depending on overflow's open direction. 808 if (mOpenOverflowUpwards) { 809 mContentContainer.setY(// align bottom 810 mMarginVertical + mOverflowPanelSize.getHeight() 811 - containerSize.getHeight()); 812 mMainPanel.setY(0); // align top 813 mOverflowButton.setY(0); // align top 814 mOverflowPanel.setY(// align bottom 815 containerSize.getHeight() - mOverflowPanelSize.getHeight()); 816 } else { 817 // opens downwards. 818 mContentContainer.setY(mMarginVertical); // align top 819 mMainPanel.setY(0); // align top 820 mOverflowButton.setY(0); // align top 821 mOverflowPanel.setY(mOverflowButtonSize.getHeight()); // align bottom 822 } 823 } else { 824 // No overflow. 825 mContentContainer.setX(mMarginHorizontal); // align left 826 mContentContainer.setY(mMarginVertical); // align top 827 mMainPanel.setX(0); // align left 828 mMainPanel.setY(0); // align top 829 } 830 } 831 } 832 updateOverflowHeight(int suggestedHeight)833 private void updateOverflowHeight(int suggestedHeight) { 834 if (hasOverflow()) { 835 final int maxItemSize = 836 (suggestedHeight - mOverflowButtonSize.getHeight()) / mLineHeight; 837 final int newHeight = calculateOverflowHeight(maxItemSize); 838 if (mOverflowPanelSize.getHeight() != newHeight) { 839 mOverflowPanelSize = new Size(mOverflowPanelSize.getWidth(), newHeight); 840 } 841 setSize(mOverflowPanel, mOverflowPanelSize); 842 if (mIsOverflowOpen) { 843 setSize(mContentContainer, mOverflowPanelSize); 844 if (mOpenOverflowUpwards) { 845 final int deltaHeight = mOverflowPanelSize.getHeight() - newHeight; 846 mContentContainer.setY(mContentContainer.getY() + deltaHeight); 847 mOverflowButton.setY(mOverflowButton.getY() - deltaHeight); 848 } 849 } else { 850 setSize(mContentContainer, mMainPanelSize); 851 } 852 updatePopupSize(); 853 } 854 } 855 updatePopupSize()856 private void updatePopupSize() { 857 int width = 0; 858 int height = 0; 859 if (mMainPanelSize != null) { 860 width = Math.max(width, mMainPanelSize.getWidth()); 861 height = Math.max(height, mMainPanelSize.getHeight()); 862 } 863 if (mOverflowPanelSize != null) { 864 width = Math.max(width, mOverflowPanelSize.getWidth()); 865 height = Math.max(height, mOverflowPanelSize.getHeight()); 866 } 867 mPopupWindow.setWidth(width + mMarginHorizontal * 2); 868 mPopupWindow.setHeight(height + mMarginVertical * 2); 869 maybeComputeTransitionDurationScale(); 870 } 871 refreshViewPort()872 private void refreshViewPort() { 873 mParent.getWindowVisibleDisplayFrame(mViewPortOnScreen); 874 } 875 getAdjustedToolbarWidth(int suggestedWidth)876 private int getAdjustedToolbarWidth(int suggestedWidth) { 877 int width = suggestedWidth; 878 refreshViewPort(); 879 int maximumWidth = mViewPortOnScreen.width() - 2 * mParent.getResources() 880 .getDimensionPixelSize(R.dimen.floating_toolbar_horizontal_margin); 881 if (width <= 0) { 882 width = mParent.getResources() 883 .getDimensionPixelSize(R.dimen.floating_toolbar_preferred_width); 884 } 885 return Math.min(width, maximumWidth); 886 } 887 888 /** 889 * Sets the touchable region of this popup to be zero. This means that all touch events on 890 * this popup will go through to the surface behind it. 891 */ setZeroTouchableSurface()892 private void setZeroTouchableSurface() { 893 mTouchableRegion.setEmpty(); 894 } 895 896 /** 897 * Sets the touchable region of this popup to be the area occupied by its content. 898 */ setContentAreaAsTouchableSurface()899 private void setContentAreaAsTouchableSurface() { 900 Objects.requireNonNull(mMainPanelSize); 901 final int width; 902 final int height; 903 if (mIsOverflowOpen) { 904 Objects.requireNonNull(mOverflowPanelSize); 905 width = mOverflowPanelSize.getWidth(); 906 height = mOverflowPanelSize.getHeight(); 907 } else { 908 width = mMainPanelSize.getWidth(); 909 height = mMainPanelSize.getHeight(); 910 } 911 mTouchableRegion.set( 912 (int) mContentContainer.getX(), 913 (int) mContentContainer.getY(), 914 (int) mContentContainer.getX() + width, 915 (int) mContentContainer.getY() + height); 916 } 917 918 /** 919 * Make the touchable area of this popup be the area specified by mTouchableRegion. 920 * This should be called after the popup window has been dismissed (dismiss/hide) 921 * and is probably being re-shown with a new content root view. 922 */ setTouchableSurfaceInsetsComputer()923 private void setTouchableSurfaceInsetsComputer() { 924 ViewTreeObserver viewTreeObserver = mPopupWindow.getContentView() 925 .getRootView() 926 .getViewTreeObserver(); 927 viewTreeObserver.removeOnComputeInternalInsetsListener(mInsetsComputer); 928 viewTreeObserver.addOnComputeInternalInsetsListener(mInsetsComputer); 929 } 930 isInRTLMode()931 private boolean isInRTLMode() { 932 return mContext.getApplicationInfo().hasRtlSupport() 933 && mContext.getResources().getConfiguration().getLayoutDirection() 934 == View.LAYOUT_DIRECTION_RTL; 935 } 936 hasOverflow()937 private boolean hasOverflow() { 938 return mOverflowPanelSize != null; 939 } 940 941 /** 942 * Fits as many menu items in the main panel and returns a list of the menu items that 943 * were not fit in. 944 * 945 * @return The menu items that are not included in this main panel. 946 */ layoutMainPanelItems( List<MenuItem> menuItems, final int toolbarWidth)947 public List<MenuItem> layoutMainPanelItems( 948 List<MenuItem> menuItems, final int toolbarWidth) { 949 Objects.requireNonNull(menuItems); 950 951 int availableWidth = toolbarWidth; 952 953 final ArrayList<MenuItem> remainingMenuItems = new ArrayList<>(); 954 // add the overflow menu items to the end of the remainingMenuItems list. 955 final ArrayList<MenuItem> overflowMenuItems = new ArrayList<>(); 956 for (MenuItem menuItem : menuItems) { 957 if (menuItem.getItemId() != android.R.id.textAssist 958 && menuItem.requiresOverflow()) { 959 overflowMenuItems.add(menuItem); 960 } else { 961 remainingMenuItems.add(menuItem); 962 } 963 } 964 remainingMenuItems.addAll(overflowMenuItems); 965 966 mMainPanel.removeAllViews(); 967 mMainPanel.setPaddingRelative(0, 0, 0, 0); 968 969 int lastGroupId = -1; 970 boolean isFirstItem = true; 971 while (!remainingMenuItems.isEmpty()) { 972 final MenuItem menuItem = remainingMenuItems.get(0); 973 974 // if this is the first item, regardless of requiresOverflow(), it should be 975 // displayed on the main panel. Otherwise all items including this one will be 976 // overflow items, and should be displayed in overflow panel. 977 if (!isFirstItem && menuItem.requiresOverflow()) { 978 break; 979 } 980 981 final boolean showIcon = isFirstItem && menuItem.getItemId() == R.id.textAssist; 982 final View menuItemButton = createMenuItemButton( 983 mContext, menuItem, mIconTextSpacing, showIcon); 984 if (!showIcon && menuItemButton instanceof LinearLayout) { 985 ((LinearLayout) menuItemButton).setGravity(Gravity.CENTER); 986 } 987 988 // Adding additional start padding for the first button to even out button spacing. 989 if (isFirstItem) { 990 menuItemButton.setPaddingRelative( 991 (int) (1.5 * menuItemButton.getPaddingStart()), 992 menuItemButton.getPaddingTop(), 993 menuItemButton.getPaddingEnd(), 994 menuItemButton.getPaddingBottom()); 995 } 996 997 // Adding additional end padding for the last button to even out button spacing. 998 boolean isLastItem = remainingMenuItems.size() == 1; 999 if (isLastItem) { 1000 menuItemButton.setPaddingRelative( 1001 menuItemButton.getPaddingStart(), 1002 menuItemButton.getPaddingTop(), 1003 (int) (1.5 * menuItemButton.getPaddingEnd()), 1004 menuItemButton.getPaddingBottom()); 1005 } 1006 1007 menuItemButton.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); 1008 final int menuItemButtonWidth = Math.min( 1009 menuItemButton.getMeasuredWidth(), toolbarWidth); 1010 1011 // Check if we can fit an item while reserving space for the overflowButton. 1012 final boolean canFitWithOverflow = 1013 menuItemButtonWidth <= availableWidth - mOverflowButtonSize.getWidth(); 1014 final boolean canFitNoOverflow = 1015 isLastItem && menuItemButtonWidth <= availableWidth; 1016 if (canFitWithOverflow || canFitNoOverflow) { 1017 setButtonTagAndClickListener(menuItemButton, menuItem); 1018 // Set tooltips for main panel items, but not overflow items (b/35726766). 1019 menuItemButton.setTooltipText(menuItem.getTooltipText()); 1020 mMainPanel.addView(menuItemButton); 1021 final ViewGroup.LayoutParams params = menuItemButton.getLayoutParams(); 1022 params.width = menuItemButtonWidth; 1023 menuItemButton.setLayoutParams(params); 1024 availableWidth -= menuItemButtonWidth; 1025 remainingMenuItems.remove(0); 1026 } else { 1027 break; 1028 } 1029 lastGroupId = menuItem.getGroupId(); 1030 isFirstItem = false; 1031 } 1032 1033 if (!remainingMenuItems.isEmpty()) { 1034 // Reserve space for overflowButton. 1035 mMainPanel.setPaddingRelative(0, 0, mOverflowButtonSize.getWidth(), 0); 1036 } 1037 1038 mMainPanelSize = measure(mMainPanel); 1039 return remainingMenuItems; 1040 } 1041 layoutOverflowPanelItems(List<MenuItem> menuItems)1042 private void layoutOverflowPanelItems(List<MenuItem> menuItems) { 1043 ArrayAdapter<MenuItem> overflowPanelAdapter = 1044 (ArrayAdapter<MenuItem>) mOverflowPanel.getAdapter(); 1045 overflowPanelAdapter.clear(); 1046 final int size = menuItems.size(); 1047 for (int i = 0; i < size; i++) { 1048 overflowPanelAdapter.add(menuItems.get(i)); 1049 } 1050 mOverflowPanel.setAdapter(overflowPanelAdapter); 1051 if (mOpenOverflowUpwards) { 1052 mOverflowPanel.setY(0); 1053 } else { 1054 mOverflowPanel.setY(mOverflowButtonSize.getHeight()); 1055 } 1056 1057 int width = Math.max(getOverflowWidth(), mOverflowButtonSize.getWidth()); 1058 int height = calculateOverflowHeight(MAX_OVERFLOW_SIZE); 1059 mOverflowPanelSize = new Size(width, height); 1060 setSize(mOverflowPanel, mOverflowPanelSize); 1061 } 1062 1063 /** 1064 * Resets the content container and appropriately position it's panels. 1065 */ preparePopupContent()1066 private void preparePopupContent() { 1067 mContentContainer.removeAllViews(); 1068 1069 // Add views in the specified order so they stack up as expected. 1070 // Order: overflowPanel, mainPanel, overflowButton. 1071 if (hasOverflow()) { 1072 mContentContainer.addView(mOverflowPanel); 1073 } 1074 mContentContainer.addView(mMainPanel); 1075 if (hasOverflow()) { 1076 mContentContainer.addView(mOverflowButton); 1077 } 1078 setPanelsStatesAtRestingPosition(); 1079 setContentAreaAsTouchableSurface(); 1080 1081 // The positioning of contents in RTL is wrong when the view is first rendered. 1082 // Hide the view and post a runnable to recalculate positions and render the view. 1083 // TODO: Investigate why this happens and fix. 1084 if (isInRTLMode()) { 1085 mContentContainer.setAlpha(0); 1086 mContentContainer.post(mPreparePopupContentRTLHelper); 1087 } 1088 } 1089 1090 /** 1091 * Clears out the panels and their container. Resets their calculated sizes. 1092 */ clearPanels()1093 private void clearPanels() { 1094 mOverflowPanelSize = null; 1095 mMainPanelSize = null; 1096 mIsOverflowOpen = false; 1097 mMainPanel.removeAllViews(); 1098 ArrayAdapter<MenuItem> overflowPanelAdapter = 1099 (ArrayAdapter<MenuItem>) mOverflowPanel.getAdapter(); 1100 overflowPanelAdapter.clear(); 1101 mOverflowPanel.setAdapter(overflowPanelAdapter); 1102 mContentContainer.removeAllViews(); 1103 } 1104 positionContentYCoordinatesIfOpeningOverflowUpwards()1105 private void positionContentYCoordinatesIfOpeningOverflowUpwards() { 1106 if (mOpenOverflowUpwards) { 1107 mMainPanel.setY(mContentContainer.getHeight() - mMainPanelSize.getHeight()); 1108 mOverflowButton.setY(mContentContainer.getHeight() - mOverflowButton.getHeight()); 1109 mOverflowPanel.setY(mContentContainer.getHeight() - mOverflowPanelSize.getHeight()); 1110 } 1111 } 1112 getOverflowWidth()1113 private int getOverflowWidth() { 1114 int overflowWidth = 0; 1115 final int count = mOverflowPanel.getAdapter().getCount(); 1116 for (int i = 0; i < count; i++) { 1117 MenuItem menuItem = (MenuItem) mOverflowPanel.getAdapter().getItem(i); 1118 overflowWidth = 1119 Math.max(mOverflowPanelViewHelper.calculateWidth(menuItem), overflowWidth); 1120 } 1121 return overflowWidth; 1122 } 1123 calculateOverflowHeight(int maxItemSize)1124 private int calculateOverflowHeight(int maxItemSize) { 1125 // Maximum of 4 items, minimum of 2 if the overflow has to scroll. 1126 int actualSize = Math.min( 1127 MAX_OVERFLOW_SIZE, 1128 Math.min( 1129 Math.max(MIN_OVERFLOW_SIZE, maxItemSize), 1130 mOverflowPanel.getCount())); 1131 int extension = 0; 1132 if (actualSize < mOverflowPanel.getCount()) { 1133 // The overflow will require scrolling to get to all the items. 1134 // Extend the height so that part of the hidden items is displayed. 1135 extension = (int) (mLineHeight * 0.5f); 1136 } 1137 return actualSize * mLineHeight 1138 + mOverflowButtonSize.getHeight() 1139 + extension; 1140 } 1141 setButtonTagAndClickListener(View menuItemButton, MenuItem menuItem)1142 private void setButtonTagAndClickListener(View menuItemButton, MenuItem menuItem) { 1143 menuItemButton.setTag(MenuItemRepr.of(menuItem)); 1144 menuItemButton.setOnClickListener(mMenuItemButtonOnClickListener); 1145 } 1146 1147 /** 1148 * NOTE: Use only in android.view.animation.* animations. Do not use in android.animation.* 1149 * animations. See comment about this in the code. 1150 */ getAdjustedDuration(int originalDuration)1151 private int getAdjustedDuration(int originalDuration) { 1152 if (mTransitionDurationScale < 150) { 1153 // For smaller transition, decrease the time. 1154 return Math.max(originalDuration - 50, 0); 1155 } else if (mTransitionDurationScale > 300) { 1156 // For bigger transition, increase the time. 1157 return originalDuration + 50; 1158 } 1159 1160 // Scale the animation duration with getDurationScale(). This allows 1161 // android.view.animation.* animations to scale just like android.animation.* animations 1162 // when animator duration scale is adjusted in "Developer Options". 1163 // For this reason, do not use this method for android.animation.* animations. 1164 return (int) (originalDuration * ValueAnimator.getDurationScale()); 1165 } 1166 maybeComputeTransitionDurationScale()1167 private void maybeComputeTransitionDurationScale() { 1168 if (mMainPanelSize != null && mOverflowPanelSize != null) { 1169 int w = mMainPanelSize.getWidth() - mOverflowPanelSize.getWidth(); 1170 int h = mOverflowPanelSize.getHeight() - mMainPanelSize.getHeight(); 1171 mTransitionDurationScale = (int) (Math.sqrt(w * w + h * h) 1172 / mContentContainer.getContext().getResources().getDisplayMetrics().density); 1173 } 1174 } 1175 createMainPanel()1176 private ViewGroup createMainPanel() { 1177 ViewGroup mainPanel = new LinearLayout(mContext) { 1178 @Override 1179 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 1180 if (isOverflowAnimating()) { 1181 // Update widthMeasureSpec to make sure that this view is not clipped 1182 // as we offset its coordinates with respect to its parent. 1183 widthMeasureSpec = MeasureSpec.makeMeasureSpec( 1184 mMainPanelSize.getWidth(), 1185 MeasureSpec.EXACTLY); 1186 } 1187 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 1188 } 1189 1190 @Override 1191 public boolean onInterceptTouchEvent(MotionEvent ev) { 1192 // Intercept the touch event while the overflow is animating. 1193 return isOverflowAnimating(); 1194 } 1195 }; 1196 return mainPanel; 1197 } 1198 createOverflowButton()1199 private ImageButton createOverflowButton() { 1200 final ImageButton overflowButton = (ImageButton) LayoutInflater.from(mContext) 1201 .inflate(R.layout.floating_popup_overflow_button, null); 1202 overflowButton.setImageDrawable(mOverflow); 1203 overflowButton.setOnClickListener(v -> { 1204 if (mIsOverflowOpen) { 1205 overflowButton.setImageDrawable(mToOverflow); 1206 mToOverflow.start(); 1207 closeOverflow(); 1208 } else { 1209 overflowButton.setImageDrawable(mToArrow); 1210 mToArrow.start(); 1211 openOverflow(); 1212 } 1213 }); 1214 return overflowButton; 1215 } 1216 createOverflowPanel()1217 private OverflowPanel createOverflowPanel() { 1218 final OverflowPanel overflowPanel = new OverflowPanel(this); 1219 overflowPanel.setLayoutParams(new ViewGroup.LayoutParams( 1220 ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); 1221 overflowPanel.setDivider(null); 1222 overflowPanel.setDividerHeight(0); 1223 1224 final ArrayAdapter adapter = 1225 new ArrayAdapter<MenuItem>(mContext, 0) { 1226 @Override 1227 public View getView(int position, View convertView, ViewGroup parent) { 1228 return mOverflowPanelViewHelper.getView( 1229 getItem(position), mOverflowPanelSize.getWidth(), convertView); 1230 } 1231 }; 1232 overflowPanel.setAdapter(adapter); 1233 1234 overflowPanel.setOnItemClickListener((parent, view, position, id) -> { 1235 MenuItem menuItem = (MenuItem) overflowPanel.getAdapter().getItem(position); 1236 if (mOnMenuItemClickListener != null) { 1237 mOnMenuItemClickListener.onMenuItemClick(menuItem); 1238 } 1239 }); 1240 1241 return overflowPanel; 1242 } 1243 isOverflowAnimating()1244 private boolean isOverflowAnimating() { 1245 final boolean overflowOpening = mOpenOverflowAnimation.hasStarted() 1246 && !mOpenOverflowAnimation.hasEnded(); 1247 final boolean overflowClosing = mCloseOverflowAnimation.hasStarted() 1248 && !mCloseOverflowAnimation.hasEnded(); 1249 return overflowOpening || overflowClosing; 1250 } 1251 createOverflowAnimationListener()1252 private Animation.AnimationListener createOverflowAnimationListener() { 1253 Animation.AnimationListener listener = new Animation.AnimationListener() { 1254 @Override 1255 public void onAnimationStart(Animation animation) { 1256 // Disable the overflow button while it's animating. 1257 // It will be re-enabled when the animation stops. 1258 mOverflowButton.setEnabled(false); 1259 // Ensure both panels have visibility turned on when the overflow animation 1260 // starts. 1261 mMainPanel.setVisibility(View.VISIBLE); 1262 mOverflowPanel.setVisibility(View.VISIBLE); 1263 } 1264 1265 @Override 1266 public void onAnimationEnd(Animation animation) { 1267 // Posting this because it seems like this is called before the animation 1268 // actually ends. 1269 mContentContainer.post(() -> { 1270 setPanelsStatesAtRestingPosition(); 1271 setContentAreaAsTouchableSurface(); 1272 }); 1273 } 1274 1275 @Override 1276 public void onAnimationRepeat(Animation animation) { 1277 } 1278 }; 1279 return listener; 1280 } 1281 measure(View view)1282 private static Size measure(View view) { 1283 Preconditions.checkState(view.getParent() == null); 1284 view.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); 1285 return new Size(view.getMeasuredWidth(), view.getMeasuredHeight()); 1286 } 1287 setSize(View view, int width, int height)1288 private static void setSize(View view, int width, int height) { 1289 view.setMinimumWidth(width); 1290 view.setMinimumHeight(height); 1291 ViewGroup.LayoutParams params = view.getLayoutParams(); 1292 params = (params == null) ? new ViewGroup.LayoutParams(0, 0) : params; 1293 params.width = width; 1294 params.height = height; 1295 view.setLayoutParams(params); 1296 } 1297 setSize(View view, Size size)1298 private static void setSize(View view, Size size) { 1299 setSize(view, size.getWidth(), size.getHeight()); 1300 } 1301 setWidth(View view, int width)1302 private static void setWidth(View view, int width) { 1303 ViewGroup.LayoutParams params = view.getLayoutParams(); 1304 setSize(view, width, params.height); 1305 } 1306 setHeight(View view, int height)1307 private static void setHeight(View view, int height) { 1308 ViewGroup.LayoutParams params = view.getLayoutParams(); 1309 setSize(view, params.width, height); 1310 } 1311 1312 /** 1313 * A custom ListView for the overflow panel. 1314 */ 1315 private static final class OverflowPanel extends ListView { 1316 1317 private final LocalFloatingToolbarPopup mPopup; 1318 OverflowPanel(LocalFloatingToolbarPopup popup)1319 OverflowPanel(LocalFloatingToolbarPopup popup) { 1320 super(Objects.requireNonNull(popup).mContext); 1321 this.mPopup = popup; 1322 setScrollBarDefaultDelayBeforeFade(ViewConfiguration.getScrollDefaultDelay() * 3); 1323 setScrollIndicators(View.SCROLL_INDICATOR_TOP | View.SCROLL_INDICATOR_BOTTOM); 1324 } 1325 1326 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)1327 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 1328 // Update heightMeasureSpec to make sure that this view is not clipped 1329 // as we offset it's coordinates with respect to its parent. 1330 int height = mPopup.mOverflowPanelSize.getHeight() 1331 - mPopup.mOverflowButtonSize.getHeight(); 1332 heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY); 1333 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 1334 } 1335 1336 @Override dispatchTouchEvent(MotionEvent ev)1337 public boolean dispatchTouchEvent(MotionEvent ev) { 1338 if (mPopup.isOverflowAnimating()) { 1339 // Eat the touch event. 1340 return true; 1341 } 1342 return super.dispatchTouchEvent(ev); 1343 } 1344 1345 @Override awakenScrollBars()1346 protected boolean awakenScrollBars() { 1347 return super.awakenScrollBars(); 1348 } 1349 } 1350 1351 /** 1352 * A custom interpolator used for various floating toolbar animations. 1353 */ 1354 private static final class LogAccelerateInterpolator implements Interpolator { 1355 1356 private static final int BASE = 100; 1357 private static final float LOGS_SCALE = 1f / computeLog(1, BASE); 1358 computeLog(float t, int base)1359 private static float computeLog(float t, int base) { 1360 return (float) (1 - Math.pow(base, -t)); 1361 } 1362 1363 @Override getInterpolation(float t)1364 public float getInterpolation(float t) { 1365 return 1 - computeLog(1 - t, BASE) * LOGS_SCALE; 1366 } 1367 } 1368 1369 /** 1370 * A helper for generating views for the overflow panel. 1371 */ 1372 private static final class OverflowPanelViewHelper { 1373 1374 private final View mCalculator; 1375 private final int mIconTextSpacing; 1376 private final int mSidePadding; 1377 1378 private final Context mContext; 1379 OverflowPanelViewHelper(Context context, int iconTextSpacing)1380 OverflowPanelViewHelper(Context context, int iconTextSpacing) { 1381 mContext = Objects.requireNonNull(context); 1382 mIconTextSpacing = iconTextSpacing; 1383 mSidePadding = context.getResources() 1384 .getDimensionPixelSize(R.dimen.floating_toolbar_overflow_side_padding); 1385 mCalculator = createMenuButton(null); 1386 } 1387 getView(MenuItem menuItem, int minimumWidth, View convertView)1388 public View getView(MenuItem menuItem, int minimumWidth, View convertView) { 1389 Objects.requireNonNull(menuItem); 1390 if (convertView != null) { 1391 updateMenuItemButton( 1392 convertView, menuItem, mIconTextSpacing, shouldShowIcon(menuItem)); 1393 } else { 1394 convertView = createMenuButton(menuItem); 1395 } 1396 convertView.setMinimumWidth(minimumWidth); 1397 return convertView; 1398 } 1399 calculateWidth(MenuItem menuItem)1400 public int calculateWidth(MenuItem menuItem) { 1401 updateMenuItemButton( 1402 mCalculator, menuItem, mIconTextSpacing, shouldShowIcon(menuItem)); 1403 mCalculator.measure( 1404 View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); 1405 return mCalculator.getMeasuredWidth(); 1406 } 1407 createMenuButton(MenuItem menuItem)1408 private View createMenuButton(MenuItem menuItem) { 1409 View button = createMenuItemButton( 1410 mContext, menuItem, mIconTextSpacing, shouldShowIcon(menuItem)); 1411 button.setPadding(mSidePadding, 0, mSidePadding, 0); 1412 return button; 1413 } 1414 shouldShowIcon(MenuItem menuItem)1415 private boolean shouldShowIcon(MenuItem menuItem) { 1416 if (menuItem != null) { 1417 return menuItem.getGroupId() == android.R.id.textAssist; 1418 } 1419 return false; 1420 } 1421 } 1422 1423 /** 1424 * Creates and returns a menu button for the specified menu item. 1425 */ createMenuItemButton( Context context, MenuItem menuItem, int iconTextSpacing, boolean showIcon)1426 private static View createMenuItemButton( 1427 Context context, MenuItem menuItem, int iconTextSpacing, boolean showIcon) { 1428 final View menuItemButton = LayoutInflater.from(context) 1429 .inflate(R.layout.floating_popup_menu_button, null); 1430 if (menuItem != null) { 1431 updateMenuItemButton(menuItemButton, menuItem, iconTextSpacing, showIcon); 1432 } 1433 return menuItemButton; 1434 } 1435 1436 /** 1437 * Updates the specified menu item button with the specified menu item data. 1438 */ updateMenuItemButton( View menuItemButton, MenuItem menuItem, int iconTextSpacing, boolean showIcon)1439 private static void updateMenuItemButton( 1440 View menuItemButton, MenuItem menuItem, int iconTextSpacing, boolean showIcon) { 1441 final TextView buttonText = menuItemButton.findViewById( 1442 R.id.floating_toolbar_menu_item_text); 1443 buttonText.setEllipsize(null); 1444 if (TextUtils.isEmpty(menuItem.getTitle())) { 1445 buttonText.setVisibility(View.GONE); 1446 } else { 1447 buttonText.setVisibility(View.VISIBLE); 1448 buttonText.setText(menuItem.getTitle()); 1449 } 1450 final ImageView buttonIcon = menuItemButton.findViewById( 1451 R.id.floating_toolbar_menu_item_image); 1452 if (menuItem.getIcon() == null || !showIcon) { 1453 buttonIcon.setVisibility(View.GONE); 1454 if (buttonText != null) { 1455 buttonText.setPaddingRelative(0, 0, 0, 0); 1456 } 1457 } else { 1458 buttonIcon.setVisibility(View.VISIBLE); 1459 buttonIcon.setImageDrawable(menuItem.getIcon()); 1460 if (buttonText != null) { 1461 buttonText.setPaddingRelative(iconTextSpacing, 0, 0, 0); 1462 } 1463 } 1464 final CharSequence contentDescription = menuItem.getContentDescription(); 1465 if (TextUtils.isEmpty(contentDescription)) { 1466 menuItemButton.setContentDescription(menuItem.getTitle()); 1467 } else { 1468 menuItemButton.setContentDescription(contentDescription); 1469 } 1470 } 1471 createContentContainer(Context context)1472 private static ViewGroup createContentContainer(Context context) { 1473 ViewGroup contentContainer = (ViewGroup) LayoutInflater.from(context) 1474 .inflate(R.layout.floating_popup_container, null); 1475 contentContainer.setLayoutParams(new ViewGroup.LayoutParams( 1476 ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); 1477 contentContainer.setTag(FloatingToolbar.FLOATING_TOOLBAR_TAG); 1478 contentContainer.setClipToOutline(true); 1479 return contentContainer; 1480 } 1481 createPopupWindow(ViewGroup content)1482 private static PopupWindow createPopupWindow(ViewGroup content) { 1483 ViewGroup popupContentHolder = new LinearLayout(content.getContext()); 1484 PopupWindow popupWindow = new PopupWindow(popupContentHolder); 1485 // TODO: Use .setIsLaidOutInScreen(true) instead of .setClippingEnabled(false) 1486 // unless FLAG_LAYOUT_IN_SCREEN has any unintentional side-effects. 1487 popupWindow.setClippingEnabled(false); 1488 popupWindow.setWindowLayoutType( 1489 WindowManager.LayoutParams.TYPE_APPLICATION_ABOVE_SUB_PANEL); 1490 popupWindow.setAnimationStyle(0); 1491 popupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); 1492 content.setLayoutParams(new ViewGroup.LayoutParams( 1493 ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); 1494 popupContentHolder.addView(content); 1495 return popupWindow; 1496 } 1497 1498 /** 1499 * Creates an "appear" animation for the specified view. 1500 * 1501 * @param view The view to animate 1502 */ createEnterAnimation(View view)1503 private static AnimatorSet createEnterAnimation(View view) { 1504 AnimatorSet animation = new AnimatorSet(); 1505 animation.playTogether( 1506 ObjectAnimator.ofFloat(view, View.ALPHA, 0, 1).setDuration(150)); 1507 return animation; 1508 } 1509 1510 /** 1511 * Creates a "disappear" animation for the specified view. 1512 * 1513 * @param view The view to animate 1514 * @param startDelay The start delay of the animation 1515 * @param listener The animation listener 1516 */ createExitAnimation( View view, int startDelay, Animator.AnimatorListener listener)1517 private static AnimatorSet createExitAnimation( 1518 View view, int startDelay, Animator.AnimatorListener listener) { 1519 AnimatorSet animation = new AnimatorSet(); 1520 animation.playTogether( 1521 ObjectAnimator.ofFloat(view, View.ALPHA, 1, 0).setDuration(100)); 1522 animation.setStartDelay(startDelay); 1523 animation.addListener(listener); 1524 return animation; 1525 } 1526 1527 /** 1528 * Returns a re-themed context with controlled look and feel for views. 1529 */ applyDefaultTheme(Context originalContext)1530 private static Context applyDefaultTheme(Context originalContext) { 1531 TypedArray a = originalContext.obtainStyledAttributes(new int[]{R.attr.isLightTheme}); 1532 boolean isLightTheme = a.getBoolean(0, true); 1533 int themeId = 1534 isLightTheme ? R.style.Theme_DeviceDefault_Light : R.style.Theme_DeviceDefault; 1535 a.recycle(); 1536 return new ContextThemeWrapper(originalContext, themeId); 1537 } 1538 1539 /** 1540 * Represents the identity of a MenuItem that is rendered in a FloatingToolbarPopup. 1541 */ 1542 @VisibleForTesting 1543 public static final class MenuItemRepr { 1544 1545 public final int itemId; 1546 public final int groupId; 1547 @Nullable public final String title; 1548 @Nullable private final Drawable mIcon; 1549 MenuItemRepr( int itemId, int groupId, @Nullable CharSequence title, @Nullable Drawable icon)1550 private MenuItemRepr( 1551 int itemId, int groupId, @Nullable CharSequence title, @Nullable Drawable icon) { 1552 this.itemId = itemId; 1553 this.groupId = groupId; 1554 this.title = (title == null) ? null : title.toString(); 1555 mIcon = icon; 1556 } 1557 1558 /** 1559 * Creates an instance of MenuItemRepr for the specified menu item. 1560 */ of(MenuItem menuItem)1561 public static MenuItemRepr of(MenuItem menuItem) { 1562 return new MenuItemRepr( 1563 menuItem.getItemId(), 1564 menuItem.getGroupId(), 1565 menuItem.getTitle(), 1566 menuItem.getIcon()); 1567 } 1568 1569 /** 1570 * Returns this object's hashcode. 1571 */ 1572 @Override hashCode()1573 public int hashCode() { 1574 return Objects.hash(itemId, groupId, title, mIcon); 1575 } 1576 1577 /** 1578 * Returns true if this object is the same as the specified object. 1579 */ 1580 @Override equals(Object o)1581 public boolean equals(Object o) { 1582 if (o == this) { 1583 return true; 1584 } 1585 if (!(o instanceof MenuItemRepr)) { 1586 return false; 1587 } 1588 final MenuItemRepr other = (MenuItemRepr) o; 1589 return itemId == other.itemId 1590 && groupId == other.groupId 1591 && TextUtils.equals(title, other.title) 1592 // Many Drawables (icons) do not implement equals(). Using equals() here instead 1593 // of reference comparisons in case a Drawable subclass implements equals(). 1594 && Objects.equals(mIcon, other.mIcon); 1595 } 1596 1597 /** 1598 * Returns true if the two menu item collections are the same based on MenuItemRepr. 1599 */ reprEquals( Collection<MenuItem> menuItems1, Collection<MenuItem> menuItems2)1600 public static boolean reprEquals( 1601 Collection<MenuItem> menuItems1, Collection<MenuItem> menuItems2) { 1602 if (menuItems1.size() != menuItems2.size()) { 1603 return false; 1604 } 1605 1606 final Iterator<MenuItem> menuItems2Iter = menuItems2.iterator(); 1607 for (MenuItem menuItem1 : menuItems1) { 1608 final MenuItem menuItem2 = menuItems2Iter.next(); 1609 if (!MenuItemRepr.of(menuItem1).equals(MenuItemRepr.of(menuItem2))) { 1610 return false; 1611 } 1612 } 1613 1614 return true; 1615 } 1616 } 1617 } 1618