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