// Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package org.chromium.chrome.browser.appmenu; import android.animation.TimeAnimator; import android.annotation.SuppressLint; import android.app.Activity; import android.content.res.Resources; import android.graphics.Rect; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.widget.ImageButton; import android.widget.LinearLayout; import android.widget.ListPopupWindow; import android.widget.ListView; import org.chromium.base.metrics.RecordUserAction; import org.chromium.chrome.R; import java.util.ArrayList; /** * Handles the drag touch events on AppMenu that start from the menu button. * * Lint suppression for NewApi is added because we are using TimeAnimator class that was marked * hidden in API 16. */ @SuppressLint("NewApi") class AppMenuDragHelper { private final Activity mActivity; private final AppMenu mAppMenu; // Internally used action constants for dragging. private static final int ITEM_ACTION_HIGHLIGHT = 0; private static final int ITEM_ACTION_PERFORM = 1; private static final int ITEM_ACTION_CLEAR_HIGHLIGHT_ALL = 2; private static final float AUTO_SCROLL_AREA_MAX_RATIO = 0.25f; // Dragging related variables, i.e., menu showing initiated by touch down and drag to navigate. private final float mAutoScrollFullVelocity; private final TimeAnimator mDragScrolling = new TimeAnimator(); private float mDragScrollOffset; private int mDragScrollOffsetRounded; private volatile float mDragScrollingVelocity; private volatile float mLastTouchX; private volatile float mLastTouchY; private final int mItemRowHeight; private boolean mIsSingleTapCanceled; private int mMenuButtonScreenCenterY; // These are used in a function locally, but defined here to avoid heap allocation on every // touch event. private final Rect mScreenVisibleRect = new Rect(); private final int[] mScreenVisiblePoint = new int[2]; private final int mTapTimeout; private final int mScaledTouchSlop; AppMenuDragHelper(Activity activity, AppMenu appMenu, int itemRowHeight) { mActivity = activity; mAppMenu = appMenu; mItemRowHeight = itemRowHeight; Resources res = mActivity.getResources(); mAutoScrollFullVelocity = res.getDimensionPixelSize(R.dimen.auto_scroll_full_velocity); // If user is dragging and the popup ListView is too big to display at once, // mDragScrolling animator scrolls mPopup.getListView() automatically depending on // the user's touch position. mDragScrolling.setTimeListener(new TimeAnimator.TimeListener() { @Override public void onTimeUpdate(TimeAnimator animation, long totalTime, long deltaTime) { ListPopupWindow popup = mAppMenu.getPopup(); if (popup == null || popup.getListView() == null) return; // We keep both mDragScrollOffset and mDragScrollOffsetRounded because // the actual scrolling is by the rounded value but at the same time we also // want to keep the precise scroll value in float. mDragScrollOffset += (deltaTime * 0.001f) * mDragScrollingVelocity; int diff = Math.round(mDragScrollOffset - mDragScrollOffsetRounded); mDragScrollOffsetRounded += diff; popup.getListView().smoothScrollBy(diff, 0); // Force touch move event to highlight items correctly for the scrolled position. if (!Float.isNaN(mLastTouchX) && !Float.isNaN(mLastTouchY)) { menuItemAction(Math.round(mLastTouchX), Math.round(mLastTouchY), ITEM_ACTION_HIGHLIGHT); } } }); // We use medium timeout, the average of tap and long press timeouts. This is consistent // with ListPopupWindow#ForwardingListener implementation. mTapTimeout = (ViewConfiguration.getTapTimeout() + ViewConfiguration.getLongPressTimeout()) / 2; mScaledTouchSlop = ViewConfiguration.get(activity).getScaledTouchSlop(); } /** * Sets up all the internal state to prepare for menu dragging. * @param startDragging Whether dragging is started. For example, if the app menu * is showed by tapping on a button, this should be false. If it is * showed by start dragging down on the menu button, this should be * true. */ void onShow(boolean startDragging) { mLastTouchX = Float.NaN; mLastTouchY = Float.NaN; mDragScrollOffset = 0.0f; mDragScrollOffsetRounded = 0; mDragScrollingVelocity = 0.0f; mIsSingleTapCanceled = false; if (startDragging) mDragScrolling.start(); } /** * Dragging mode will be stopped by calling this function. Note that it will fall back to normal * non-dragging mode. */ void finishDragging() { // If the menu is being dismissed, we cannot access mAppMenu.getPopup().getListView() // needed to by menuItemAction. Only clear highlighting if the menu is still showing. // See crbug.com/589805. if (mAppMenu.getPopup().isShowing()) { menuItemAction(0, 0, ITEM_ACTION_CLEAR_HIGHLIGHT_ALL); } mDragScrolling.cancel(); } /** * Gets all the touch events and updates dragging related logic. Note that if this app menu * is initiated by software UI control, then the control should set onTouchListener and forward * all the events to this method because the initial UI control that processed ACTION_DOWN will * continue to get all the subsequent events. * * @param event Touch event to be processed. * @param button Button that received the touch event. * @return Whether the event is handled. */ boolean handleDragging(MotionEvent event, View button) { if (!mAppMenu.isShowing() || !mDragScrolling.isRunning()) return false; // We will only use the screen space coordinate (rawX, rawY) to reduce confusion. // This code works across many different controls, so using local coordinates will be // a disaster. final float rawX = event.getRawX(); final float rawY = event.getRawY(); final int roundedRawX = Math.round(rawX); final int roundedRawY = Math.round(rawY); final int eventActionMasked = event.getActionMasked(); final long timeSinceDown = event.getEventTime() - event.getDownTime(); final ListView listView = mAppMenu.getPopup().getListView(); mLastTouchX = rawX; mLastTouchY = rawY; mMenuButtonScreenCenterY = getScreenVisibleRect(button).centerY(); if (eventActionMasked == MotionEvent.ACTION_CANCEL) { mAppMenu.dismiss(); return true; } else if (eventActionMasked == MotionEvent.ACTION_UP) { nativeRecordAppMenuTouchDuration(timeSinceDown); } mIsSingleTapCanceled |= timeSinceDown > mTapTimeout; mIsSingleTapCanceled |= !pointInView(button, event.getX(), event.getY(), mScaledTouchSlop); if (!mIsSingleTapCanceled && eventActionMasked == MotionEvent.ACTION_UP) { RecordUserAction.record("MobileUsingMenuBySwButtonTap"); finishDragging(); } // After this line, drag scrolling is happening. if (!mDragScrolling.isRunning()) return false; boolean didPerformClick = false; int itemAction = ITEM_ACTION_CLEAR_HIGHLIGHT_ALL; switch (eventActionMasked) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_MOVE: itemAction = ITEM_ACTION_HIGHLIGHT; break; case MotionEvent.ACTION_UP: itemAction = ITEM_ACTION_PERFORM; break; default: break; } didPerformClick = menuItemAction(roundedRawX, roundedRawY, itemAction); if (eventActionMasked == MotionEvent.ACTION_UP && !didPerformClick) { RecordUserAction.record("MobileUsingMenuBySwButtonDragging"); mAppMenu.dismiss(); } else if (eventActionMasked == MotionEvent.ACTION_MOVE) { // Auto scrolling on the top or the bottom of the listView. if (listView.getHeight() > 0) { float autoScrollAreaRatio = Math.min(AUTO_SCROLL_AREA_MAX_RATIO, mItemRowHeight * 1.2f / listView.getHeight()); float normalizedY = (rawY - getScreenVisibleRect(listView).top) / listView.getHeight(); if (normalizedY < autoScrollAreaRatio) { // Top mDragScrollingVelocity = (normalizedY / autoScrollAreaRatio - 1.0f) * mAutoScrollFullVelocity; } else if (normalizedY > 1.0f - autoScrollAreaRatio) { // Bottom mDragScrollingVelocity = ((normalizedY - 1.0f) / autoScrollAreaRatio + 1.0f) * mAutoScrollFullVelocity; } else { // Middle or not scrollable. mDragScrollingVelocity = 0.0f; } } } return true; } private boolean pointInView(View view, float x, float y, float slop) { return x >= -slop && y >= -slop && x < (view.getWidth() + slop) && y < (view.getHeight() + slop); } /** * Performs the specified action on the menu item specified by the screen coordinate position. * @param screenX X in screen space coordinate. * @param screenY Y in screen space coordinate. * @param action Action type to perform, it should be one of ITEM_ACTION_* constants. * @return true whether or not a menu item is performed (executed). */ private boolean menuItemAction(int screenX, int screenY, int action) { ListView listView = mAppMenu.getPopup().getListView(); // Starting M, we have a popup menu animation that slides down. If we process dragging // events while it's sliding, it will touch many views that are passing by user's finger, // which is not desirable. So we only process when the first item is below the menu button. // Unfortunately, there is no available listener for sliding animation finished. Thus the // following nasty heuristics. final View firstRow = listView.getChildAt(0); if (listView.getFirstVisiblePosition() == 0 && firstRow != null && firstRow.getTop() == 0 && getScreenVisibleRect(firstRow).bottom <= mMenuButtonScreenCenterY) { return false; } ArrayList<View> itemViews = new ArrayList<View>(); for (int i = 0; i < listView.getChildCount(); ++i) { boolean hasImageButtons = false; if (listView.getChildAt(i) instanceof LinearLayout) { LinearLayout layout = (LinearLayout) listView.getChildAt(i); for (int j = 0; j < layout.getChildCount(); ++j) { itemViews.add(layout.getChildAt(j)); if (layout.getChildAt(j) instanceof ImageButton) hasImageButtons = true; } } if (!hasImageButtons) itemViews.add(listView.getChildAt(i)); } boolean didPerformClick = false; for (int i = 0; i < itemViews.size(); ++i) { View itemView = itemViews.get(i); boolean shouldPerform = itemView.isEnabled() && itemView.isShown() && getScreenVisibleRect(itemView).contains(screenX, screenY); switch (action) { case ITEM_ACTION_HIGHLIGHT: itemView.setPressed(shouldPerform); break; case ITEM_ACTION_PERFORM: if (shouldPerform) { RecordUserAction.record("MobileUsingMenuBySwButtonDragging"); itemView.performClick(); didPerformClick = true; } break; case ITEM_ACTION_CLEAR_HIGHLIGHT_ALL: itemView.setPressed(false); break; default: assert false; break; } } return didPerformClick; } /** * @return Visible rect in screen coordinates for the given View. */ private Rect getScreenVisibleRect(View view) { view.getLocalVisibleRect(mScreenVisibleRect); view.getLocationOnScreen(mScreenVisiblePoint); mScreenVisibleRect.offset(mScreenVisiblePoint[0], mScreenVisiblePoint[1]); return mScreenVisibleRect; } private static native void nativeRecordAppMenuTouchDuration(long timeMs); }