diff options
| -rw-r--r-- | core/java/android/service/selectiontoolbar/RemoteSelectionToolbar.java | 1619 |
1 files changed, 1619 insertions, 0 deletions
diff --git a/core/java/android/service/selectiontoolbar/RemoteSelectionToolbar.java b/core/java/android/service/selectiontoolbar/RemoteSelectionToolbar.java new file mode 100644 index 000000000000..95ecc4e16446 --- /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<MenuItemRepr, MenuItem> 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<MenuItem> 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<MenuItem>) + */ + private void updateMenuItems( + List<MenuItem> 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<MenuItem> 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<MenuItem> 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<MenuItem> layoutMainPanelItems( + List<MenuItem> menuItems, final int toolbarWidth) { + Objects.requireNonNull(menuItems); + + int availableWidth = toolbarWidth; + + final LinkedList<MenuItem> remainingMenuItems = new LinkedList<>(); + // add the overflow menu items to the end of the remainingMenuItems list. + final LinkedList<MenuItem> 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<MenuItem> menuItems) { + ArrayAdapter<MenuItem> overflowPanelAdapter = + (ArrayAdapter<MenuItem>) 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<MenuItem> overflowPanelAdapter = + (ArrayAdapter<MenuItem>) 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<MenuItem>(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<MenuItem> menuItems1, Collection<MenuItem> menuItems2) { + if (menuItems1.size() != menuItems2.size()) { + return false; + } + + final Iterator<MenuItem> menuItems2Iter = menuItems2.iterator(); + for (MenuItem menuItem1 : menuItems1) { + final MenuItem menuItem2 = menuItems2Iter.next(); + if (!MenuItemRepr.of(menuItem1).equals(MenuItemRepr.of(menuItem2))) { + return false; + } + } + + return true; + } + } +} |