blob: be9945d35fbc9048d1091707e2483864fb7fc707 [file] [log] [blame]
/*
* 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));
}
}
}