From 25af083ef97d8713895de23b9e104b9e6c681fc4 Mon Sep 17 00:00:00 2001 From: Joanne Chung Date: Wed, 19 Jan 2022 16:29:19 +0800 Subject: [PATCH] Clone LocalFloatingToolbarPopup to RemoteSelectionToolbar This change doesn't change the content of LocalFloatingToolbarPopup, we just rename the file. This helps to review the follow up selction toolbar service implementation change easier. Bug: 190030331 Bug: 205823018 Test: build pass Change-Id: I0a98fe7f2057fd3a04a408ab1acf1b4090515cb9 --- .../RemoteSelectionToolbar.java | 1619 +++++++++++++++++ 1 file changed, 1619 insertions(+) create mode 100644 core/java/android/service/selectiontoolbar/RemoteSelectionToolbar.java diff --git a/core/java/android/service/selectiontoolbar/RemoteSelectionToolbar.java b/core/java/android/service/selectiontoolbar/RemoteSelectionToolbar.java new file mode 100644 index 0000000000000..95ecc4e16446b --- /dev/null +++ b/core/java/android/service/selectiontoolbar/RemoteSelectionToolbar.java @@ -0,0 +1,1619 @@ +/* + * Copyright (C) 2022 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 android.service.selectiontoolbar; + +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 com.android.internal.widget.floatingtoolbar.FloatingToolbarPopup; + +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 to render menu items in the local app process. + * + * 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. + */ + +final class RemoteSelectionToolbar implements 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 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 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. + + private final Rect mPreviousContentRect = new Rect(); + private int mSuggestedWidth; + private boolean mWidthChanged = true; + + /** + * Initializes a new floating toolbar popup. + * + * @param parent A parent view to get the {@link android.view.View#getWindowToken()} token + * from. + */ + RemoteSelectionToolbar(Context context, View parent) { + mParent = Objects.requireNonNull(parent); + mContext = applyDefaultTheme(context); + mContentContainer = createContentContainer(mContext); + 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(); + } + }); + } + + @Override + 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. + */ + private 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) + */ + private 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. + */ + private boolean isLayoutRequired(List menuItems) { + return !MenuItemRepr.reprEquals(menuItems, mMenuItems.values()); + } + + @Override + public void setWidthChanged(boolean widthChanged) { + mWidthChanged = widthChanged; + } + + @Override + public void setSuggestedWidth(int suggestedWidth) { + // Check if there's been a substantial width spec change. + int difference = Math.abs(suggestedWidth - mSuggestedWidth); + mWidthChanged = difference > (mSuggestedWidth * 0.2); + mSuggestedWidth = suggestedWidth; + } + + @Override + public void show(List menuItems, + MenuItem.OnMenuItemClickListener menuItemClickListener, Rect contentRect) { + if (isLayoutRequired(menuItems) || mWidthChanged) { + dismiss(); + layoutMenuItems(menuItems, menuItemClickListener, mSuggestedWidth); + } else { + updateMenuItems(menuItems, menuItemClickListener); + } + if (!isShowing()) { + show(contentRect); + } else if (!mPreviousContentRect.equals(contentRect)) { + updateCoordinates(contentRect); + } + mWidthChanged = false; + mPreviousContentRect.set(contentRect); + } + + private 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(); + } + + @Override + public void dismiss() { + if (mDismissed) { + return; + } + + mHidden = false; + mDismissed = true; + mHideAnimation.cancel(); + + runDismissAnimation(); + setZeroTouchableSurface(); + } + + @Override + public void hide() { + if (!isShowing()) { + return; + } + + mHidden = true; + runHideAnimation(); + setZeroTouchableSurface(); + } + + @Override + public boolean isShowing() { + return !mDismissed && !mHidden; + } + + @Override + 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. + */ + private 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 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 RemoteSelectionToolbar mPopup; + + OverflowPanel(RemoteSelectionToolbar 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("floating_toolbar"); + 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; + } + } +}