Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 0c7ed288 authored by Abodunrinwa Toki's avatar Abodunrinwa Toki
Browse files

New floating toolbar implementation for secondary action mode views.

Change-Id: I04d179576290f1b107b3777287d81f149e16e658
parent 545ebdef
Loading
Loading
Loading
Loading
+596 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2015 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.internal.widget;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.graphics.Color;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.View.MeasureSpec;
import android.view.ViewGroup;
import android.view.Window;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.PopupWindow;

import com.android.internal.R;
import com.android.internal.util.Preconditions;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

/**
 * A floating toolbar for showing contextual menu items.
 * This view shows as many menu item buttons as can fit in the horizontal toolbar and the
 * the remaining menu items in a vertical overflow view when the overflow button is clicked.
 * The horizontal toolbar morphs into the vertical overflow view.
 */
public final class FloatingToolbar {

    private static final MenuItem.OnMenuItemClickListener NO_OP_MENUITEM_CLICK_LISTENER =
            new MenuItem.OnMenuItemClickListener() {
                @Override
                public boolean onMenuItemClick(MenuItem item) {
                    return false;
                }
            };

    private final Context mContext;
    private final FloatingToolbarPopup mPopup;
    private final ViewGroup mMenuItemButtonsContainer;
    private final View.OnClickListener mMenuItemButtonOnClickListener =
            new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    if (v.getTag() instanceof MenuItem) {
                        mMenuItemClickListener.onMenuItemClick((MenuItem) v.getTag());
                        mPopup.dismiss();
                    }
                }
            };

    private final Rect mContentRect = new Rect();
    private final Point mCoordinates = new Point();

    private Menu mMenu;
    private List<CharSequence> mShowingTitles = new ArrayList<CharSequence>();
    private MenuItem.OnMenuItemClickListener mMenuItemClickListener = NO_OP_MENUITEM_CLICK_LISTENER;
    private View mOpenOverflowButton;

    private int mSuggestedWidth;

    /**
     * Initializes a floating toolbar.
     */
    public FloatingToolbar(Context context, Window window) {
        mContext = Preconditions.checkNotNull(context);
        mPopup = new FloatingToolbarPopup(Preconditions.checkNotNull(window.getDecorView()));
        mMenuItemButtonsContainer = createMenuButtonsContainer(context);
    }

    /**
     * Sets the menu to be shown in this floating toolbar.
     * NOTE: Call {@link #updateLayout()} or {@link #show()} to effect visual changes to the
     * toolbar.
     */
    public FloatingToolbar setMenu(Menu menu) {
        mMenu = Preconditions.checkNotNull(menu);
        return this;
    }

    /**
     * Sets the custom listener for invocation of menu items in this floating
     * toolbar.
     */
    public FloatingToolbar setOnMenuItemClickListener(
            MenuItem.OnMenuItemClickListener menuItemClickListener) {
        if (menuItemClickListener != null) {
            mMenuItemClickListener = menuItemClickListener;
        } else {
            mMenuItemClickListener = NO_OP_MENUITEM_CLICK_LISTENER;
        }
        return this;
    }

    /**
     * Sets the content rectangle. This is the area of the interesting content that this toolbar
     * should avoid obstructing.
     * NOTE: Call {@link #updateLayout()} or {@link #show()} to effect visual changes to the
     * toolbar.
     */
    public FloatingToolbar setContentRect(Rect rect) {
        mContentRect.set(Preconditions.checkNotNull(rect));
        return this;
    }

    /**
     * Sets the suggested width of this floating toolbar.
     * The actual width will be about this size but there are no guarantees that it will be exactly
     * the suggested width.
     * NOTE: Call {@link #updateLayout()} or {@link #show()} to effect visual changes to the
     * toolbar.
     */
    public FloatingToolbar setSuggestedWidth(int suggestedWidth) {
        mSuggestedWidth = suggestedWidth;
        return this;
    }

    /**
     * Shows this floating toolbar.
     */
    public FloatingToolbar show() {
        List<MenuItem> menuItems = getVisibleAndEnabledMenuItems(mMenu);
        if (hasContentChanged(menuItems) || hasWidthChanged()) {
            mPopup.dismiss();
            layoutMenuItemButtons(menuItems);
            mShowingTitles = getMenuItemTitles(menuItems);
        }
        refreshCoordinates();
        mPopup.updateCoordinates(mCoordinates.x, mCoordinates.y);
        if (!mPopup.isShowing()) {
            mPopup.show(mCoordinates.x, mCoordinates.y);
        }
        return this;
    }

    /**
     * Updates this floating toolbar to reflect recent position and view updates.
     * NOTE: This method is a no-op if the toolbar isn't showing.
     */
    public FloatingToolbar updateLayout() {
        if (mPopup.isShowing()) {
            // show() performs all the logic we need here.
            show();
        }
        return this;
    }

    /**
     * Dismisses this floating toolbar.
     */
    public void dismiss() {
        mPopup.dismiss();
    }

    /**
     * Returns {@code true} if this popup is currently showing. {@code false} otherwise.
     */
    public boolean isShowing() {
        return mPopup.isShowing();
    }

    /**
     * Refreshes {@link #mCoordinates} with values based on {@link #mContentRect}.
     */
    private void refreshCoordinates() {
        int popupWidth = mPopup.getWidth();
        int popupHeight = mPopup.getHeight();
        if (!mPopup.isShowing()) {
            // Popup isn't yet shown, get estimated size from the menu item buttons container.
            mMenuItemButtonsContainer.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
            popupWidth = mMenuItemButtonsContainer.getMeasuredWidth();
            popupHeight = mMenuItemButtonsContainer.getMeasuredHeight();
        }
        int x = mContentRect.centerX() - popupWidth / 2;
        int y;
        if (shouldDisplayAtTopOfContent()) {
            y = mContentRect.top - popupHeight;
        } else {
            y = mContentRect.bottom;
        }
        mCoordinates.set(x, y);
    }

    /**
     * Returns true if this floating toolbar's menu items have been reordered or changed.
     */
    private boolean hasContentChanged(List<MenuItem> menuItems) {
        return !mShowingTitles.equals(getMenuItemTitles(menuItems));
    }

    /**
     * Returns true if there is a significant change in width of the toolbar.
     */
    private boolean hasWidthChanged() {
        int actualWidth = mPopup.getWidth();
        int difference = Math.abs(actualWidth - mSuggestedWidth);
        return difference > (actualWidth * 0.2);
    }

    /**
     * Returns true if the preferred positioning of the toolbar is above the content rect.
     */
    private boolean shouldDisplayAtTopOfContent() {
        return mContentRect.top - getMinimumOverflowHeight(mContext) > 0;
    }

    /**
     * Returns the visible and enabled menu items in the specified menu.
     * This method is recursive.
     */
    private List<MenuItem> getVisibleAndEnabledMenuItems(Menu menu) {
        List<MenuItem> menuItems = new ArrayList<MenuItem>();
        for (int i = 0; (menu != null) && (i < menu.size()); i++) {
            MenuItem menuItem = menu.getItem(i);
            if (menuItem.isVisible() && menuItem.isEnabled()) {
                Menu subMenu = menuItem.getSubMenu();
                if (subMenu != null) {
                    menuItems.addAll(getVisibleAndEnabledMenuItems(subMenu));
                } else {
                    menuItems.add(menuItem);
                }
            }
        }
        return menuItems;
    }

    private List<CharSequence> getMenuItemTitles(List<MenuItem> menuItems) {
        List<CharSequence> titles = new ArrayList<CharSequence>();
        for (MenuItem menuItem : menuItems) {
            titles.add(menuItem.getTitle());
        }
        return titles;
    }

    private void layoutMenuItemButtons(List<MenuItem> menuItems) {
        final int toolbarWidth = getAdjustedToolbarWidth(mContext, mSuggestedWidth)
                // Reserve space for the "open overflow" button.
                - getEstimatedOpenOverflowButtonWidth(mContext);

        int availableWidth = toolbarWidth;
        LinkedList<MenuItem> remainingMenuItems = new LinkedList<MenuItem>(menuItems);

        mMenuItemButtonsContainer.removeAllViews();

        boolean isFirstItem = true;
        while (!remainingMenuItems.isEmpty()) {
            final MenuItem menuItem = remainingMenuItems.peek();
            Button menuItemButton = createMenuItemButton(mContext, menuItem);

            // Adding additional left padding for the first button to even out button spacing.
            if (isFirstItem) {
                menuItemButton.setPadding(
                        2 * menuItemButton.getPaddingLeft(),
                        menuItemButton.getPaddingTop(),
                        menuItemButton.getPaddingRight(),
                        menuItemButton.getPaddingBottom());
                isFirstItem = false;
            }

            // Adding additional right padding for the last button to even out button spacing.
            if (remainingMenuItems.size() == 1) {
                menuItemButton.setPadding(
                        menuItemButton.getPaddingLeft(),
                        menuItemButton.getPaddingTop(),
                        2 * menuItemButton.getPaddingRight(),
                        menuItemButton.getPaddingBottom());
            }

            menuItemButton.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
            int menuItemButtonWidth = Math.min(menuItemButton.getMeasuredWidth(), toolbarWidth);
            if (menuItemButtonWidth <= availableWidth) {
                menuItemButton.setTag(menuItem);
                menuItemButton.setOnClickListener(mMenuItemButtonOnClickListener);
                mMenuItemButtonsContainer.addView(menuItemButton);
                menuItemButton.getLayoutParams().width = menuItemButtonWidth;
                availableWidth -= menuItemButtonWidth;
                remainingMenuItems.pop();
            } else {
                // The "open overflow" button launches the vertical overflow from the
                // floating toolbar.
                createOpenOverflowButtonIfNotExists();
                mMenuItemButtonsContainer.addView(mOpenOverflowButton);
                break;
            }
        }
        mPopup.setContentView(mMenuItemButtonsContainer);
    }

    /**
     * Creates and returns the button that opens the vertical overflow.
     */
    private void createOpenOverflowButtonIfNotExists() {
        mOpenOverflowButton = (ImageButton) LayoutInflater.from(mContext)
                .inflate(R.layout.floating_popup_open_overflow_button, null);
        mOpenOverflowButton.setOnClickListener(
                new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        // Open the overflow.
                    }
                });
    }

    /**
     * Creates and returns a floating toolbar menu buttons container.
     */
    private static ViewGroup createMenuButtonsContainer(Context context) {
        return (ViewGroup) LayoutInflater.from(context)
                .inflate(R.layout.floating_popup_container, null);
    }

    /**
     * Creates and returns a menu button for the specified menu item.
     */
    private static Button createMenuItemButton(Context context, MenuItem menuItem) {
        Button menuItemButton = (Button) LayoutInflater.from(context)
                .inflate(R.layout.floating_popup_menu_button, null);
        menuItemButton.setText(menuItem.getTitle());
        menuItemButton.setContentDescription(menuItem.getTitle());
        return menuItemButton;
    }

    private static int getMinimumOverflowHeight(Context context) {
        return context.getResources().
                getDimensionPixelSize(R.dimen.floating_toolbar_minimum_overflow_height);
    }

    private static int getEstimatedOpenOverflowButtonWidth(Context context) {
        return context.getResources()
                .getDimensionPixelSize(R.dimen.floating_toolbar_menu_button_minimum_width);
    }

    private static int getAdjustedToolbarWidth(Context context, int width) {
        if (width <= 0 || width > getScreenWidth(context)) {
            width = context.getResources()
                    .getDimensionPixelSize(R.dimen.floating_toolbar_default_width);
        }
        return width;
    }

    /**
     * Returns the device's screen width.
     */
    public static int getScreenWidth(Context context) {
        return context.getResources().getDisplayMetrics().widthPixels;
    }

    /**
     * Returns the device's screen height.
     */
    public static int getScreenHeight(Context context) {
        return context.getResources().getDisplayMetrics().heightPixels;
    }


    /**
     * A popup window used by the floating toolbar.
     */
    private static final class FloatingToolbarPopup {

        private final View mParent;
        private final PopupWindow mPopupWindow;
        private final ViewGroup mContentContainer;
        private final Animator.AnimatorListener mOnDismissEnd =
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationEnd(Animator animation) {
                        mPopupWindow.dismiss();
                        mDismissAnimating = false;
                    }
                };
        private final AnimatorSet mGrowFadeInFromBottomAnimation;
        private final AnimatorSet mShrinkFadeOutFromBottomAnimation;

        private boolean mDismissAnimating;

        /**
         * Initializes a new floating bar popup.
         *
         * @param parent  A parent view to get the {@link View#getWindowToken()} token from.
         */
        public FloatingToolbarPopup(View parent) {
            mParent = Preconditions.checkNotNull(parent);
            mContentContainer = createContentContainer(parent.getContext());
            mPopupWindow = createPopupWindow(mContentContainer);
            mGrowFadeInFromBottomAnimation = createGrowFadeInFromBottom(mContentContainer);
            mShrinkFadeOutFromBottomAnimation =
                    createShrinkFadeOutFromBottomAnimation(mContentContainer, mOnDismissEnd);
        }

        /**
         * Shows this popup at the specified coordinates.
         * The specified coordinates may be adjusted to make sure the popup is entirely on-screen.
         * If this popup is already showing, this will be a no-op.
         */
        public void show(int x, int y) {
            if (isShowing()) {
                updateCoordinates(x, y);
                return;
            }

            mPopupWindow.showAtLocation(mParent, Gravity.NO_GRAVITY, 0, 0);
            positionOnScreen(x, y);
            growFadeInFromBottom();

            mDismissAnimating = false;
        }

        /**
         * Gets rid of this popup. If the popup isn't currently showing, this will be a no-op.
         */
        public void dismiss() {
            if (!isShowing()) {
                return;
            }

            if (mDismissAnimating) {
                // This window is already dismissing. Don't restart the animation.
                return;
            }
            mDismissAnimating = true;
            shrinkFadeOutFromBottom();
        }

        /**
         * Returns {@code true} if this popup is currently showing. {@code false} otherwise.
         */
        public boolean isShowing() {
            return mPopupWindow.isShowing() && !mDismissAnimating;
        }

        /**
         * Updates the coordinates of this popup.
         * The specified coordinates may be adjusted to make sure the popup is entirely on-screen.
         */
        public void updateCoordinates(int x, int y) {
            if (isShowing()) {
                positionOnScreen(x, y);
            }
        }

        /**
         * Sets the content of this popup.
         */
        public void setContentView(View view) {
            Preconditions.checkNotNull(view);
            mContentContainer.removeAllViews();
            mContentContainer.addView(view);
        }

        /**
         * Returns the width of this popup.
         */
        public int getWidth() {
            return mContentContainer.getWidth();
        }

        /**
         * Returns the height of this popup.
         */
        public int getHeight() {
            return mContentContainer.getHeight();
        }

        /**
         * Returns the context this popup is running in.
         */
        public Context getContext() {
            return mContentContainer.getContext();
        }

        private void positionOnScreen(int x, int y) {
            if (getWidth() == 0) {
                // content size is yet to be measured.
                mContentContainer.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
            }
            x = clamp(x, 0, getScreenWidth(getContext()) - getWidth());
            y = clamp(y, 0, getScreenHeight(getContext()) - getHeight());

            // Position the view w.r.t. the window.
            mContentContainer.setX(x);
            mContentContainer.setY(y);
        }

        /**
         * Performs the "grow and fade in from the bottom" animation on the floating popup.
         */
        private void growFadeInFromBottom() {
            setPivot();
            mGrowFadeInFromBottomAnimation.start();
        }

        /**
         * Performs the "shrink and fade out from bottom" animation on the floating popup.
         */
        private void shrinkFadeOutFromBottom() {
            setPivot();
            mShrinkFadeOutFromBottomAnimation.start();
        }

        /**
         * Sets the popup content container's pivot.
         */
        private void setPivot() {
            mContentContainer.setPivotX(mContentContainer.getMeasuredWidth() / 2);
            mContentContainer.setPivotY(mContentContainer.getMeasuredHeight());
        }

        private static ViewGroup createContentContainer(Context context) {
            return (ViewGroup) LayoutInflater.from(context)
                    .inflate(R.layout.floating_popup_container, null);
        }

        private static PopupWindow createPopupWindow(View content) {
            ViewGroup popupContentHolder = new LinearLayout(content.getContext());
            PopupWindow popupWindow = new PopupWindow(popupContentHolder);
            popupWindow.setAnimationStyle(0);
            popupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
            popupWindow.setWidth(getScreenWidth(content.getContext()));
            popupWindow.setHeight(getScreenHeight(content.getContext()));
            content.setLayoutParams(new ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
            popupContentHolder.addView(content);
            return popupWindow;
        }

        /**
         * Creates a "grow and fade in from the bottom" animation for the specified view.
         *
         * @param view  The view to animate
         */
        private static AnimatorSet createGrowFadeInFromBottom(View view) {
            AnimatorSet growFadeInFromBottomAnimation =  new AnimatorSet();
            growFadeInFromBottomAnimation.playTogether(
                    ObjectAnimator.ofFloat(view, View.SCALE_X, 0.5f, 1).setDuration(125),
                    ObjectAnimator.ofFloat(view, View.SCALE_Y, 0.5f, 1).setDuration(125),
                    ObjectAnimator.ofFloat(view, View.ALPHA, 0, 1).setDuration(75));
            return growFadeInFromBottomAnimation;
        }

        /**
         * Creates a "shrink and fade out from bottom" animation for the specified view.
         *
         * @param view  The view to animate
         * @param listener  The animation listener
         */
        private static AnimatorSet createShrinkFadeOutFromBottomAnimation(
                View view, Animator.AnimatorListener listener) {
            AnimatorSet shrinkFadeOutFromBottomAnimation =  new AnimatorSet();
            shrinkFadeOutFromBottomAnimation.playTogether(
                    ObjectAnimator.ofFloat(view, View.SCALE_Y, 1, 0.5f).setDuration(125),
                    ObjectAnimator.ofFloat(view, View.ALPHA, 1, 0).setDuration(75));
            shrinkFadeOutFromBottomAnimation.setStartDelay(150);
            shrinkFadeOutFromBottomAnimation.addListener(listener);
            return shrinkFadeOutFromBottomAnimation;
        }

        /**
         * Returns value, restricted to the range min->max (inclusive).
         * If maximum is less than minimum, the result is undefined.
         *
         * @param value  The value to clamp.
         * @param minimum  The minimum value in the range.
         * @param maximum  The maximum value in the range. Must not be less than minimum.
         */
        private static int clamp(int value, int minimum, int maximum) {
            return Math.max(minimum, Math.min(value, maximum));
        }
    }
}
+25 −0
Original line number Diff line number Diff line
<?xml version="1.0" encoding="utf-8"?>
<!--
/* Copyright 2015, 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.
*/
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="wrap_content"
    android:layout_height="@dimen/floating_toolbar_height"
    android:elevation="2dp"
    android:focusable="true"
    android:focusableInTouchMode="true"
    android:background="@android:color/background_light" />
+31 −0
Original line number Diff line number Diff line
<?xml version="1.0" encoding="utf-8"?>
<!--
/* Copyright 2015, 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.
*/
-->
<Button xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:minWidth="@dimen/floating_toolbar_menu_button_side_padding"
    android:paddingLeft="@dimen/floating_toolbar_menu_button_side_padding"
    android:paddingRight="@dimen/floating_toolbar_menu_button_side_padding"
    android:paddingTop="0dp"
    android:paddingBottom="0dp"
    android:singleLine="true"
    android:ellipsize="end"
    android:fontFamily="sans-serif"
    android:textSize="@dimen/floating_toolbar_text_size"
    android:textAllCaps="true"
    android:background="?attr/selectableItemBackground" />
 No newline at end of file
+25 −0

File added.

Preview size limit exceeded, changes collapsed.

+7 −0

File changed.

Preview size limit exceeded, changes collapsed.

Loading