From c048b812c992dba75ccead64525d7d8e63cbd057 Mon Sep 17 00:00:00 2001 From: Joanne Chung Date: Fri, 15 Oct 2021 20:30:01 +0800 Subject: [PATCH] Move FloatingToolbarPopup out from FloatingToolbar This is a refinement to simply move FloatingToolbarPopup out to be a stand alone class that helps the follow up change. No logic is changed in this change. Bug: 190030331 Test: manual Test: atest TextViewActivityTest Test: atest TextViewIntegrationTest Change-Id: I2b371e44bc36300b9a537798e8f7eb8702a7f0f2 Merged-In: I835a63da60861551c6519f9e2886d39b6319fd9a --- .../internal/widget/FloatingToolbar.java | 1588 +--------------- .../internal/widget/FloatingToolbarPopup.java | 1610 +++++++++++++++++ .../FloatingToolbarEspressoUtils.java | 2 +- 3 files changed, 1612 insertions(+), 1588 deletions(-) create mode 100644 core/java/com/android/internal/widget/FloatingToolbarPopup.java diff --git a/core/java/com/android/internal/widget/FloatingToolbar.java b/core/java/com/android/internal/widget/FloatingToolbar.java index 514e0b8e0ded3..a0bf9b59cc14a 100644 --- a/core/java/com/android/internal/widget/FloatingToolbar.java +++ b/core/java/com/android/internal/widget/FloatingToolbar.java @@ -16,62 +16,18 @@ package com.android.internal.widget; -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.AnimatorSet; -import android.animation.ObjectAnimator; -import android.animation.ValueAnimator; import android.annotation.Nullable; -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Color; -import android.graphics.Point; import android.graphics.Rect; -import android.graphics.Region; -import android.graphics.drawable.AnimatedVectorDrawable; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.text.TextUtils; -import android.util.Size; -import android.view.ContextThemeWrapper; -import android.view.Gravity; -import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; -import android.view.MotionEvent; import android.view.View; -import android.view.View.MeasureSpec; import android.view.View.OnLayoutChangeListener; -import android.view.ViewConfiguration; -import android.view.ViewGroup; -import android.view.ViewTreeObserver; import android.view.Window; -import android.view.WindowManager; -import android.view.animation.Animation; -import android.view.animation.AnimationSet; -import android.view.animation.AnimationUtils; -import android.view.animation.Interpolator; -import android.view.animation.Transformation; -import android.widget.ArrayAdapter; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.ListView; import android.widget.PopupWindow; -import android.widget.TextView; - -import com.android.internal.R; -import com.android.internal.annotations.VisibleForTesting; -import com.android.internal.util.Preconditions; import java.util.ArrayList; -import java.util.Collection; import java.util.Comparator; -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.LinkedList; import java.util.List; -import java.util.Map; import java.util.Objects; /** @@ -90,7 +46,6 @@ public final class FloatingToolbar { private static final MenuItem.OnMenuItemClickListener NO_OP_MENUITEM_CLICK_LISTENER = item -> false; - private final Context mContext; private final Window mWindow; private final FloatingToolbarPopup mPopup; @@ -158,9 +113,8 @@ public final class FloatingToolbar { public FloatingToolbar(Window window) { // TODO(b/65172902): Pass context in constructor when DecorView (and other callers) // supports multi-display. - mContext = applyDefaultTheme(window.getContext()); mWindow = Objects.requireNonNull(window); - mPopup = new FloatingToolbarPopup(mContext, window.getDecorView()); + mPopup = new FloatingToolbarPopup(window.getContext(), window.getDecorView()); } /** @@ -321,1544 +275,4 @@ public final class FloatingToolbar { private void unregisterOrientationHandler() { mWindow.getDecorView().removeOnLayoutChangeListener(mOrientationChangeHandler); } - - - /** - * A popup window used by the floating toolbar. - * - * This class is responsible for the rendering/animation of the floating toolbar. - * It holds 2 panels (i.e. main panel and overflow panel) and an overflow button - * to transition between panels. - */ - private static final class FloatingToolbarPopup { - - /* Minimum and maximum number of items allowed in the overflow. */ - private static final int MIN_OVERFLOW_SIZE = 2; - private static final int MAX_OVERFLOW_SIZE = 4; - - private final Context mContext; - private final View mParent; // Parent for the popup window. - private final PopupWindow mPopupWindow; - - /* Margins between the popup window and it's content. */ - private final int mMarginHorizontal; - private final int mMarginVertical; - - /* View components */ - private final ViewGroup mContentContainer; // holds all contents. - private final ViewGroup mMainPanel; // holds menu items that are initially displayed. - private final OverflowPanel mOverflowPanel; // holds menu items hidden in the overflow. - private final ImageButton mOverflowButton; // opens/closes the overflow. - /* overflow button drawables. */ - private final Drawable mArrow; - private final Drawable mOverflow; - private final AnimatedVectorDrawable mToArrow; - private final AnimatedVectorDrawable mToOverflow; - - private final OverflowPanelViewHelper mOverflowPanelViewHelper; - - /* Animation interpolators. */ - private final Interpolator mLogAccelerateInterpolator; - private final Interpolator mFastOutSlowInInterpolator; - private final Interpolator mLinearOutSlowInInterpolator; - private final Interpolator mFastOutLinearInInterpolator; - - /* Animations. */ - private final AnimatorSet mShowAnimation; - private final AnimatorSet mDismissAnimation; - private final AnimatorSet mHideAnimation; - private final AnimationSet mOpenOverflowAnimation; - private final AnimationSet mCloseOverflowAnimation; - private final Animation.AnimationListener mOverflowAnimationListener; - - private final Rect mViewPortOnScreen = new Rect(); // portion of screen we can draw in. - private final Point mCoordsOnWindow = new Point(); // popup window coordinates. - /* Temporary data holders. Reset values before using. */ - private final int[] mTmpCoords = new int[2]; - - private final Region mTouchableRegion = new Region(); - private final ViewTreeObserver.OnComputeInternalInsetsListener mInsetsComputer = - info -> { - info.contentInsets.setEmpty(); - info.visibleInsets.setEmpty(); - info.touchableRegion.set(mTouchableRegion); - info.setTouchableInsets( - ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION); - }; - - private final int mLineHeight; - private final int mIconTextSpacing; - - /** - * @see OverflowPanelViewHelper#preparePopupContent(). - */ - private final Runnable mPreparePopupContentRTLHelper = new Runnable() { - @Override - public void run() { - setPanelsStatesAtRestingPosition(); - setContentAreaAsTouchableSurface(); - mContentContainer.setAlpha(1); - } - }; - - private boolean mDismissed = true; // tracks whether this popup is dismissed or dismissing. - private boolean mHidden; // tracks whether this popup is hidden or hiding. - - /* Calculated sizes for panels and overflow button. */ - private final Size mOverflowButtonSize; - private Size mOverflowPanelSize; // Should be null when there is no overflow. - private Size mMainPanelSize; - - /* Menu items and click listeners */ - private final Map mMenuItems = new LinkedHashMap<>(); - private MenuItem.OnMenuItemClickListener mOnMenuItemClickListener; - private final View.OnClickListener mMenuItemButtonOnClickListener = - new View.OnClickListener() { - @Override - public void onClick(View v) { - if (mOnMenuItemClickListener == null) { - return; - } - final Object tag = v.getTag(); - if (!(tag instanceof MenuItemRepr)) { - return; - } - final MenuItem menuItem = mMenuItems.get((MenuItemRepr) tag); - if (menuItem == null) { - return; - } - mOnMenuItemClickListener.onMenuItemClick(menuItem); - } - }; - - private boolean mOpenOverflowUpwards; // Whether the overflow opens upwards or downwards. - private boolean mIsOverflowOpen; - - private int mTransitionDurationScale; // Used to scale the toolbar transition duration. - - /** - * Initializes a new floating toolbar popup. - * - * @param parent A parent view to get the {@link android.view.View#getWindowToken()} token - * from. - */ - public FloatingToolbarPopup(Context context, View parent) { - mParent = Objects.requireNonNull(parent); - mContext = Objects.requireNonNull(context); - mContentContainer = createContentContainer(context); - mPopupWindow = createPopupWindow(mContentContainer); - mMarginHorizontal = parent.getResources() - .getDimensionPixelSize(R.dimen.floating_toolbar_horizontal_margin); - mMarginVertical = parent.getResources() - .getDimensionPixelSize(R.dimen.floating_toolbar_vertical_margin); - mLineHeight = context.getResources() - .getDimensionPixelSize(R.dimen.floating_toolbar_height); - mIconTextSpacing = context.getResources() - .getDimensionPixelSize(R.dimen.floating_toolbar_icon_text_spacing); - - // Interpolators - mLogAccelerateInterpolator = new LogAccelerateInterpolator(); - mFastOutSlowInInterpolator = AnimationUtils.loadInterpolator( - mContext, android.R.interpolator.fast_out_slow_in); - mLinearOutSlowInInterpolator = AnimationUtils.loadInterpolator( - mContext, android.R.interpolator.linear_out_slow_in); - mFastOutLinearInInterpolator = AnimationUtils.loadInterpolator( - mContext, android.R.interpolator.fast_out_linear_in); - - // Drawables. Needed for views. - mArrow = mContext.getResources() - .getDrawable(R.drawable.ft_avd_tooverflow, mContext.getTheme()); - mArrow.setAutoMirrored(true); - mOverflow = mContext.getResources() - .getDrawable(R.drawable.ft_avd_toarrow, mContext.getTheme()); - mOverflow.setAutoMirrored(true); - mToArrow = (AnimatedVectorDrawable) mContext.getResources() - .getDrawable(R.drawable.ft_avd_toarrow_animation, mContext.getTheme()); - mToArrow.setAutoMirrored(true); - mToOverflow = (AnimatedVectorDrawable) mContext.getResources() - .getDrawable(R.drawable.ft_avd_tooverflow_animation, mContext.getTheme()); - mToOverflow.setAutoMirrored(true); - - // Views - mOverflowButton = createOverflowButton(); - mOverflowButtonSize = measure(mOverflowButton); - mMainPanel = createMainPanel(); - mOverflowPanelViewHelper = new OverflowPanelViewHelper(mContext, mIconTextSpacing); - mOverflowPanel = createOverflowPanel(); - - // Animation. Need views. - mOverflowAnimationListener = createOverflowAnimationListener(); - mOpenOverflowAnimation = new AnimationSet(true); - mOpenOverflowAnimation.setAnimationListener(mOverflowAnimationListener); - mCloseOverflowAnimation = new AnimationSet(true); - mCloseOverflowAnimation.setAnimationListener(mOverflowAnimationListener); - mShowAnimation = createEnterAnimation(mContentContainer); - mDismissAnimation = createExitAnimation( - mContentContainer, - 150, // startDelay - new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - mPopupWindow.dismiss(); - mContentContainer.removeAllViews(); - } - }); - mHideAnimation = createExitAnimation( - mContentContainer, - 0, // startDelay - new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - mPopupWindow.dismiss(); - } - }); - } - - /** - * Makes this toolbar "outside touchable" and sets the onDismissListener. - * - * @param outsideTouchable if true, the popup will be made "outside touchable" and - * "non focusable". The reverse will happen if false. - * @param onDismiss - * - * @return true if the "outsideTouchable" setting was modified. Otherwise returns false - * - * @see PopupWindow#setOutsideTouchable(boolean) - * @see PopupWindow#setFocusable(boolean) - * @see PopupWindow.OnDismissListener - */ - public boolean setOutsideTouchable( - boolean outsideTouchable, @Nullable PopupWindow.OnDismissListener onDismiss) { - boolean ret = false; - if (mPopupWindow.isOutsideTouchable() ^ outsideTouchable) { - mPopupWindow.setOutsideTouchable(outsideTouchable); - mPopupWindow.setFocusable(!outsideTouchable); - mPopupWindow.update(); - ret = true; - } - mPopupWindow.setOnDismissListener(onDismiss); - return ret; - } - - /** - * Lays out buttons for the specified menu items. - * Requires a subsequent call to {@link #show()} to show the items. - */ - public void layoutMenuItems( - List menuItems, - MenuItem.OnMenuItemClickListener menuItemClickListener, - int suggestedWidth) { - cancelOverflowAnimations(); - clearPanels(); - updateMenuItems(menuItems, menuItemClickListener); - menuItems = layoutMainPanelItems(menuItems, getAdjustedToolbarWidth(suggestedWidth)); - if (!menuItems.isEmpty()) { - // Add remaining items to the overflow. - layoutOverflowPanelItems(menuItems); - } - updatePopupSize(); - } - - /** - * Updates the popup's menu items without rebuilding the widget. - * Use in place of layoutMenuItems() when the popup's views need not be reconstructed. - * - * @see isLayoutRequired(List) - */ - public void updateMenuItems( - List menuItems, MenuItem.OnMenuItemClickListener menuItemClickListener) { - mMenuItems.clear(); - for (MenuItem menuItem : menuItems) { - mMenuItems.put(MenuItemRepr.of(menuItem), menuItem); - } - mOnMenuItemClickListener = menuItemClickListener; - } - - /** - * Returns true if this popup needs a relayout to properly render the specified menu items. - */ - public boolean isLayoutRequired(List menuItems) { - return !MenuItemRepr.reprEquals(menuItems, mMenuItems.values()); - } - - /** - * Shows this popup at the specified coordinates. - * The specified coordinates may be adjusted to make sure the popup is entirely on-screen. - */ - public void show(Rect contentRectOnScreen) { - Objects.requireNonNull(contentRectOnScreen); - - if (isShowing()) { - return; - } - - mHidden = false; - mDismissed = false; - cancelDismissAndHideAnimations(); - cancelOverflowAnimations(); - - refreshCoordinatesAndOverflowDirection(contentRectOnScreen); - preparePopupContent(); - // We need to specify the position in window coordinates. - // TODO: Consider to use PopupWindow.setIsLaidOutInScreen(true) so that we can - // specify the popup position in screen coordinates. - mPopupWindow.showAtLocation( - mParent, Gravity.NO_GRAVITY, mCoordsOnWindow.x, mCoordsOnWindow.y); - setTouchableSurfaceInsetsComputer(); - runShowAnimation(); - } - - /** - * Gets rid of this popup. If the popup isn't currently showing, this will be a no-op. - */ - public void dismiss() { - if (mDismissed) { - return; - } - - mHidden = false; - mDismissed = true; - mHideAnimation.cancel(); - - runDismissAnimation(); - setZeroTouchableSurface(); - } - - /** - * Hides this popup. This is a no-op if this popup is not showing. - * Use {@link #isHidden()} to distinguish between a hidden and a dismissed popup. - */ - public void hide() { - if (!isShowing()) { - return; - } - - mHidden = true; - runHideAnimation(); - setZeroTouchableSurface(); - } - - /** - * Returns {@code true} if this popup is currently showing. {@code false} otherwise. - */ - public boolean isShowing() { - return !mDismissed && !mHidden; - } - - /** - * Returns {@code true} if this popup is currently hidden. {@code false} otherwise. - */ - public boolean isHidden() { - return mHidden; - } - - /** - * Updates the coordinates of this popup. - * The specified coordinates may be adjusted to make sure the popup is entirely on-screen. - * This is a no-op if this popup is not showing. - */ - public void updateCoordinates(Rect contentRectOnScreen) { - Objects.requireNonNull(contentRectOnScreen); - - if (!isShowing() || !mPopupWindow.isShowing()) { - return; - } - - cancelOverflowAnimations(); - refreshCoordinatesAndOverflowDirection(contentRectOnScreen); - preparePopupContent(); - // We need to specify the position in window coordinates. - // TODO: Consider to use PopupWindow.setIsLaidOutInScreen(true) so that we can - // specify the popup position in screen coordinates. - mPopupWindow.update( - mCoordsOnWindow.x, mCoordsOnWindow.y, - mPopupWindow.getWidth(), mPopupWindow.getHeight()); - } - - private void refreshCoordinatesAndOverflowDirection(Rect contentRectOnScreen) { - refreshViewPort(); - - // Initialize x ensuring that the toolbar isn't rendered behind the nav bar in - // landscape. - final int x = Math.min( - contentRectOnScreen.centerX() - mPopupWindow.getWidth() / 2, - mViewPortOnScreen.right - mPopupWindow.getWidth()); - - final int y; - - final int availableHeightAboveContent = - contentRectOnScreen.top - mViewPortOnScreen.top; - final int availableHeightBelowContent = - mViewPortOnScreen.bottom - contentRectOnScreen.bottom; - - final int margin = 2 * mMarginVertical; - final int toolbarHeightWithVerticalMargin = mLineHeight + margin; - - if (!hasOverflow()) { - if (availableHeightAboveContent >= toolbarHeightWithVerticalMargin) { - // There is enough space at the top of the content. - y = contentRectOnScreen.top - toolbarHeightWithVerticalMargin; - } else if (availableHeightBelowContent >= toolbarHeightWithVerticalMargin) { - // There is enough space at the bottom of the content. - y = contentRectOnScreen.bottom; - } else if (availableHeightBelowContent >= mLineHeight) { - // Just enough space to fit the toolbar with no vertical margins. - y = contentRectOnScreen.bottom - mMarginVertical; - } else { - // Not enough space. Prefer to position as high as possible. - y = Math.max( - mViewPortOnScreen.top, - contentRectOnScreen.top - toolbarHeightWithVerticalMargin); - } - } else { - // Has an overflow. - final int minimumOverflowHeightWithMargin = - calculateOverflowHeight(MIN_OVERFLOW_SIZE) + margin; - final int availableHeightThroughContentDown = mViewPortOnScreen.bottom - - contentRectOnScreen.top + toolbarHeightWithVerticalMargin; - final int availableHeightThroughContentUp = contentRectOnScreen.bottom - - mViewPortOnScreen.top + toolbarHeightWithVerticalMargin; - - if (availableHeightAboveContent >= minimumOverflowHeightWithMargin) { - // There is enough space at the top of the content rect for the overflow. - // Position above and open upwards. - updateOverflowHeight(availableHeightAboveContent - margin); - y = contentRectOnScreen.top - mPopupWindow.getHeight(); - mOpenOverflowUpwards = true; - } else if (availableHeightAboveContent >= toolbarHeightWithVerticalMargin - && availableHeightThroughContentDown >= minimumOverflowHeightWithMargin) { - // There is enough space at the top of the content rect for the main panel - // but not the overflow. - // Position above but open downwards. - updateOverflowHeight(availableHeightThroughContentDown - margin); - y = contentRectOnScreen.top - toolbarHeightWithVerticalMargin; - mOpenOverflowUpwards = false; - } else if (availableHeightBelowContent >= minimumOverflowHeightWithMargin) { - // There is enough space at the bottom of the content rect for the overflow. - // Position below and open downwards. - updateOverflowHeight(availableHeightBelowContent - margin); - y = contentRectOnScreen.bottom; - mOpenOverflowUpwards = false; - } else if (availableHeightBelowContent >= toolbarHeightWithVerticalMargin - && mViewPortOnScreen.height() >= minimumOverflowHeightWithMargin) { - // There is enough space at the bottom of the content rect for the main panel - // but not the overflow. - // Position below but open upwards. - updateOverflowHeight(availableHeightThroughContentUp - margin); - y = contentRectOnScreen.bottom + toolbarHeightWithVerticalMargin - - mPopupWindow.getHeight(); - mOpenOverflowUpwards = true; - } else { - // Not enough space. - // Position at the top of the view port and open downwards. - updateOverflowHeight(mViewPortOnScreen.height() - margin); - y = mViewPortOnScreen.top; - mOpenOverflowUpwards = false; - } - } - - // We later specify the location of PopupWindow relative to the attached window. - // The idea here is that 1) we can get the location of a View in both window coordinates - // and screen coordiantes, where the offset between them should be equal to the window - // origin, and 2) we can use an arbitrary for this calculation while calculating the - // location of the rootview is supposed to be least expensive. - // TODO: Consider to use PopupWindow.setIsLaidOutInScreen(true) so that we can avoid - // the following calculation. - mParent.getRootView().getLocationOnScreen(mTmpCoords); - int rootViewLeftOnScreen = mTmpCoords[0]; - int rootViewTopOnScreen = mTmpCoords[1]; - mParent.getRootView().getLocationInWindow(mTmpCoords); - int rootViewLeftOnWindow = mTmpCoords[0]; - int rootViewTopOnWindow = mTmpCoords[1]; - int windowLeftOnScreen = rootViewLeftOnScreen - rootViewLeftOnWindow; - int windowTopOnScreen = rootViewTopOnScreen - rootViewTopOnWindow; - mCoordsOnWindow.set( - Math.max(0, x - windowLeftOnScreen), Math.max(0, y - windowTopOnScreen)); - } - - /** - * Performs the "show" animation on the floating popup. - */ - private void runShowAnimation() { - mShowAnimation.start(); - } - - /** - * Performs the "dismiss" animation on the floating popup. - */ - private void runDismissAnimation() { - mDismissAnimation.start(); - } - - /** - * Performs the "hide" animation on the floating popup. - */ - private void runHideAnimation() { - mHideAnimation.start(); - } - - private void cancelDismissAndHideAnimations() { - mDismissAnimation.cancel(); - mHideAnimation.cancel(); - } - - private void cancelOverflowAnimations() { - mContentContainer.clearAnimation(); - mMainPanel.animate().cancel(); - mOverflowPanel.animate().cancel(); - mToArrow.stop(); - mToOverflow.stop(); - } - - private void openOverflow() { - final int targetWidth = mOverflowPanelSize.getWidth(); - final int targetHeight = mOverflowPanelSize.getHeight(); - final int startWidth = mContentContainer.getWidth(); - final int startHeight = mContentContainer.getHeight(); - final float startY = mContentContainer.getY(); - final float left = mContentContainer.getX(); - final float right = left + mContentContainer.getWidth(); - Animation widthAnimation = new Animation() { - @Override - protected void applyTransformation(float interpolatedTime, Transformation t) { - int deltaWidth = (int) (interpolatedTime * (targetWidth - startWidth)); - setWidth(mContentContainer, startWidth + deltaWidth); - if (isInRTLMode()) { - mContentContainer.setX(left); - - // Lock the panels in place. - mMainPanel.setX(0); - mOverflowPanel.setX(0); - } else { - mContentContainer.setX(right - mContentContainer.getWidth()); - - // Offset the panels' positions so they look like they're locked in place - // on the screen. - mMainPanel.setX(mContentContainer.getWidth() - startWidth); - mOverflowPanel.setX(mContentContainer.getWidth() - targetWidth); - } - } - }; - Animation heightAnimation = new Animation() { - @Override - protected void applyTransformation(float interpolatedTime, Transformation t) { - int deltaHeight = (int) (interpolatedTime * (targetHeight - startHeight)); - setHeight(mContentContainer, startHeight + deltaHeight); - if (mOpenOverflowUpwards) { - mContentContainer.setY( - startY - (mContentContainer.getHeight() - startHeight)); - positionContentYCoordinatesIfOpeningOverflowUpwards(); - } - } - }; - final float overflowButtonStartX = mOverflowButton.getX(); - final float overflowButtonTargetX = isInRTLMode() ? - overflowButtonStartX + targetWidth - mOverflowButton.getWidth() : - overflowButtonStartX - targetWidth + mOverflowButton.getWidth(); - Animation overflowButtonAnimation = new Animation() { - @Override - protected void applyTransformation(float interpolatedTime, Transformation t) { - float overflowButtonX = overflowButtonStartX - + interpolatedTime * (overflowButtonTargetX - overflowButtonStartX); - float deltaContainerWidth = isInRTLMode() ? - 0 : - mContentContainer.getWidth() - startWidth; - float actualOverflowButtonX = overflowButtonX + deltaContainerWidth; - mOverflowButton.setX(actualOverflowButtonX); - } - }; - widthAnimation.setInterpolator(mLogAccelerateInterpolator); - widthAnimation.setDuration(getAdjustedDuration(250)); - heightAnimation.setInterpolator(mFastOutSlowInInterpolator); - heightAnimation.setDuration(getAdjustedDuration(250)); - overflowButtonAnimation.setInterpolator(mFastOutSlowInInterpolator); - overflowButtonAnimation.setDuration(getAdjustedDuration(250)); - mOpenOverflowAnimation.getAnimations().clear(); - mOpenOverflowAnimation.getAnimations().clear(); - mOpenOverflowAnimation.addAnimation(widthAnimation); - mOpenOverflowAnimation.addAnimation(heightAnimation); - mOpenOverflowAnimation.addAnimation(overflowButtonAnimation); - mContentContainer.startAnimation(mOpenOverflowAnimation); - mIsOverflowOpen = true; - mMainPanel.animate() - .alpha(0).withLayer() - .setInterpolator(mLinearOutSlowInInterpolator) - .setDuration(250) - .start(); - mOverflowPanel.setAlpha(1); // fadeIn in 0ms. - } - - private void closeOverflow() { - final int targetWidth = mMainPanelSize.getWidth(); - final int startWidth = mContentContainer.getWidth(); - final float left = mContentContainer.getX(); - final float right = left + mContentContainer.getWidth(); - Animation widthAnimation = new Animation() { - @Override - protected void applyTransformation(float interpolatedTime, Transformation t) { - int deltaWidth = (int) (interpolatedTime * (targetWidth - startWidth)); - setWidth(mContentContainer, startWidth + deltaWidth); - if (isInRTLMode()) { - mContentContainer.setX(left); - - // Lock the panels in place. - mMainPanel.setX(0); - mOverflowPanel.setX(0); - } else { - mContentContainer.setX(right - mContentContainer.getWidth()); - - // Offset the panels' positions so they look like they're locked in place - // on the screen. - mMainPanel.setX(mContentContainer.getWidth() - targetWidth); - mOverflowPanel.setX(mContentContainer.getWidth() - startWidth); - } - } - }; - final int targetHeight = mMainPanelSize.getHeight(); - final int startHeight = mContentContainer.getHeight(); - final float bottom = mContentContainer.getY() + mContentContainer.getHeight(); - Animation heightAnimation = new Animation() { - @Override - protected void applyTransformation(float interpolatedTime, Transformation t) { - int deltaHeight = (int) (interpolatedTime * (targetHeight - startHeight)); - setHeight(mContentContainer, startHeight + deltaHeight); - if (mOpenOverflowUpwards) { - mContentContainer.setY(bottom - mContentContainer.getHeight()); - positionContentYCoordinatesIfOpeningOverflowUpwards(); - } - } - }; - final float overflowButtonStartX = mOverflowButton.getX(); - final float overflowButtonTargetX = isInRTLMode() ? - overflowButtonStartX - startWidth + mOverflowButton.getWidth() : - overflowButtonStartX + startWidth - mOverflowButton.getWidth(); - Animation overflowButtonAnimation = new Animation() { - @Override - protected void applyTransformation(float interpolatedTime, Transformation t) { - float overflowButtonX = overflowButtonStartX - + interpolatedTime * (overflowButtonTargetX - overflowButtonStartX); - float deltaContainerWidth = isInRTLMode() ? - 0 : - mContentContainer.getWidth() - startWidth; - float actualOverflowButtonX = overflowButtonX + deltaContainerWidth; - mOverflowButton.setX(actualOverflowButtonX); - } - }; - widthAnimation.setInterpolator(mFastOutSlowInInterpolator); - widthAnimation.setDuration(getAdjustedDuration(250)); - heightAnimation.setInterpolator(mLogAccelerateInterpolator); - heightAnimation.setDuration(getAdjustedDuration(250)); - overflowButtonAnimation.setInterpolator(mFastOutSlowInInterpolator); - overflowButtonAnimation.setDuration(getAdjustedDuration(250)); - mCloseOverflowAnimation.getAnimations().clear(); - mCloseOverflowAnimation.addAnimation(widthAnimation); - mCloseOverflowAnimation.addAnimation(heightAnimation); - mCloseOverflowAnimation.addAnimation(overflowButtonAnimation); - mContentContainer.startAnimation(mCloseOverflowAnimation); - mIsOverflowOpen = false; - mMainPanel.animate() - .alpha(1).withLayer() - .setInterpolator(mFastOutLinearInInterpolator) - .setDuration(100) - .start(); - mOverflowPanel.animate() - .alpha(0).withLayer() - .setInterpolator(mLinearOutSlowInInterpolator) - .setDuration(150) - .start(); - } - - /** - * Defines the position of the floating toolbar popup panels when transition animation has - * stopped. - */ - private void setPanelsStatesAtRestingPosition() { - mOverflowButton.setEnabled(true); - mOverflowPanel.awakenScrollBars(); - - if (mIsOverflowOpen) { - // Set open state. - final Size containerSize = mOverflowPanelSize; - setSize(mContentContainer, containerSize); - mMainPanel.setAlpha(0); - mMainPanel.setVisibility(View.INVISIBLE); - mOverflowPanel.setAlpha(1); - mOverflowPanel.setVisibility(View.VISIBLE); - mOverflowButton.setImageDrawable(mArrow); - mOverflowButton.setContentDescription(mContext.getString( - R.string.floating_toolbar_close_overflow_description)); - - // Update x-coordinates depending on RTL state. - if (isInRTLMode()) { - mContentContainer.setX(mMarginHorizontal); // align left - mMainPanel.setX(0); // align left - mOverflowButton.setX( // align right - containerSize.getWidth() - mOverflowButtonSize.getWidth()); - mOverflowPanel.setX(0); // align left - } else { - mContentContainer.setX( // align right - mPopupWindow.getWidth() - - containerSize.getWidth() - mMarginHorizontal); - mMainPanel.setX(-mContentContainer.getX()); // align right - mOverflowButton.setX(0); // align left - mOverflowPanel.setX(0); // align left - } - - // Update y-coordinates depending on overflow's open direction. - if (mOpenOverflowUpwards) { - mContentContainer.setY(mMarginVertical); // align top - mMainPanel.setY( // align bottom - containerSize.getHeight() - mContentContainer.getHeight()); - mOverflowButton.setY( // align bottom - containerSize.getHeight() - mOverflowButtonSize.getHeight()); - mOverflowPanel.setY(0); // align top - } else { - // opens downwards. - mContentContainer.setY(mMarginVertical); // align top - mMainPanel.setY(0); // align top - mOverflowButton.setY(0); // align top - mOverflowPanel.setY(mOverflowButtonSize.getHeight()); // align bottom - } - } else { - // Overflow not open. Set closed state. - final Size containerSize = mMainPanelSize; - setSize(mContentContainer, containerSize); - mMainPanel.setAlpha(1); - mMainPanel.setVisibility(View.VISIBLE); - mOverflowPanel.setAlpha(0); - mOverflowPanel.setVisibility(View.INVISIBLE); - mOverflowButton.setImageDrawable(mOverflow); - mOverflowButton.setContentDescription(mContext.getString( - R.string.floating_toolbar_open_overflow_description)); - - if (hasOverflow()) { - // Update x-coordinates depending on RTL state. - if (isInRTLMode()) { - mContentContainer.setX(mMarginHorizontal); // align left - mMainPanel.setX(0); // align left - mOverflowButton.setX(0); // align left - mOverflowPanel.setX(0); // align left - } else { - mContentContainer.setX( // align right - mPopupWindow.getWidth() - - containerSize.getWidth() - mMarginHorizontal); - mMainPanel.setX(0); // align left - mOverflowButton.setX( // align right - containerSize.getWidth() - mOverflowButtonSize.getWidth()); - mOverflowPanel.setX( // align right - containerSize.getWidth() - mOverflowPanelSize.getWidth()); - } - - // Update y-coordinates depending on overflow's open direction. - if (mOpenOverflowUpwards) { - mContentContainer.setY( // align bottom - mMarginVertical + - mOverflowPanelSize.getHeight() - containerSize.getHeight()); - mMainPanel.setY(0); // align top - mOverflowButton.setY(0); // align top - mOverflowPanel.setY( // align bottom - containerSize.getHeight() - mOverflowPanelSize.getHeight()); - } else { - // opens downwards. - mContentContainer.setY(mMarginVertical); // align top - mMainPanel.setY(0); // align top - mOverflowButton.setY(0); // align top - mOverflowPanel.setY(mOverflowButtonSize.getHeight()); // align bottom - } - } else { - // No overflow. - mContentContainer.setX(mMarginHorizontal); // align left - mContentContainer.setY(mMarginVertical); // align top - mMainPanel.setX(0); // align left - mMainPanel.setY(0); // align top - } - } - } - - private void updateOverflowHeight(int suggestedHeight) { - if (hasOverflow()) { - final int maxItemSize = (suggestedHeight - mOverflowButtonSize.getHeight()) / - mLineHeight; - final int newHeight = calculateOverflowHeight(maxItemSize); - if (mOverflowPanelSize.getHeight() != newHeight) { - mOverflowPanelSize = new Size(mOverflowPanelSize.getWidth(), newHeight); - } - setSize(mOverflowPanel, mOverflowPanelSize); - if (mIsOverflowOpen) { - setSize(mContentContainer, mOverflowPanelSize); - if (mOpenOverflowUpwards) { - final int deltaHeight = mOverflowPanelSize.getHeight() - newHeight; - mContentContainer.setY(mContentContainer.getY() + deltaHeight); - mOverflowButton.setY(mOverflowButton.getY() - deltaHeight); - } - } else { - setSize(mContentContainer, mMainPanelSize); - } - updatePopupSize(); - } - } - - private void updatePopupSize() { - int width = 0; - int height = 0; - if (mMainPanelSize != null) { - width = Math.max(width, mMainPanelSize.getWidth()); - height = Math.max(height, mMainPanelSize.getHeight()); - } - if (mOverflowPanelSize != null) { - width = Math.max(width, mOverflowPanelSize.getWidth()); - height = Math.max(height, mOverflowPanelSize.getHeight()); - } - mPopupWindow.setWidth(width + mMarginHorizontal * 2); - mPopupWindow.setHeight(height + mMarginVertical * 2); - maybeComputeTransitionDurationScale(); - } - - private void refreshViewPort() { - mParent.getWindowVisibleDisplayFrame(mViewPortOnScreen); - } - - private int getAdjustedToolbarWidth(int suggestedWidth) { - int width = suggestedWidth; - refreshViewPort(); - int maximumWidth = mViewPortOnScreen.width() - 2 * mParent.getResources() - .getDimensionPixelSize(R.dimen.floating_toolbar_horizontal_margin); - if (width <= 0) { - width = mParent.getResources() - .getDimensionPixelSize(R.dimen.floating_toolbar_preferred_width); - } - return Math.min(width, maximumWidth); - } - - /** - * Sets the touchable region of this popup to be zero. This means that all touch events on - * this popup will go through to the surface behind it. - */ - private void setZeroTouchableSurface() { - mTouchableRegion.setEmpty(); - } - - /** - * Sets the touchable region of this popup to be the area occupied by its content. - */ - private void setContentAreaAsTouchableSurface() { - Objects.requireNonNull(mMainPanelSize); - final int width; - final int height; - if (mIsOverflowOpen) { - Objects.requireNonNull(mOverflowPanelSize); - width = mOverflowPanelSize.getWidth(); - height = mOverflowPanelSize.getHeight(); - } else { - width = mMainPanelSize.getWidth(); - height = mMainPanelSize.getHeight(); - } - mTouchableRegion.set( - (int) mContentContainer.getX(), - (int) mContentContainer.getY(), - (int) mContentContainer.getX() + width, - (int) mContentContainer.getY() + height); - } - - /** - * Make the touchable area of this popup be the area specified by mTouchableRegion. - * This should be called after the popup window has been dismissed (dismiss/hide) - * and is probably being re-shown with a new content root view. - */ - private void setTouchableSurfaceInsetsComputer() { - ViewTreeObserver viewTreeObserver = mPopupWindow.getContentView() - .getRootView() - .getViewTreeObserver(); - viewTreeObserver.removeOnComputeInternalInsetsListener(mInsetsComputer); - viewTreeObserver.addOnComputeInternalInsetsListener(mInsetsComputer); - } - - private boolean isInRTLMode() { - return mContext.getApplicationInfo().hasRtlSupport() - && mContext.getResources().getConfiguration().getLayoutDirection() - == View.LAYOUT_DIRECTION_RTL; - } - - private boolean hasOverflow() { - return mOverflowPanelSize != null; - } - - /** - * Fits as many menu items in the main panel and returns a list of the menu items that - * were not fit in. - * - * @return The menu items that are not included in this main panel. - */ - public List layoutMainPanelItems( - List menuItems, final int toolbarWidth) { - Objects.requireNonNull(menuItems); - - int availableWidth = toolbarWidth; - - final LinkedList remainingMenuItems = new LinkedList<>(); - // add the overflow menu items to the end of the remainingMenuItems list. - final LinkedList overflowMenuItems = new LinkedList(); - for (MenuItem menuItem : menuItems) { - if (menuItem.getItemId() != android.R.id.textAssist - && menuItem.requiresOverflow()) { - overflowMenuItems.add(menuItem); - } else { - remainingMenuItems.add(menuItem); - } - } - remainingMenuItems.addAll(overflowMenuItems); - - mMainPanel.removeAllViews(); - mMainPanel.setPaddingRelative(0, 0, 0, 0); - - int lastGroupId = -1; - boolean isFirstItem = true; - while (!remainingMenuItems.isEmpty()) { - final MenuItem menuItem = remainingMenuItems.peek(); - - // if this is the first item, regardless of requiresOverflow(), it should be - // displayed on the main panel. Otherwise all items including this one will be - // overflow items, and should be displayed in overflow panel. - if(!isFirstItem && menuItem.requiresOverflow()) { - break; - } - - final boolean showIcon = isFirstItem && menuItem.getItemId() == R.id.textAssist; - final View menuItemButton = createMenuItemButton( - mContext, menuItem, mIconTextSpacing, showIcon); - if (!showIcon && menuItemButton instanceof LinearLayout) { - ((LinearLayout) menuItemButton).setGravity(Gravity.CENTER); - } - - // Adding additional start padding for the first button to even out button spacing. - if (isFirstItem) { - menuItemButton.setPaddingRelative( - (int) (1.5 * menuItemButton.getPaddingStart()), - menuItemButton.getPaddingTop(), - menuItemButton.getPaddingEnd(), - menuItemButton.getPaddingBottom()); - } - - // Adding additional end padding for the last button to even out button spacing. - boolean isLastItem = remainingMenuItems.size() == 1; - if (isLastItem) { - menuItemButton.setPaddingRelative( - menuItemButton.getPaddingStart(), - menuItemButton.getPaddingTop(), - (int) (1.5 * menuItemButton.getPaddingEnd()), - menuItemButton.getPaddingBottom()); - } - - menuItemButton.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); - final int menuItemButtonWidth = Math.min( - menuItemButton.getMeasuredWidth(), toolbarWidth); - - // Check if we can fit an item while reserving space for the overflowButton. - final boolean canFitWithOverflow = - menuItemButtonWidth <= - availableWidth - mOverflowButtonSize.getWidth(); - final boolean canFitNoOverflow = - isLastItem && menuItemButtonWidth <= availableWidth; - if (canFitWithOverflow || canFitNoOverflow) { - setButtonTagAndClickListener(menuItemButton, menuItem); - // Set tooltips for main panel items, but not overflow items (b/35726766). - menuItemButton.setTooltipText(menuItem.getTooltipText()); - mMainPanel.addView(menuItemButton); - final ViewGroup.LayoutParams params = menuItemButton.getLayoutParams(); - params.width = menuItemButtonWidth; - menuItemButton.setLayoutParams(params); - availableWidth -= menuItemButtonWidth; - remainingMenuItems.pop(); - } else { - break; - } - lastGroupId = menuItem.getGroupId(); - isFirstItem = false; - } - - if (!remainingMenuItems.isEmpty()) { - // Reserve space for overflowButton. - mMainPanel.setPaddingRelative(0, 0, mOverflowButtonSize.getWidth(), 0); - } - - mMainPanelSize = measure(mMainPanel); - return remainingMenuItems; - } - - private void layoutOverflowPanelItems(List menuItems) { - ArrayAdapter overflowPanelAdapter = - (ArrayAdapter) mOverflowPanel.getAdapter(); - overflowPanelAdapter.clear(); - final int size = menuItems.size(); - for (int i = 0; i < size; i++) { - overflowPanelAdapter.add(menuItems.get(i)); - } - mOverflowPanel.setAdapter(overflowPanelAdapter); - if (mOpenOverflowUpwards) { - mOverflowPanel.setY(0); - } else { - mOverflowPanel.setY(mOverflowButtonSize.getHeight()); - } - - int width = Math.max(getOverflowWidth(), mOverflowButtonSize.getWidth()); - int height = calculateOverflowHeight(MAX_OVERFLOW_SIZE); - mOverflowPanelSize = new Size(width, height); - setSize(mOverflowPanel, mOverflowPanelSize); - } - - /** - * Resets the content container and appropriately position it's panels. - */ - private void preparePopupContent() { - mContentContainer.removeAllViews(); - - // Add views in the specified order so they stack up as expected. - // Order: overflowPanel, mainPanel, overflowButton. - if (hasOverflow()) { - mContentContainer.addView(mOverflowPanel); - } - mContentContainer.addView(mMainPanel); - if (hasOverflow()) { - mContentContainer.addView(mOverflowButton); - } - setPanelsStatesAtRestingPosition(); - setContentAreaAsTouchableSurface(); - - // The positioning of contents in RTL is wrong when the view is first rendered. - // Hide the view and post a runnable to recalculate positions and render the view. - // TODO: Investigate why this happens and fix. - if (isInRTLMode()) { - mContentContainer.setAlpha(0); - mContentContainer.post(mPreparePopupContentRTLHelper); - } - } - - /** - * Clears out the panels and their container. Resets their calculated sizes. - */ - private void clearPanels() { - mOverflowPanelSize = null; - mMainPanelSize = null; - mIsOverflowOpen = false; - mMainPanel.removeAllViews(); - ArrayAdapter overflowPanelAdapter = - (ArrayAdapter) mOverflowPanel.getAdapter(); - overflowPanelAdapter.clear(); - mOverflowPanel.setAdapter(overflowPanelAdapter); - mContentContainer.removeAllViews(); - } - - private void positionContentYCoordinatesIfOpeningOverflowUpwards() { - if (mOpenOverflowUpwards) { - mMainPanel.setY(mContentContainer.getHeight() - mMainPanelSize.getHeight()); - mOverflowButton.setY(mContentContainer.getHeight() - mOverflowButton.getHeight()); - mOverflowPanel.setY(mContentContainer.getHeight() - mOverflowPanelSize.getHeight()); - } - } - - private int getOverflowWidth() { - int overflowWidth = 0; - final int count = mOverflowPanel.getAdapter().getCount(); - for (int i = 0; i < count; i++) { - MenuItem menuItem = (MenuItem) mOverflowPanel.getAdapter().getItem(i); - overflowWidth = - Math.max(mOverflowPanelViewHelper.calculateWidth(menuItem), overflowWidth); - } - return overflowWidth; - } - - private int calculateOverflowHeight(int maxItemSize) { - // Maximum of 4 items, minimum of 2 if the overflow has to scroll. - int actualSize = Math.min( - MAX_OVERFLOW_SIZE, - Math.min( - Math.max(MIN_OVERFLOW_SIZE, maxItemSize), - mOverflowPanel.getCount())); - int extension = 0; - if (actualSize < mOverflowPanel.getCount()) { - // The overflow will require scrolling to get to all the items. - // Extend the height so that part of the hidden items is displayed. - extension = (int) (mLineHeight * 0.5f); - } - return actualSize * mLineHeight - + mOverflowButtonSize.getHeight() - + extension; - } - - private void setButtonTagAndClickListener(View menuItemButton, MenuItem menuItem) { - menuItemButton.setTag(MenuItemRepr.of(menuItem)); - menuItemButton.setOnClickListener(mMenuItemButtonOnClickListener); - } - - /** - * NOTE: Use only in android.view.animation.* animations. Do not use in android.animation.* - * animations. See comment about this in the code. - */ - private int getAdjustedDuration(int originalDuration) { - if (mTransitionDurationScale < 150) { - // For smaller transition, decrease the time. - return Math.max(originalDuration - 50, 0); - } else if (mTransitionDurationScale > 300) { - // For bigger transition, increase the time. - return originalDuration + 50; - } - - // Scale the animation duration with getDurationScale(). This allows - // android.view.animation.* animations to scale just like android.animation.* animations - // when animator duration scale is adjusted in "Developer Options". - // For this reason, do not use this method for android.animation.* animations. - return (int) (originalDuration * ValueAnimator.getDurationScale()); - } - - private void maybeComputeTransitionDurationScale() { - if (mMainPanelSize != null && mOverflowPanelSize != null) { - int w = mMainPanelSize.getWidth() - mOverflowPanelSize.getWidth(); - int h = mOverflowPanelSize.getHeight() - mMainPanelSize.getHeight(); - mTransitionDurationScale = (int) (Math.sqrt(w * w + h * h) / - mContentContainer.getContext().getResources().getDisplayMetrics().density); - } - } - - private ViewGroup createMainPanel() { - ViewGroup mainPanel = new LinearLayout(mContext) { - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - if (isOverflowAnimating()) { - // Update widthMeasureSpec to make sure that this view is not clipped - // as we offset it's coordinates with respect to it's parent. - widthMeasureSpec = MeasureSpec.makeMeasureSpec( - mMainPanelSize.getWidth(), - MeasureSpec.EXACTLY); - } - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - } - - @Override - public boolean onInterceptTouchEvent(MotionEvent ev) { - // Intercept the touch event while the overflow is animating. - return isOverflowAnimating(); - } - }; - return mainPanel; - } - - private ImageButton createOverflowButton() { - final ImageButton overflowButton = (ImageButton) LayoutInflater.from(mContext) - .inflate(R.layout.floating_popup_overflow_button, null); - overflowButton.setImageDrawable(mOverflow); - overflowButton.setOnClickListener(v -> { - if (mIsOverflowOpen) { - overflowButton.setImageDrawable(mToOverflow); - mToOverflow.start(); - closeOverflow(); - } else { - overflowButton.setImageDrawable(mToArrow); - mToArrow.start(); - openOverflow(); - } - }); - return overflowButton; - } - - private OverflowPanel createOverflowPanel() { - final OverflowPanel overflowPanel = new OverflowPanel(this); - overflowPanel.setLayoutParams(new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); - overflowPanel.setDivider(null); - overflowPanel.setDividerHeight(0); - - final ArrayAdapter adapter = - new ArrayAdapter(mContext, 0) { - @Override - public View getView(int position, View convertView, ViewGroup parent) { - return mOverflowPanelViewHelper.getView( - getItem(position), mOverflowPanelSize.getWidth(), convertView); - } - }; - overflowPanel.setAdapter(adapter); - - overflowPanel.setOnItemClickListener((parent, view, position, id) -> { - MenuItem menuItem = (MenuItem) overflowPanel.getAdapter().getItem(position); - if (mOnMenuItemClickListener != null) { - mOnMenuItemClickListener.onMenuItemClick(menuItem); - } - }); - - return overflowPanel; - } - - private boolean isOverflowAnimating() { - final boolean overflowOpening = mOpenOverflowAnimation.hasStarted() - && !mOpenOverflowAnimation.hasEnded(); - final boolean overflowClosing = mCloseOverflowAnimation.hasStarted() - && !mCloseOverflowAnimation.hasEnded(); - return overflowOpening || overflowClosing; - } - - private Animation.AnimationListener createOverflowAnimationListener() { - Animation.AnimationListener listener = new Animation.AnimationListener() { - @Override - public void onAnimationStart(Animation animation) { - // Disable the overflow button while it's animating. - // It will be re-enabled when the animation stops. - mOverflowButton.setEnabled(false); - // Ensure both panels have visibility turned on when the overflow animation - // starts. - mMainPanel.setVisibility(View.VISIBLE); - mOverflowPanel.setVisibility(View.VISIBLE); - } - - @Override - public void onAnimationEnd(Animation animation) { - // Posting this because it seems like this is called before the animation - // actually ends. - mContentContainer.post(() -> { - setPanelsStatesAtRestingPosition(); - setContentAreaAsTouchableSurface(); - }); - } - - @Override - public void onAnimationRepeat(Animation animation) { - } - }; - return listener; - } - - private static Size measure(View view) { - Preconditions.checkState(view.getParent() == null); - view.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); - return new Size(view.getMeasuredWidth(), view.getMeasuredHeight()); - } - - private static void setSize(View view, int width, int height) { - view.setMinimumWidth(width); - view.setMinimumHeight(height); - ViewGroup.LayoutParams params = view.getLayoutParams(); - params = (params == null) ? new ViewGroup.LayoutParams(0, 0) : params; - params.width = width; - params.height = height; - view.setLayoutParams(params); - } - - private static void setSize(View view, Size size) { - setSize(view, size.getWidth(), size.getHeight()); - } - - private static void setWidth(View view, int width) { - ViewGroup.LayoutParams params = view.getLayoutParams(); - setSize(view, width, params.height); - } - - private static void setHeight(View view, int height) { - ViewGroup.LayoutParams params = view.getLayoutParams(); - setSize(view, params.width, height); - } - - /** - * A custom ListView for the overflow panel. - */ - private static final class OverflowPanel extends ListView { - - private final FloatingToolbarPopup mPopup; - - OverflowPanel(FloatingToolbarPopup popup) { - super(Objects.requireNonNull(popup).mContext); - this.mPopup = popup; - setScrollBarDefaultDelayBeforeFade(ViewConfiguration.getScrollDefaultDelay() * 3); - setScrollIndicators(View.SCROLL_INDICATOR_TOP | View.SCROLL_INDICATOR_BOTTOM); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - // Update heightMeasureSpec to make sure that this view is not clipped - // as we offset it's coordinates with respect to it's parent. - int height = mPopup.mOverflowPanelSize.getHeight() - - mPopup.mOverflowButtonSize.getHeight(); - heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY); - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - } - - @Override - public boolean dispatchTouchEvent(MotionEvent ev) { - if (mPopup.isOverflowAnimating()) { - // Eat the touch event. - return true; - } - return super.dispatchTouchEvent(ev); - } - - @Override - protected boolean awakenScrollBars() { - return super.awakenScrollBars(); - } - } - - /** - * A custom interpolator used for various floating toolbar animations. - */ - private static final class LogAccelerateInterpolator implements Interpolator { - - private static final int BASE = 100; - private static final float LOGS_SCALE = 1f / computeLog(1, BASE); - - private static float computeLog(float t, int base) { - return (float) (1 - Math.pow(base, -t)); - } - - @Override - public float getInterpolation(float t) { - return 1 - computeLog(1 - t, BASE) * LOGS_SCALE; - } - } - - /** - * A helper for generating views for the overflow panel. - */ - private static final class OverflowPanelViewHelper { - - private final View mCalculator; - private final int mIconTextSpacing; - private final int mSidePadding; - - private final Context mContext; - - public OverflowPanelViewHelper(Context context, int iconTextSpacing) { - mContext = Objects.requireNonNull(context); - mIconTextSpacing = iconTextSpacing; - mSidePadding = context.getResources() - .getDimensionPixelSize(R.dimen.floating_toolbar_overflow_side_padding); - mCalculator = createMenuButton(null); - } - - public View getView(MenuItem menuItem, int minimumWidth, View convertView) { - Objects.requireNonNull(menuItem); - if (convertView != null) { - updateMenuItemButton( - convertView, menuItem, mIconTextSpacing, shouldShowIcon(menuItem)); - } else { - convertView = createMenuButton(menuItem); - } - convertView.setMinimumWidth(minimumWidth); - return convertView; - } - - public int calculateWidth(MenuItem menuItem) { - updateMenuItemButton( - mCalculator, menuItem, mIconTextSpacing, shouldShowIcon(menuItem)); - mCalculator.measure( - View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); - return mCalculator.getMeasuredWidth(); - } - - private View createMenuButton(MenuItem menuItem) { - View button = createMenuItemButton( - mContext, menuItem, mIconTextSpacing, shouldShowIcon(menuItem)); - button.setPadding(mSidePadding, 0, mSidePadding, 0); - return button; - } - - private boolean shouldShowIcon(MenuItem menuItem) { - if (menuItem != null) { - return menuItem.getGroupId() == android.R.id.textAssist; - } - return false; - } - } - } - - /** - * Represents the identity of a MenuItem that is rendered in a FloatingToolbarPopup. - */ - @VisibleForTesting - public static final class MenuItemRepr { - - public final int itemId; - public final int groupId; - @Nullable public final String title; - @Nullable private final Drawable mIcon; - - private MenuItemRepr( - int itemId, int groupId, @Nullable CharSequence title, @Nullable Drawable icon) { - this.itemId = itemId; - this.groupId = groupId; - this.title = (title == null) ? null : title.toString(); - mIcon = icon; - } - - /** - * Creates an instance of MenuItemRepr for the specified menu item. - */ - public static MenuItemRepr of(MenuItem menuItem) { - return new MenuItemRepr( - menuItem.getItemId(), - menuItem.getGroupId(), - menuItem.getTitle(), - menuItem.getIcon()); - } - - /** - * Returns this object's hashcode. - */ - @Override - public int hashCode() { - return Objects.hash(itemId, groupId, title, mIcon); - } - - /** - * Returns true if this object is the same as the specified object. - */ - @Override - public boolean equals(Object o) { - if (o == this) { - return true; - } - if (!(o instanceof MenuItemRepr)) { - return false; - } - final MenuItemRepr other = (MenuItemRepr) o; - return itemId == other.itemId - && groupId == other.groupId - && TextUtils.equals(title, other.title) - // Many Drawables (icons) do not implement equals(). Using equals() here instead - // of reference comparisons in case a Drawable subclass implements equals(). - && Objects.equals(mIcon, other.mIcon); - } - - /** - * Returns true if the two menu item collections are the same based on MenuItemRepr. - */ - public static boolean reprEquals( - Collection menuItems1, Collection menuItems2) { - if (menuItems1.size() != menuItems2.size()) { - return false; - } - - final Iterator menuItems2Iter = menuItems2.iterator(); - for (MenuItem menuItem1 : menuItems1) { - final MenuItem menuItem2 = menuItems2Iter.next(); - if (!MenuItemRepr.of(menuItem1).equals(MenuItemRepr.of(menuItem2))) { - return false; - } - } - - return true; - } - } - - /** - * Creates and returns a menu button for the specified menu item. - */ - private static View createMenuItemButton( - Context context, MenuItem menuItem, int iconTextSpacing, boolean showIcon) { - final View menuItemButton = LayoutInflater.from(context) - .inflate(R.layout.floating_popup_menu_button, null); - if (menuItem != null) { - updateMenuItemButton(menuItemButton, menuItem, iconTextSpacing, showIcon); - } - return menuItemButton; - } - - /** - * Updates the specified menu item button with the specified menu item data. - */ - private static void updateMenuItemButton( - View menuItemButton, MenuItem menuItem, int iconTextSpacing, boolean showIcon) { - final TextView buttonText = menuItemButton.findViewById( - R.id.floating_toolbar_menu_item_text); - buttonText.setEllipsize(null); - if (TextUtils.isEmpty(menuItem.getTitle())) { - buttonText.setVisibility(View.GONE); - } else { - buttonText.setVisibility(View.VISIBLE); - buttonText.setText(menuItem.getTitle()); - } - final ImageView buttonIcon = menuItemButton.findViewById( - R.id.floating_toolbar_menu_item_image); - if (menuItem.getIcon() == null || !showIcon) { - buttonIcon.setVisibility(View.GONE); - if (buttonText != null) { - buttonText.setPaddingRelative(0, 0, 0, 0); - } - } else { - buttonIcon.setVisibility(View.VISIBLE); - buttonIcon.setImageDrawable(menuItem.getIcon()); - if (buttonText != null) { - buttonText.setPaddingRelative(iconTextSpacing, 0, 0, 0); - } - } - final CharSequence contentDescription = menuItem.getContentDescription(); - if (TextUtils.isEmpty(contentDescription)) { - menuItemButton.setContentDescription(menuItem.getTitle()); - } else { - menuItemButton.setContentDescription(contentDescription); - } - } - - private static ViewGroup createContentContainer(Context context) { - ViewGroup contentContainer = (ViewGroup) LayoutInflater.from(context) - .inflate(R.layout.floating_popup_container, null); - contentContainer.setLayoutParams(new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - contentContainer.setTag(FLOATING_TOOLBAR_TAG); - contentContainer.setClipToOutline(true); - return contentContainer; - } - - private static PopupWindow createPopupWindow(ViewGroup content) { - ViewGroup popupContentHolder = new LinearLayout(content.getContext()); - PopupWindow popupWindow = new PopupWindow(popupContentHolder); - // TODO: Use .setIsLaidOutInScreen(true) instead of .setClippingEnabled(false) - // unless FLAG_LAYOUT_IN_SCREEN has any unintentional side-effects. - popupWindow.setClippingEnabled(false); - popupWindow.setWindowLayoutType( - WindowManager.LayoutParams.TYPE_APPLICATION_ABOVE_SUB_PANEL); - popupWindow.setAnimationStyle(0); - popupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - content.setLayoutParams(new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - popupContentHolder.addView(content); - return popupWindow; - } - - /** - * Creates an "appear" animation for the specified view. - * - * @param view The view to animate - */ - private static AnimatorSet createEnterAnimation(View view) { - AnimatorSet animation = new AnimatorSet(); - animation.playTogether( - ObjectAnimator.ofFloat(view, View.ALPHA, 0, 1).setDuration(150)); - return animation; - } - - /** - * Creates a "disappear" animation for the specified view. - * - * @param view The view to animate - * @param startDelay The start delay of the animation - * @param listener The animation listener - */ - private static AnimatorSet createExitAnimation( - View view, int startDelay, Animator.AnimatorListener listener) { - AnimatorSet animation = new AnimatorSet(); - animation.playTogether( - ObjectAnimator.ofFloat(view, View.ALPHA, 1, 0).setDuration(100)); - animation.setStartDelay(startDelay); - animation.addListener(listener); - return animation; - } - - /** - * Returns a re-themed context with controlled look and feel for views. - */ - private static Context applyDefaultTheme(Context originalContext) { - TypedArray a = originalContext.obtainStyledAttributes(new int[]{R.attr.isLightTheme}); - boolean isLightTheme = a.getBoolean(0, true); - int themeId - = isLightTheme ? R.style.Theme_DeviceDefault_Light : R.style.Theme_DeviceDefault; - a.recycle(); - return new ContextThemeWrapper(originalContext, themeId); - } } diff --git a/core/java/com/android/internal/widget/FloatingToolbarPopup.java b/core/java/com/android/internal/widget/FloatingToolbarPopup.java new file mode 100644 index 0000000000000..e0388f6f3cc0f --- /dev/null +++ b/core/java/com/android/internal/widget/FloatingToolbarPopup.java @@ -0,0 +1,1610 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.internal.widget; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.annotation.Nullable; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.Region; +import android.graphics.drawable.AnimatedVectorDrawable; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.util.Size; +import android.view.ContextThemeWrapper; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.WindowManager; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.AnimationUtils; +import android.view.animation.Interpolator; +import android.view.animation.Transformation; +import android.widget.ArrayAdapter; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.PopupWindow; +import android.widget.TextView; + +import com.android.internal.R; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.util.Preconditions; + +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * A popup window used by the floating toolbar. + * + * This class is responsible for the rendering/animation of the floating toolbar. + * It holds 2 panels (i.e. main panel and overflow panel) and an overflow button + * to transition between panels. + */ +public final class FloatingToolbarPopup { + + /* Minimum and maximum number of items allowed in the overflow. */ + private static final int MIN_OVERFLOW_SIZE = 2; + private static final int MAX_OVERFLOW_SIZE = 4; + + private final Context mContext; + private final View mParent; // Parent for the popup window. + private final PopupWindow mPopupWindow; + + /* Margins between the popup window and its content. */ + private final int mMarginHorizontal; + private final int mMarginVertical; + + /* View components */ + private final ViewGroup mContentContainer; // holds all contents. + private final ViewGroup mMainPanel; // holds menu items that are initially displayed. + // holds menu items hidden in the overflow. + private final FloatingToolbarPopup.OverflowPanel mOverflowPanel; + private final ImageButton mOverflowButton; // opens/closes the overflow. + /* overflow button drawables. */ + private final Drawable mArrow; + private final Drawable mOverflow; + private final AnimatedVectorDrawable mToArrow; + private final AnimatedVectorDrawable mToOverflow; + + private final FloatingToolbarPopup.OverflowPanelViewHelper + mOverflowPanelViewHelper; + + /* Animation interpolators. */ + private final Interpolator mLogAccelerateInterpolator; + private final Interpolator mFastOutSlowInInterpolator; + private final Interpolator mLinearOutSlowInInterpolator; + private final Interpolator mFastOutLinearInInterpolator; + + /* Animations. */ + private final AnimatorSet mShowAnimation; + private final AnimatorSet mDismissAnimation; + private final AnimatorSet mHideAnimation; + private final AnimationSet mOpenOverflowAnimation; + private final AnimationSet mCloseOverflowAnimation; + private final Animation.AnimationListener mOverflowAnimationListener; + + private final Rect mViewPortOnScreen = new Rect(); // portion of screen we can draw in. + private final Point mCoordsOnWindow = new Point(); // popup window coordinates. + /* Temporary data holders. Reset values before using. */ + private final int[] mTmpCoords = new int[2]; + + private final Region mTouchableRegion = new Region(); + private final ViewTreeObserver.OnComputeInternalInsetsListener mInsetsComputer = + info -> { + info.contentInsets.setEmpty(); + info.visibleInsets.setEmpty(); + info.touchableRegion.set(mTouchableRegion); + info.setTouchableInsets( + ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION); + }; + + private final int mLineHeight; + private final int mIconTextSpacing; + + /** + * @see FloatingToolbarPopup.OverflowPanelViewHelper#preparePopupContent(). + */ + private final Runnable mPreparePopupContentRTLHelper = new Runnable() { + @Override + public void run() { + setPanelsStatesAtRestingPosition(); + setContentAreaAsTouchableSurface(); + mContentContainer.setAlpha(1); + } + }; + + private boolean mDismissed = true; // tracks whether this popup is dismissed or dismissing. + private boolean mHidden; // tracks whether this popup is hidden or hiding. + + /* Calculated sizes for panels and overflow button. */ + private final Size mOverflowButtonSize; + private Size mOverflowPanelSize; // Should be null when there is no overflow. + private Size mMainPanelSize; + + /* Menu items and click listeners */ + private final Map mMenuItems = new LinkedHashMap<>(); + private MenuItem.OnMenuItemClickListener mOnMenuItemClickListener; + private final View.OnClickListener mMenuItemButtonOnClickListener = + new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mOnMenuItemClickListener == null) { + return; + } + final Object tag = v.getTag(); + if (!(tag instanceof MenuItemRepr)) { + return; + } + final MenuItem menuItem = mMenuItems.get((MenuItemRepr) tag); + if (menuItem == null) { + return; + } + mOnMenuItemClickListener.onMenuItemClick(menuItem); + } + }; + + private boolean mOpenOverflowUpwards; // Whether the overflow opens upwards or downwards. + private boolean mIsOverflowOpen; + + private int mTransitionDurationScale; // Used to scale the toolbar transition duration. + + /** + * Initializes a new floating toolbar popup. + * + * @param parent A parent view to get the {@link android.view.View#getWindowToken()} token + * from. + */ + public FloatingToolbarPopup(Context context, View parent) { + mParent = Objects.requireNonNull(parent); + mContext = applyDefaultTheme(context); + mContentContainer = createContentContainer(context); + mPopupWindow = createPopupWindow(mContentContainer); + mMarginHorizontal = parent.getResources() + .getDimensionPixelSize(R.dimen.floating_toolbar_horizontal_margin); + mMarginVertical = parent.getResources() + .getDimensionPixelSize(R.dimen.floating_toolbar_vertical_margin); + mLineHeight = context.getResources() + .getDimensionPixelSize(R.dimen.floating_toolbar_height); + mIconTextSpacing = context.getResources() + .getDimensionPixelSize(R.dimen.floating_toolbar_icon_text_spacing); + + // Interpolators + mLogAccelerateInterpolator = new FloatingToolbarPopup.LogAccelerateInterpolator(); + mFastOutSlowInInterpolator = AnimationUtils.loadInterpolator( + mContext, android.R.interpolator.fast_out_slow_in); + mLinearOutSlowInInterpolator = AnimationUtils.loadInterpolator( + mContext, android.R.interpolator.linear_out_slow_in); + mFastOutLinearInInterpolator = AnimationUtils.loadInterpolator( + mContext, android.R.interpolator.fast_out_linear_in); + + // Drawables. Needed for views. + mArrow = mContext.getResources() + .getDrawable(R.drawable.ft_avd_tooverflow, mContext.getTheme()); + mArrow.setAutoMirrored(true); + mOverflow = mContext.getResources() + .getDrawable(R.drawable.ft_avd_toarrow, mContext.getTheme()); + mOverflow.setAutoMirrored(true); + mToArrow = (AnimatedVectorDrawable) mContext.getResources() + .getDrawable(R.drawable.ft_avd_toarrow_animation, mContext.getTheme()); + mToArrow.setAutoMirrored(true); + mToOverflow = (AnimatedVectorDrawable) mContext.getResources() + .getDrawable(R.drawable.ft_avd_tooverflow_animation, mContext.getTheme()); + mToOverflow.setAutoMirrored(true); + + // Views + mOverflowButton = createOverflowButton(); + mOverflowButtonSize = measure(mOverflowButton); + mMainPanel = createMainPanel(); + mOverflowPanelViewHelper = + new FloatingToolbarPopup.OverflowPanelViewHelper(mContext, mIconTextSpacing); + mOverflowPanel = createOverflowPanel(); + + // Animation. Need views. + mOverflowAnimationListener = createOverflowAnimationListener(); + mOpenOverflowAnimation = new AnimationSet(true); + mOpenOverflowAnimation.setAnimationListener(mOverflowAnimationListener); + mCloseOverflowAnimation = new AnimationSet(true); + mCloseOverflowAnimation.setAnimationListener(mOverflowAnimationListener); + mShowAnimation = createEnterAnimation(mContentContainer); + mDismissAnimation = createExitAnimation( + mContentContainer, + 150, // startDelay + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + mPopupWindow.dismiss(); + mContentContainer.removeAllViews(); + } + }); + mHideAnimation = createExitAnimation( + mContentContainer, + 0, // startDelay + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + mPopupWindow.dismiss(); + } + }); + } + + /** + * Makes this toolbar "outside touchable" and sets the onDismissListener. + * + * @param outsideTouchable if true, the popup will be made "outside touchable" and + * "non focusable". The reverse will happen if false. + * @param onDismiss + * + * @return true if the "outsideTouchable" setting was modified. Otherwise returns false + * + * @see PopupWindow#setOutsideTouchable(boolean) + * @see PopupWindow#setFocusable(boolean) + * @see PopupWindow.OnDismissListener + */ + public boolean setOutsideTouchable( + boolean outsideTouchable, @Nullable PopupWindow.OnDismissListener onDismiss) { + boolean ret = false; + if (mPopupWindow.isOutsideTouchable() ^ outsideTouchable) { + mPopupWindow.setOutsideTouchable(outsideTouchable); + mPopupWindow.setFocusable(!outsideTouchable); + mPopupWindow.update(); + ret = true; + } + mPopupWindow.setOnDismissListener(onDismiss); + return ret; + } + + /** + * Lays out buttons for the specified menu items. + * Requires a subsequent call to {@link FloatingToolbar#show()} to show the items. + */ + public void layoutMenuItems( + List menuItems, + MenuItem.OnMenuItemClickListener menuItemClickListener, + int suggestedWidth) { + cancelOverflowAnimations(); + clearPanels(); + updateMenuItems(menuItems, menuItemClickListener); + menuItems = layoutMainPanelItems(menuItems, getAdjustedToolbarWidth(suggestedWidth)); + if (!menuItems.isEmpty()) { + // Add remaining items to the overflow. + layoutOverflowPanelItems(menuItems); + } + updatePopupSize(); + } + + /** + * Updates the popup's menu items without rebuilding the widget. + * Use in place of layoutMenuItems() when the popup's views need not be reconstructed. + * + * @see #isLayoutRequired(List) + */ + public void updateMenuItems( + List menuItems, MenuItem.OnMenuItemClickListener menuItemClickListener) { + mMenuItems.clear(); + for (MenuItem menuItem : menuItems) { + mMenuItems.put(MenuItemRepr.of(menuItem), menuItem); + } + mOnMenuItemClickListener = menuItemClickListener; + } + + /** + * Returns true if this popup needs a relayout to properly render the specified menu items. + */ + public boolean isLayoutRequired(List menuItems) { + return !MenuItemRepr.reprEquals(menuItems, mMenuItems.values()); + } + + /** + * Shows this popup at the specified coordinates. + * The specified coordinates may be adjusted to make sure the popup is entirely on-screen. + */ + public void show(Rect contentRectOnScreen) { + Objects.requireNonNull(contentRectOnScreen); + + if (isShowing()) { + return; + } + + mHidden = false; + mDismissed = false; + cancelDismissAndHideAnimations(); + cancelOverflowAnimations(); + + refreshCoordinatesAndOverflowDirection(contentRectOnScreen); + preparePopupContent(); + // We need to specify the position in window coordinates. + // TODO: Consider to use PopupWindow.setIsLaidOutInScreen(true) so that we can + // specify the popup position in screen coordinates. + mPopupWindow.showAtLocation( + mParent, Gravity.NO_GRAVITY, mCoordsOnWindow.x, mCoordsOnWindow.y); + setTouchableSurfaceInsetsComputer(); + runShowAnimation(); + } + + /** + * Gets rid of this popup. If the popup isn't currently showing, this will be a no-op. + */ + public void dismiss() { + if (mDismissed) { + return; + } + + mHidden = false; + mDismissed = true; + mHideAnimation.cancel(); + + runDismissAnimation(); + setZeroTouchableSurface(); + } + + /** + * Hides this popup. This is a no-op if this popup is not showing. + * Use {@link #isHidden()} to distinguish between a hidden and a dismissed popup. + */ + public void hide() { + if (!isShowing()) { + return; + } + + mHidden = true; + runHideAnimation(); + setZeroTouchableSurface(); + } + + /** + * Returns {@code true} if this popup is currently showing. {@code false} otherwise. + */ + public boolean isShowing() { + return !mDismissed && !mHidden; + } + + /** + * Returns {@code true} if this popup is currently hidden. {@code false} otherwise. + */ + public boolean isHidden() { + return mHidden; + } + + /** + * Updates the coordinates of this popup. + * The specified coordinates may be adjusted to make sure the popup is entirely on-screen. + * This is a no-op if this popup is not showing. + */ + public void updateCoordinates(Rect contentRectOnScreen) { + Objects.requireNonNull(contentRectOnScreen); + + if (!isShowing() || !mPopupWindow.isShowing()) { + return; + } + + cancelOverflowAnimations(); + refreshCoordinatesAndOverflowDirection(contentRectOnScreen); + preparePopupContent(); + // We need to specify the position in window coordinates. + // TODO: Consider to use PopupWindow.setIsLaidOutInScreen(true) so that we can + // specify the popup position in screen coordinates. + mPopupWindow.update( + mCoordsOnWindow.x, mCoordsOnWindow.y, + mPopupWindow.getWidth(), mPopupWindow.getHeight()); + } + + private void refreshCoordinatesAndOverflowDirection(Rect contentRectOnScreen) { + refreshViewPort(); + + // Initialize x ensuring that the toolbar isn't rendered behind the nav bar in + // landscape. + final int x = Math.min( + contentRectOnScreen.centerX() - mPopupWindow.getWidth() / 2, + mViewPortOnScreen.right - mPopupWindow.getWidth()); + + final int y; + + final int availableHeightAboveContent = + contentRectOnScreen.top - mViewPortOnScreen.top; + final int availableHeightBelowContent = + mViewPortOnScreen.bottom - contentRectOnScreen.bottom; + + final int margin = 2 * mMarginVertical; + final int toolbarHeightWithVerticalMargin = mLineHeight + margin; + + if (!hasOverflow()) { + if (availableHeightAboveContent >= toolbarHeightWithVerticalMargin) { + // There is enough space at the top of the content. + y = contentRectOnScreen.top - toolbarHeightWithVerticalMargin; + } else if (availableHeightBelowContent >= toolbarHeightWithVerticalMargin) { + // There is enough space at the bottom of the content. + y = contentRectOnScreen.bottom; + } else if (availableHeightBelowContent >= mLineHeight) { + // Just enough space to fit the toolbar with no vertical margins. + y = contentRectOnScreen.bottom - mMarginVertical; + } else { + // Not enough space. Prefer to position as high as possible. + y = Math.max( + mViewPortOnScreen.top, + contentRectOnScreen.top - toolbarHeightWithVerticalMargin); + } + } else { + // Has an overflow. + final int minimumOverflowHeightWithMargin = + calculateOverflowHeight(MIN_OVERFLOW_SIZE) + margin; + final int availableHeightThroughContentDown = + mViewPortOnScreen.bottom - contentRectOnScreen.top + + toolbarHeightWithVerticalMargin; + final int availableHeightThroughContentUp = + contentRectOnScreen.bottom - mViewPortOnScreen.top + + toolbarHeightWithVerticalMargin; + + if (availableHeightAboveContent >= minimumOverflowHeightWithMargin) { + // There is enough space at the top of the content rect for the overflow. + // Position above and open upwards. + updateOverflowHeight(availableHeightAboveContent - margin); + y = contentRectOnScreen.top - mPopupWindow.getHeight(); + mOpenOverflowUpwards = true; + } else if (availableHeightAboveContent >= toolbarHeightWithVerticalMargin + && availableHeightThroughContentDown >= minimumOverflowHeightWithMargin) { + // There is enough space at the top of the content rect for the main panel + // but not the overflow. + // Position above but open downwards. + updateOverflowHeight(availableHeightThroughContentDown - margin); + y = contentRectOnScreen.top - toolbarHeightWithVerticalMargin; + mOpenOverflowUpwards = false; + } else if (availableHeightBelowContent >= minimumOverflowHeightWithMargin) { + // There is enough space at the bottom of the content rect for the overflow. + // Position below and open downwards. + updateOverflowHeight(availableHeightBelowContent - margin); + y = contentRectOnScreen.bottom; + mOpenOverflowUpwards = false; + } else if (availableHeightBelowContent >= toolbarHeightWithVerticalMargin + && mViewPortOnScreen.height() >= minimumOverflowHeightWithMargin) { + // There is enough space at the bottom of the content rect for the main panel + // but not the overflow. + // Position below but open upwards. + updateOverflowHeight(availableHeightThroughContentUp - margin); + y = contentRectOnScreen.bottom + toolbarHeightWithVerticalMargin + - mPopupWindow.getHeight(); + mOpenOverflowUpwards = true; + } else { + // Not enough space. + // Position at the top of the view port and open downwards. + updateOverflowHeight(mViewPortOnScreen.height() - margin); + y = mViewPortOnScreen.top; + mOpenOverflowUpwards = false; + } + } + + // We later specify the location of PopupWindow relative to the attached window. + // The idea here is that 1) we can get the location of a View in both window coordinates + // and screen coordinates, where the offset between them should be equal to the window + // origin, and 2) we can use an arbitrary for this calculation while calculating the + // location of the rootview is supposed to be least expensive. + // TODO: Consider to use PopupWindow.setIsLaidOutInScreen(true) so that we can avoid + // the following calculation. + mParent.getRootView().getLocationOnScreen(mTmpCoords); + int rootViewLeftOnScreen = mTmpCoords[0]; + int rootViewTopOnScreen = mTmpCoords[1]; + mParent.getRootView().getLocationInWindow(mTmpCoords); + int rootViewLeftOnWindow = mTmpCoords[0]; + int rootViewTopOnWindow = mTmpCoords[1]; + int windowLeftOnScreen = rootViewLeftOnScreen - rootViewLeftOnWindow; + int windowTopOnScreen = rootViewTopOnScreen - rootViewTopOnWindow; + mCoordsOnWindow.set( + Math.max(0, x - windowLeftOnScreen), Math.max(0, y - windowTopOnScreen)); + } + + /** + * Performs the "show" animation on the floating popup. + */ + private void runShowAnimation() { + mShowAnimation.start(); + } + + /** + * Performs the "dismiss" animation on the floating popup. + */ + private void runDismissAnimation() { + mDismissAnimation.start(); + } + + /** + * Performs the "hide" animation on the floating popup. + */ + private void runHideAnimation() { + mHideAnimation.start(); + } + + private void cancelDismissAndHideAnimations() { + mDismissAnimation.cancel(); + mHideAnimation.cancel(); + } + + private void cancelOverflowAnimations() { + mContentContainer.clearAnimation(); + mMainPanel.animate().cancel(); + mOverflowPanel.animate().cancel(); + mToArrow.stop(); + mToOverflow.stop(); + } + + private void openOverflow() { + final int targetWidth = mOverflowPanelSize.getWidth(); + final int targetHeight = mOverflowPanelSize.getHeight(); + final int startWidth = mContentContainer.getWidth(); + final int startHeight = mContentContainer.getHeight(); + final float startY = mContentContainer.getY(); + final float left = mContentContainer.getX(); + final float right = left + mContentContainer.getWidth(); + Animation widthAnimation = new Animation() { + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + int deltaWidth = (int) (interpolatedTime * (targetWidth - startWidth)); + setWidth(mContentContainer, startWidth + deltaWidth); + if (isInRTLMode()) { + mContentContainer.setX(left); + + // Lock the panels in place. + mMainPanel.setX(0); + mOverflowPanel.setX(0); + } else { + mContentContainer.setX(right - mContentContainer.getWidth()); + + // Offset the panels' positions so they look like they're locked in place + // on the screen. + mMainPanel.setX(mContentContainer.getWidth() - startWidth); + mOverflowPanel.setX(mContentContainer.getWidth() - targetWidth); + } + } + }; + Animation heightAnimation = new Animation() { + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + int deltaHeight = (int) (interpolatedTime * (targetHeight - startHeight)); + setHeight(mContentContainer, startHeight + deltaHeight); + if (mOpenOverflowUpwards) { + mContentContainer.setY( + startY - (mContentContainer.getHeight() - startHeight)); + positionContentYCoordinatesIfOpeningOverflowUpwards(); + } + } + }; + final float overflowButtonStartX = mOverflowButton.getX(); + final float overflowButtonTargetX = + isInRTLMode() ? overflowButtonStartX + targetWidth - mOverflowButton.getWidth() + : overflowButtonStartX - targetWidth + mOverflowButton.getWidth(); + Animation overflowButtonAnimation = new Animation() { + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + float overflowButtonX = overflowButtonStartX + + interpolatedTime * (overflowButtonTargetX - overflowButtonStartX); + float deltaContainerWidth = + isInRTLMode() ? 0 : mContentContainer.getWidth() - startWidth; + float actualOverflowButtonX = overflowButtonX + deltaContainerWidth; + mOverflowButton.setX(actualOverflowButtonX); + } + }; + widthAnimation.setInterpolator(mLogAccelerateInterpolator); + widthAnimation.setDuration(getAdjustedDuration(250)); + heightAnimation.setInterpolator(mFastOutSlowInInterpolator); + heightAnimation.setDuration(getAdjustedDuration(250)); + overflowButtonAnimation.setInterpolator(mFastOutSlowInInterpolator); + overflowButtonAnimation.setDuration(getAdjustedDuration(250)); + mOpenOverflowAnimation.getAnimations().clear(); + mOpenOverflowAnimation.getAnimations().clear(); + mOpenOverflowAnimation.addAnimation(widthAnimation); + mOpenOverflowAnimation.addAnimation(heightAnimation); + mOpenOverflowAnimation.addAnimation(overflowButtonAnimation); + mContentContainer.startAnimation(mOpenOverflowAnimation); + mIsOverflowOpen = true; + mMainPanel.animate() + .alpha(0).withLayer() + .setInterpolator(mLinearOutSlowInInterpolator) + .setDuration(250) + .start(); + mOverflowPanel.setAlpha(1); // fadeIn in 0ms. + } + + private void closeOverflow() { + final int targetWidth = mMainPanelSize.getWidth(); + final int startWidth = mContentContainer.getWidth(); + final float left = mContentContainer.getX(); + final float right = left + mContentContainer.getWidth(); + Animation widthAnimation = new Animation() { + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + int deltaWidth = (int) (interpolatedTime * (targetWidth - startWidth)); + setWidth(mContentContainer, startWidth + deltaWidth); + if (isInRTLMode()) { + mContentContainer.setX(left); + + // Lock the panels in place. + mMainPanel.setX(0); + mOverflowPanel.setX(0); + } else { + mContentContainer.setX(right - mContentContainer.getWidth()); + + // Offset the panels' positions so they look like they're locked in place + // on the screen. + mMainPanel.setX(mContentContainer.getWidth() - targetWidth); + mOverflowPanel.setX(mContentContainer.getWidth() - startWidth); + } + } + }; + final int targetHeight = mMainPanelSize.getHeight(); + final int startHeight = mContentContainer.getHeight(); + final float bottom = mContentContainer.getY() + mContentContainer.getHeight(); + Animation heightAnimation = new Animation() { + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + int deltaHeight = (int) (interpolatedTime * (targetHeight - startHeight)); + setHeight(mContentContainer, startHeight + deltaHeight); + if (mOpenOverflowUpwards) { + mContentContainer.setY(bottom - mContentContainer.getHeight()); + positionContentYCoordinatesIfOpeningOverflowUpwards(); + } + } + }; + final float overflowButtonStartX = mOverflowButton.getX(); + final float overflowButtonTargetX = + isInRTLMode() ? overflowButtonStartX - startWidth + mOverflowButton.getWidth() + : overflowButtonStartX + startWidth - mOverflowButton.getWidth(); + Animation overflowButtonAnimation = new Animation() { + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + float overflowButtonX = overflowButtonStartX + + interpolatedTime * (overflowButtonTargetX - overflowButtonStartX); + float deltaContainerWidth = + isInRTLMode() ? 0 : mContentContainer.getWidth() - startWidth; + float actualOverflowButtonX = overflowButtonX + deltaContainerWidth; + mOverflowButton.setX(actualOverflowButtonX); + } + }; + widthAnimation.setInterpolator(mFastOutSlowInInterpolator); + widthAnimation.setDuration(getAdjustedDuration(250)); + heightAnimation.setInterpolator(mLogAccelerateInterpolator); + heightAnimation.setDuration(getAdjustedDuration(250)); + overflowButtonAnimation.setInterpolator(mFastOutSlowInInterpolator); + overflowButtonAnimation.setDuration(getAdjustedDuration(250)); + mCloseOverflowAnimation.getAnimations().clear(); + mCloseOverflowAnimation.addAnimation(widthAnimation); + mCloseOverflowAnimation.addAnimation(heightAnimation); + mCloseOverflowAnimation.addAnimation(overflowButtonAnimation); + mContentContainer.startAnimation(mCloseOverflowAnimation); + mIsOverflowOpen = false; + mMainPanel.animate() + .alpha(1).withLayer() + .setInterpolator(mFastOutLinearInInterpolator) + .setDuration(100) + .start(); + mOverflowPanel.animate() + .alpha(0).withLayer() + .setInterpolator(mLinearOutSlowInInterpolator) + .setDuration(150) + .start(); + } + + /** + * Defines the position of the floating toolbar popup panels when transition animation has + * stopped. + */ + private void setPanelsStatesAtRestingPosition() { + mOverflowButton.setEnabled(true); + mOverflowPanel.awakenScrollBars(); + + if (mIsOverflowOpen) { + // Set open state. + final Size containerSize = mOverflowPanelSize; + setSize(mContentContainer, containerSize); + mMainPanel.setAlpha(0); + mMainPanel.setVisibility(View.INVISIBLE); + mOverflowPanel.setAlpha(1); + mOverflowPanel.setVisibility(View.VISIBLE); + mOverflowButton.setImageDrawable(mArrow); + mOverflowButton.setContentDescription(mContext.getString( + R.string.floating_toolbar_close_overflow_description)); + + // Update x-coordinates depending on RTL state. + if (isInRTLMode()) { + mContentContainer.setX(mMarginHorizontal); // align left + mMainPanel.setX(0); // align left + mOverflowButton.setX(// align right + containerSize.getWidth() - mOverflowButtonSize.getWidth()); + mOverflowPanel.setX(0); // align left + } else { + mContentContainer.setX(// align right + mPopupWindow.getWidth() - containerSize.getWidth() - mMarginHorizontal); + mMainPanel.setX(-mContentContainer.getX()); // align right + mOverflowButton.setX(0); // align left + mOverflowPanel.setX(0); // align left + } + + // Update y-coordinates depending on overflow's open direction. + if (mOpenOverflowUpwards) { + mContentContainer.setY(mMarginVertical); // align top + mMainPanel.setY(// align bottom + containerSize.getHeight() - mContentContainer.getHeight()); + mOverflowButton.setY(// align bottom + containerSize.getHeight() - mOverflowButtonSize.getHeight()); + mOverflowPanel.setY(0); // align top + } else { + // opens downwards. + mContentContainer.setY(mMarginVertical); // align top + mMainPanel.setY(0); // align top + mOverflowButton.setY(0); // align top + mOverflowPanel.setY(mOverflowButtonSize.getHeight()); // align bottom + } + } else { + // Overflow not open. Set closed state. + final Size containerSize = mMainPanelSize; + setSize(mContentContainer, containerSize); + mMainPanel.setAlpha(1); + mMainPanel.setVisibility(View.VISIBLE); + mOverflowPanel.setAlpha(0); + mOverflowPanel.setVisibility(View.INVISIBLE); + mOverflowButton.setImageDrawable(mOverflow); + mOverflowButton.setContentDescription(mContext.getString( + R.string.floating_toolbar_open_overflow_description)); + + if (hasOverflow()) { + // Update x-coordinates depending on RTL state. + if (isInRTLMode()) { + mContentContainer.setX(mMarginHorizontal); // align left + mMainPanel.setX(0); // align left + mOverflowButton.setX(0); // align left + mOverflowPanel.setX(0); // align left + } else { + mContentContainer.setX(// align right + mPopupWindow.getWidth() - containerSize.getWidth() - mMarginHorizontal); + mMainPanel.setX(0); // align left + mOverflowButton.setX(// align right + containerSize.getWidth() - mOverflowButtonSize.getWidth()); + mOverflowPanel.setX(// align right + containerSize.getWidth() - mOverflowPanelSize.getWidth()); + } + + // Update y-coordinates depending on overflow's open direction. + if (mOpenOverflowUpwards) { + mContentContainer.setY(// align bottom + mMarginVertical + mOverflowPanelSize.getHeight() + - containerSize.getHeight()); + mMainPanel.setY(0); // align top + mOverflowButton.setY(0); // align top + mOverflowPanel.setY(// align bottom + containerSize.getHeight() - mOverflowPanelSize.getHeight()); + } else { + // opens downwards. + mContentContainer.setY(mMarginVertical); // align top + mMainPanel.setY(0); // align top + mOverflowButton.setY(0); // align top + mOverflowPanel.setY(mOverflowButtonSize.getHeight()); // align bottom + } + } else { + // No overflow. + mContentContainer.setX(mMarginHorizontal); // align left + mContentContainer.setY(mMarginVertical); // align top + mMainPanel.setX(0); // align left + mMainPanel.setY(0); // align top + } + } + } + + private void updateOverflowHeight(int suggestedHeight) { + if (hasOverflow()) { + final int maxItemSize = + (suggestedHeight - mOverflowButtonSize.getHeight()) / mLineHeight; + final int newHeight = calculateOverflowHeight(maxItemSize); + if (mOverflowPanelSize.getHeight() != newHeight) { + mOverflowPanelSize = new Size(mOverflowPanelSize.getWidth(), newHeight); + } + setSize(mOverflowPanel, mOverflowPanelSize); + if (mIsOverflowOpen) { + setSize(mContentContainer, mOverflowPanelSize); + if (mOpenOverflowUpwards) { + final int deltaHeight = mOverflowPanelSize.getHeight() - newHeight; + mContentContainer.setY(mContentContainer.getY() + deltaHeight); + mOverflowButton.setY(mOverflowButton.getY() - deltaHeight); + } + } else { + setSize(mContentContainer, mMainPanelSize); + } + updatePopupSize(); + } + } + + private void updatePopupSize() { + int width = 0; + int height = 0; + if (mMainPanelSize != null) { + width = Math.max(width, mMainPanelSize.getWidth()); + height = Math.max(height, mMainPanelSize.getHeight()); + } + if (mOverflowPanelSize != null) { + width = Math.max(width, mOverflowPanelSize.getWidth()); + height = Math.max(height, mOverflowPanelSize.getHeight()); + } + mPopupWindow.setWidth(width + mMarginHorizontal * 2); + mPopupWindow.setHeight(height + mMarginVertical * 2); + maybeComputeTransitionDurationScale(); + } + + private void refreshViewPort() { + mParent.getWindowVisibleDisplayFrame(mViewPortOnScreen); + } + + private int getAdjustedToolbarWidth(int suggestedWidth) { + int width = suggestedWidth; + refreshViewPort(); + int maximumWidth = mViewPortOnScreen.width() - 2 * mParent.getResources() + .getDimensionPixelSize(R.dimen.floating_toolbar_horizontal_margin); + if (width <= 0) { + width = mParent.getResources() + .getDimensionPixelSize(R.dimen.floating_toolbar_preferred_width); + } + return Math.min(width, maximumWidth); + } + + /** + * Sets the touchable region of this popup to be zero. This means that all touch events on + * this popup will go through to the surface behind it. + */ + private void setZeroTouchableSurface() { + mTouchableRegion.setEmpty(); + } + + /** + * Sets the touchable region of this popup to be the area occupied by its content. + */ + private void setContentAreaAsTouchableSurface() { + Objects.requireNonNull(mMainPanelSize); + final int width; + final int height; + if (mIsOverflowOpen) { + Objects.requireNonNull(mOverflowPanelSize); + width = mOverflowPanelSize.getWidth(); + height = mOverflowPanelSize.getHeight(); + } else { + width = mMainPanelSize.getWidth(); + height = mMainPanelSize.getHeight(); + } + mTouchableRegion.set( + (int) mContentContainer.getX(), + (int) mContentContainer.getY(), + (int) mContentContainer.getX() + width, + (int) mContentContainer.getY() + height); + } + + /** + * Make the touchable area of this popup be the area specified by mTouchableRegion. + * This should be called after the popup window has been dismissed (dismiss/hide) + * and is probably being re-shown with a new content root view. + */ + private void setTouchableSurfaceInsetsComputer() { + ViewTreeObserver viewTreeObserver = mPopupWindow.getContentView() + .getRootView() + .getViewTreeObserver(); + viewTreeObserver.removeOnComputeInternalInsetsListener(mInsetsComputer); + viewTreeObserver.addOnComputeInternalInsetsListener(mInsetsComputer); + } + + private boolean isInRTLMode() { + return mContext.getApplicationInfo().hasRtlSupport() + && mContext.getResources().getConfiguration().getLayoutDirection() + == View.LAYOUT_DIRECTION_RTL; + } + + private boolean hasOverflow() { + return mOverflowPanelSize != null; + } + + /** + * Fits as many menu items in the main panel and returns a list of the menu items that + * were not fit in. + * + * @return The menu items that are not included in this main panel. + */ + public List layoutMainPanelItems( + List menuItems, final int toolbarWidth) { + Objects.requireNonNull(menuItems); + + int availableWidth = toolbarWidth; + + final LinkedList remainingMenuItems = new LinkedList<>(); + // add the overflow menu items to the end of the remainingMenuItems list. + final LinkedList overflowMenuItems = new LinkedList(); + for (MenuItem menuItem : menuItems) { + if (menuItem.getItemId() != android.R.id.textAssist + && menuItem.requiresOverflow()) { + overflowMenuItems.add(menuItem); + } else { + remainingMenuItems.add(menuItem); + } + } + remainingMenuItems.addAll(overflowMenuItems); + + mMainPanel.removeAllViews(); + mMainPanel.setPaddingRelative(0, 0, 0, 0); + + int lastGroupId = -1; + boolean isFirstItem = true; + while (!remainingMenuItems.isEmpty()) { + final MenuItem menuItem = remainingMenuItems.peek(); + + // if this is the first item, regardless of requiresOverflow(), it should be + // displayed on the main panel. Otherwise all items including this one will be + // overflow items, and should be displayed in overflow panel. + if (!isFirstItem && menuItem.requiresOverflow()) { + break; + } + + final boolean showIcon = isFirstItem && menuItem.getItemId() == R.id.textAssist; + final View menuItemButton = createMenuItemButton( + mContext, menuItem, mIconTextSpacing, showIcon); + if (!showIcon && menuItemButton instanceof LinearLayout) { + ((LinearLayout) menuItemButton).setGravity(Gravity.CENTER); + } + + // Adding additional start padding for the first button to even out button spacing. + if (isFirstItem) { + menuItemButton.setPaddingRelative( + (int) (1.5 * menuItemButton.getPaddingStart()), + menuItemButton.getPaddingTop(), + menuItemButton.getPaddingEnd(), + menuItemButton.getPaddingBottom()); + } + + // Adding additional end padding for the last button to even out button spacing. + boolean isLastItem = remainingMenuItems.size() == 1; + if (isLastItem) { + menuItemButton.setPaddingRelative( + menuItemButton.getPaddingStart(), + menuItemButton.getPaddingTop(), + (int) (1.5 * menuItemButton.getPaddingEnd()), + menuItemButton.getPaddingBottom()); + } + + menuItemButton.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); + final int menuItemButtonWidth = Math.min( + menuItemButton.getMeasuredWidth(), toolbarWidth); + + // Check if we can fit an item while reserving space for the overflowButton. + final boolean canFitWithOverflow = + menuItemButtonWidth <= availableWidth - mOverflowButtonSize.getWidth(); + final boolean canFitNoOverflow = + isLastItem && menuItemButtonWidth <= availableWidth; + if (canFitWithOverflow || canFitNoOverflow) { + setButtonTagAndClickListener(menuItemButton, menuItem); + // Set tooltips for main panel items, but not overflow items (b/35726766). + menuItemButton.setTooltipText(menuItem.getTooltipText()); + mMainPanel.addView(menuItemButton); + final ViewGroup.LayoutParams params = menuItemButton.getLayoutParams(); + params.width = menuItemButtonWidth; + menuItemButton.setLayoutParams(params); + availableWidth -= menuItemButtonWidth; + remainingMenuItems.pop(); + } else { + break; + } + lastGroupId = menuItem.getGroupId(); + isFirstItem = false; + } + + if (!remainingMenuItems.isEmpty()) { + // Reserve space for overflowButton. + mMainPanel.setPaddingRelative(0, 0, mOverflowButtonSize.getWidth(), 0); + } + + mMainPanelSize = measure(mMainPanel); + return remainingMenuItems; + } + + private void layoutOverflowPanelItems(List menuItems) { + ArrayAdapter overflowPanelAdapter = + (ArrayAdapter) mOverflowPanel.getAdapter(); + overflowPanelAdapter.clear(); + final int size = menuItems.size(); + for (int i = 0; i < size; i++) { + overflowPanelAdapter.add(menuItems.get(i)); + } + mOverflowPanel.setAdapter(overflowPanelAdapter); + if (mOpenOverflowUpwards) { + mOverflowPanel.setY(0); + } else { + mOverflowPanel.setY(mOverflowButtonSize.getHeight()); + } + + int width = Math.max(getOverflowWidth(), mOverflowButtonSize.getWidth()); + int height = calculateOverflowHeight(MAX_OVERFLOW_SIZE); + mOverflowPanelSize = new Size(width, height); + setSize(mOverflowPanel, mOverflowPanelSize); + } + + /** + * Resets the content container and appropriately position it's panels. + */ + private void preparePopupContent() { + mContentContainer.removeAllViews(); + + // Add views in the specified order so they stack up as expected. + // Order: overflowPanel, mainPanel, overflowButton. + if (hasOverflow()) { + mContentContainer.addView(mOverflowPanel); + } + mContentContainer.addView(mMainPanel); + if (hasOverflow()) { + mContentContainer.addView(mOverflowButton); + } + setPanelsStatesAtRestingPosition(); + setContentAreaAsTouchableSurface(); + + // The positioning of contents in RTL is wrong when the view is first rendered. + // Hide the view and post a runnable to recalculate positions and render the view. + // TODO: Investigate why this happens and fix. + if (isInRTLMode()) { + mContentContainer.setAlpha(0); + mContentContainer.post(mPreparePopupContentRTLHelper); + } + } + + /** + * Clears out the panels and their container. Resets their calculated sizes. + */ + private void clearPanels() { + mOverflowPanelSize = null; + mMainPanelSize = null; + mIsOverflowOpen = false; + mMainPanel.removeAllViews(); + ArrayAdapter overflowPanelAdapter = + (ArrayAdapter) mOverflowPanel.getAdapter(); + overflowPanelAdapter.clear(); + mOverflowPanel.setAdapter(overflowPanelAdapter); + mContentContainer.removeAllViews(); + } + + private void positionContentYCoordinatesIfOpeningOverflowUpwards() { + if (mOpenOverflowUpwards) { + mMainPanel.setY(mContentContainer.getHeight() - mMainPanelSize.getHeight()); + mOverflowButton.setY(mContentContainer.getHeight() - mOverflowButton.getHeight()); + mOverflowPanel.setY(mContentContainer.getHeight() - mOverflowPanelSize.getHeight()); + } + } + + private int getOverflowWidth() { + int overflowWidth = 0; + final int count = mOverflowPanel.getAdapter().getCount(); + for (int i = 0; i < count; i++) { + MenuItem menuItem = (MenuItem) mOverflowPanel.getAdapter().getItem(i); + overflowWidth = + Math.max(mOverflowPanelViewHelper.calculateWidth(menuItem), overflowWidth); + } + return overflowWidth; + } + + private int calculateOverflowHeight(int maxItemSize) { + // Maximum of 4 items, minimum of 2 if the overflow has to scroll. + int actualSize = Math.min( + MAX_OVERFLOW_SIZE, + Math.min( + Math.max(MIN_OVERFLOW_SIZE, maxItemSize), + mOverflowPanel.getCount())); + int extension = 0; + if (actualSize < mOverflowPanel.getCount()) { + // The overflow will require scrolling to get to all the items. + // Extend the height so that part of the hidden items is displayed. + extension = (int) (mLineHeight * 0.5f); + } + return actualSize * mLineHeight + + mOverflowButtonSize.getHeight() + + extension; + } + + private void setButtonTagAndClickListener(View menuItemButton, MenuItem menuItem) { + menuItemButton.setTag(MenuItemRepr.of(menuItem)); + menuItemButton.setOnClickListener(mMenuItemButtonOnClickListener); + } + + /** + * NOTE: Use only in android.view.animation.* animations. Do not use in android.animation.* + * animations. See comment about this in the code. + */ + private int getAdjustedDuration(int originalDuration) { + if (mTransitionDurationScale < 150) { + // For smaller transition, decrease the time. + return Math.max(originalDuration - 50, 0); + } else if (mTransitionDurationScale > 300) { + // For bigger transition, increase the time. + return originalDuration + 50; + } + + // Scale the animation duration with getDurationScale(). This allows + // android.view.animation.* animations to scale just like android.animation.* animations + // when animator duration scale is adjusted in "Developer Options". + // For this reason, do not use this method for android.animation.* animations. + return (int) (originalDuration * ValueAnimator.getDurationScale()); + } + + private void maybeComputeTransitionDurationScale() { + if (mMainPanelSize != null && mOverflowPanelSize != null) { + int w = mMainPanelSize.getWidth() - mOverflowPanelSize.getWidth(); + int h = mOverflowPanelSize.getHeight() - mMainPanelSize.getHeight(); + mTransitionDurationScale = (int) (Math.sqrt(w * w + h * h) + / mContentContainer.getContext().getResources().getDisplayMetrics().density); + } + } + + private ViewGroup createMainPanel() { + ViewGroup mainPanel = new LinearLayout(mContext) { + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + if (isOverflowAnimating()) { + // Update widthMeasureSpec to make sure that this view is not clipped + // as we offset its coordinates with respect to its parent. + widthMeasureSpec = MeasureSpec.makeMeasureSpec( + mMainPanelSize.getWidth(), + MeasureSpec.EXACTLY); + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + // Intercept the touch event while the overflow is animating. + return isOverflowAnimating(); + } + }; + return mainPanel; + } + + private ImageButton createOverflowButton() { + final ImageButton overflowButton = (ImageButton) LayoutInflater.from(mContext) + .inflate(R.layout.floating_popup_overflow_button, null); + overflowButton.setImageDrawable(mOverflow); + overflowButton.setOnClickListener(v -> { + if (mIsOverflowOpen) { + overflowButton.setImageDrawable(mToOverflow); + mToOverflow.start(); + closeOverflow(); + } else { + overflowButton.setImageDrawable(mToArrow); + mToArrow.start(); + openOverflow(); + } + }); + return overflowButton; + } + + private FloatingToolbarPopup.OverflowPanel createOverflowPanel() { + final FloatingToolbarPopup.OverflowPanel + overflowPanel = new FloatingToolbarPopup.OverflowPanel(this); + overflowPanel.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + overflowPanel.setDivider(null); + overflowPanel.setDividerHeight(0); + + final ArrayAdapter adapter = + new ArrayAdapter(mContext, 0) { + @Override + public View getView(int position, View convertView, ViewGroup parent) { + return mOverflowPanelViewHelper.getView( + getItem(position), mOverflowPanelSize.getWidth(), convertView); + } + }; + overflowPanel.setAdapter(adapter); + + overflowPanel.setOnItemClickListener((parent, view, position, id) -> { + MenuItem menuItem = (MenuItem) overflowPanel.getAdapter().getItem(position); + if (mOnMenuItemClickListener != null) { + mOnMenuItemClickListener.onMenuItemClick(menuItem); + } + }); + + return overflowPanel; + } + + private boolean isOverflowAnimating() { + final boolean overflowOpening = mOpenOverflowAnimation.hasStarted() + && !mOpenOverflowAnimation.hasEnded(); + final boolean overflowClosing = mCloseOverflowAnimation.hasStarted() + && !mCloseOverflowAnimation.hasEnded(); + return overflowOpening || overflowClosing; + } + + private Animation.AnimationListener createOverflowAnimationListener() { + Animation.AnimationListener listener = new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { + // Disable the overflow button while it's animating. + // It will be re-enabled when the animation stops. + mOverflowButton.setEnabled(false); + // Ensure both panels have visibility turned on when the overflow animation + // starts. + mMainPanel.setVisibility(View.VISIBLE); + mOverflowPanel.setVisibility(View.VISIBLE); + } + + @Override + public void onAnimationEnd(Animation animation) { + // Posting this because it seems like this is called before the animation + // actually ends. + mContentContainer.post(() -> { + setPanelsStatesAtRestingPosition(); + setContentAreaAsTouchableSurface(); + }); + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + }; + return listener; + } + + private static Size measure(View view) { + Preconditions.checkState(view.getParent() == null); + view.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); + return new Size(view.getMeasuredWidth(), view.getMeasuredHeight()); + } + + private static void setSize(View view, int width, int height) { + view.setMinimumWidth(width); + view.setMinimumHeight(height); + ViewGroup.LayoutParams params = view.getLayoutParams(); + params = (params == null) ? new ViewGroup.LayoutParams(0, 0) : params; + params.width = width; + params.height = height; + view.setLayoutParams(params); + } + + private static void setSize(View view, Size size) { + setSize(view, size.getWidth(), size.getHeight()); + } + + private static void setWidth(View view, int width) { + ViewGroup.LayoutParams params = view.getLayoutParams(); + setSize(view, width, params.height); + } + + private static void setHeight(View view, int height) { + ViewGroup.LayoutParams params = view.getLayoutParams(); + setSize(view, params.width, height); + } + + /** + * A custom ListView for the overflow panel. + */ + private static final class OverflowPanel extends ListView { + + private final FloatingToolbarPopup mPopup; + + OverflowPanel(FloatingToolbarPopup popup) { + super(Objects.requireNonNull(popup).mContext); + this.mPopup = popup; + setScrollBarDefaultDelayBeforeFade(ViewConfiguration.getScrollDefaultDelay() * 3); + setScrollIndicators(View.SCROLL_INDICATOR_TOP | View.SCROLL_INDICATOR_BOTTOM); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // Update heightMeasureSpec to make sure that this view is not clipped + // as we offset it's coordinates with respect to its parent. + int height = mPopup.mOverflowPanelSize.getHeight() + - mPopup.mOverflowButtonSize.getHeight(); + heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + if (mPopup.isOverflowAnimating()) { + // Eat the touch event. + return true; + } + return super.dispatchTouchEvent(ev); + } + + @Override + protected boolean awakenScrollBars() { + return super.awakenScrollBars(); + } + } + + /** + * A custom interpolator used for various floating toolbar animations. + */ + private static final class LogAccelerateInterpolator implements Interpolator { + + private static final int BASE = 100; + private static final float LOGS_SCALE = 1f / computeLog(1, BASE); + + private static float computeLog(float t, int base) { + return (float) (1 - Math.pow(base, -t)); + } + + @Override + public float getInterpolation(float t) { + return 1 - computeLog(1 - t, BASE) * LOGS_SCALE; + } + } + + /** + * A helper for generating views for the overflow panel. + */ + private static final class OverflowPanelViewHelper { + + private final View mCalculator; + private final int mIconTextSpacing; + private final int mSidePadding; + + private final Context mContext; + + OverflowPanelViewHelper(Context context, int iconTextSpacing) { + mContext = Objects.requireNonNull(context); + mIconTextSpacing = iconTextSpacing; + mSidePadding = context.getResources() + .getDimensionPixelSize(R.dimen.floating_toolbar_overflow_side_padding); + mCalculator = createMenuButton(null); + } + + public View getView(MenuItem menuItem, int minimumWidth, View convertView) { + Objects.requireNonNull(menuItem); + if (convertView != null) { + updateMenuItemButton( + convertView, menuItem, mIconTextSpacing, shouldShowIcon(menuItem)); + } else { + convertView = createMenuButton(menuItem); + } + convertView.setMinimumWidth(minimumWidth); + return convertView; + } + + public int calculateWidth(MenuItem menuItem) { + updateMenuItemButton( + mCalculator, menuItem, mIconTextSpacing, shouldShowIcon(menuItem)); + mCalculator.measure( + View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); + return mCalculator.getMeasuredWidth(); + } + + private View createMenuButton(MenuItem menuItem) { + View button = createMenuItemButton( + mContext, menuItem, mIconTextSpacing, shouldShowIcon(menuItem)); + button.setPadding(mSidePadding, 0, mSidePadding, 0); + return button; + } + + private boolean shouldShowIcon(MenuItem menuItem) { + if (menuItem != null) { + return menuItem.getGroupId() == android.R.id.textAssist; + } + return false; + } + } + + /** + * Creates and returns a menu button for the specified menu item. + */ + private static View createMenuItemButton( + Context context, MenuItem menuItem, int iconTextSpacing, boolean showIcon) { + final View menuItemButton = LayoutInflater.from(context) + .inflate(R.layout.floating_popup_menu_button, null); + if (menuItem != null) { + updateMenuItemButton(menuItemButton, menuItem, iconTextSpacing, showIcon); + } + return menuItemButton; + } + + /** + * Updates the specified menu item button with the specified menu item data. + */ + private static void updateMenuItemButton( + View menuItemButton, MenuItem menuItem, int iconTextSpacing, boolean showIcon) { + final TextView buttonText = menuItemButton.findViewById( + R.id.floating_toolbar_menu_item_text); + buttonText.setEllipsize(null); + if (TextUtils.isEmpty(menuItem.getTitle())) { + buttonText.setVisibility(View.GONE); + } else { + buttonText.setVisibility(View.VISIBLE); + buttonText.setText(menuItem.getTitle()); + } + final ImageView buttonIcon = menuItemButton.findViewById( + R.id.floating_toolbar_menu_item_image); + if (menuItem.getIcon() == null || !showIcon) { + buttonIcon.setVisibility(View.GONE); + if (buttonText != null) { + buttonText.setPaddingRelative(0, 0, 0, 0); + } + } else { + buttonIcon.setVisibility(View.VISIBLE); + buttonIcon.setImageDrawable(menuItem.getIcon()); + if (buttonText != null) { + buttonText.setPaddingRelative(iconTextSpacing, 0, 0, 0); + } + } + final CharSequence contentDescription = menuItem.getContentDescription(); + if (TextUtils.isEmpty(contentDescription)) { + menuItemButton.setContentDescription(menuItem.getTitle()); + } else { + menuItemButton.setContentDescription(contentDescription); + } + } + + private static ViewGroup createContentContainer(Context context) { + ViewGroup contentContainer = (ViewGroup) LayoutInflater.from(context) + .inflate(R.layout.floating_popup_container, null); + contentContainer.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + contentContainer.setTag(FloatingToolbar.FLOATING_TOOLBAR_TAG); + contentContainer.setClipToOutline(true); + return contentContainer; + } + + private static PopupWindow createPopupWindow(ViewGroup content) { + ViewGroup popupContentHolder = new LinearLayout(content.getContext()); + PopupWindow popupWindow = new PopupWindow(popupContentHolder); + // TODO: Use .setIsLaidOutInScreen(true) instead of .setClippingEnabled(false) + // unless FLAG_LAYOUT_IN_SCREEN has any unintentional side-effects. + popupWindow.setClippingEnabled(false); + popupWindow.setWindowLayoutType( + WindowManager.LayoutParams.TYPE_APPLICATION_ABOVE_SUB_PANEL); + popupWindow.setAnimationStyle(0); + popupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + content.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + popupContentHolder.addView(content); + return popupWindow; + } + + /** + * Creates an "appear" animation for the specified view. + * + * @param view The view to animate + */ + private static AnimatorSet createEnterAnimation(View view) { + AnimatorSet animation = new AnimatorSet(); + animation.playTogether( + ObjectAnimator.ofFloat(view, View.ALPHA, 0, 1).setDuration(150)); + return animation; + } + + /** + * Creates a "disappear" animation for the specified view. + * + * @param view The view to animate + * @param startDelay The start delay of the animation + * @param listener The animation listener + */ + private static AnimatorSet createExitAnimation( + View view, int startDelay, Animator.AnimatorListener listener) { + AnimatorSet animation = new AnimatorSet(); + animation.playTogether( + ObjectAnimator.ofFloat(view, View.ALPHA, 1, 0).setDuration(100)); + animation.setStartDelay(startDelay); + animation.addListener(listener); + return animation; + } + + /** + * Returns a re-themed context with controlled look and feel for views. + */ + private static Context applyDefaultTheme(Context originalContext) { + TypedArray a = originalContext.obtainStyledAttributes(new int[]{R.attr.isLightTheme}); + boolean isLightTheme = a.getBoolean(0, true); + int themeId = + isLightTheme ? R.style.Theme_DeviceDefault_Light : R.style.Theme_DeviceDefault; + a.recycle(); + return new ContextThemeWrapper(originalContext, themeId); + } + + /** + * Represents the identity of a MenuItem that is rendered in a FloatingToolbarPopup. + */ + @VisibleForTesting + public static final class MenuItemRepr { + + public final int itemId; + public final int groupId; + @Nullable public final String title; + @Nullable private final Drawable mIcon; + + private MenuItemRepr( + int itemId, int groupId, @Nullable CharSequence title, @Nullable Drawable icon) { + this.itemId = itemId; + this.groupId = groupId; + this.title = (title == null) ? null : title.toString(); + mIcon = icon; + } + + /** + * Creates an instance of MenuItemRepr for the specified menu item. + */ + public static MenuItemRepr of(MenuItem menuItem) { + return new MenuItemRepr( + menuItem.getItemId(), + menuItem.getGroupId(), + menuItem.getTitle(), + menuItem.getIcon()); + } + + /** + * Returns this object's hashcode. + */ + @Override + public int hashCode() { + return Objects.hash(itemId, groupId, title, mIcon); + } + + /** + * Returns true if this object is the same as the specified object. + */ + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (!(o instanceof MenuItemRepr)) { + return false; + } + final MenuItemRepr other = (MenuItemRepr) o; + return itemId == other.itemId + && groupId == other.groupId + && TextUtils.equals(title, other.title) + // Many Drawables (icons) do not implement equals(). Using equals() here instead + // of reference comparisons in case a Drawable subclass implements equals(). + && Objects.equals(mIcon, other.mIcon); + } + + /** + * Returns true if the two menu item collections are the same based on MenuItemRepr. + */ + public static boolean reprEquals( + Collection menuItems1, Collection menuItems2) { + if (menuItems1.size() != menuItems2.size()) { + return false; + } + + final Iterator menuItems2Iter = menuItems2.iterator(); + for (MenuItem menuItem1 : menuItems1) { + final MenuItem menuItem2 = menuItems2Iter.next(); + if (!MenuItemRepr.of(menuItem1).equals(MenuItemRepr.of(menuItem2))) { + return false; + } + } + + return true; + } + } +} diff --git a/core/tests/coretests/src/android/widget/espresso/FloatingToolbarEspressoUtils.java b/core/tests/coretests/src/android/widget/espresso/FloatingToolbarEspressoUtils.java index 477978630ec2d..9696fdfc462b7 100644 --- a/core/tests/coretests/src/android/widget/espresso/FloatingToolbarEspressoUtils.java +++ b/core/tests/coretests/src/android/widget/espresso/FloatingToolbarEspressoUtils.java @@ -28,7 +28,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withTagValue; import static androidx.test.espresso.matcher.ViewMatchers.withText; -import static com.android.internal.widget.FloatingToolbar.MenuItemRepr; +import static com.android.internal.widget.FloatingToolbarPopup.MenuItemRepr; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.is;