1// Copyright 2014 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5package org.chromium.chrome.browser.appmenu;
6
7import android.animation.TimeAnimator;
8import android.annotation.SuppressLint;
9import android.app.Activity;
10import android.content.res.Resources;
11import android.graphics.Rect;
12import android.view.GestureDetector;
13import android.view.GestureDetector.SimpleOnGestureListener;
14import android.view.MotionEvent;
15import android.view.View;
16import android.widget.ImageButton;
17import android.widget.LinearLayout;
18import android.widget.ListPopupWindow;
19import android.widget.ListView;
20
21import org.chromium.chrome.R;
22import org.chromium.chrome.browser.UmaBridge;
23
24import java.util.ArrayList;
25
26/**
27 * Handles the drag touch events on AppMenu that start from the menu button.
28 *
29 * Lint suppression for NewApi is added because we are using TimeAnimator class that was marked
30 * hidden in API 16.
31 */
32@SuppressLint("NewApi")
33class AppMenuDragHelper {
34    private final Activity mActivity;
35    private final AppMenu mAppMenu;
36
37    // Internally used action constants for dragging.
38    private static final int ITEM_ACTION_HIGHLIGHT = 0;
39    private static final int ITEM_ACTION_PERFORM = 1;
40    private static final int ITEM_ACTION_CLEAR_HIGHLIGHT_ALL = 2;
41
42    private static final float AUTO_SCROLL_AREA_MAX_RATIO = 0.25f;
43
44    // Dragging related variables, i.e., menu showing initiated by touch down and drag to navigate.
45    private final float mAutoScrollFullVelocity;
46    private final TimeAnimator mDragScrolling = new TimeAnimator();
47    private float mDragScrollOffset;
48    private int mDragScrollOffsetRounded;
49    private volatile float mDragScrollingVelocity;
50    private volatile float mLastTouchX;
51    private volatile float mLastTouchY;
52    private final int mItemRowHeight;
53    private boolean mIsSingleTapUpHappened;
54    GestureDetector mGestureSingleTapDetector;
55
56    // These are used in a function locally, but defined here to avoid heap allocation on every
57    // touch event.
58    private final Rect mScreenVisibleRect = new Rect();
59    private final int[] mScreenVisiblePoint = new int[2];
60
61    AppMenuDragHelper(Activity activity, AppMenu appMenu, int itemRowHeight) {
62        mActivity = activity;
63        mAppMenu = appMenu;
64        mItemRowHeight = itemRowHeight;
65        Resources res = mActivity.getResources();
66        mAutoScrollFullVelocity = res.getDimensionPixelSize(R.dimen.auto_scroll_full_velocity);
67        // If user is dragging and the popup ListView is too big to display at once,
68        // mDragScrolling animator scrolls mPopup.getListView() automatically depending on
69        // the user's touch position.
70        mDragScrolling.setTimeListener(new TimeAnimator.TimeListener() {
71            @Override
72            public void onTimeUpdate(TimeAnimator animation, long totalTime, long deltaTime) {
73                ListPopupWindow popup = mAppMenu.getPopup();
74                if (popup == null || popup.getListView() == null) return;
75
76                // We keep both mDragScrollOffset and mDragScrollOffsetRounded because
77                // the actual scrolling is by the rounded value but at the same time we also
78                // want to keep the precise scroll value in float.
79                mDragScrollOffset += (deltaTime * 0.001f) * mDragScrollingVelocity;
80                int diff = Math.round(mDragScrollOffset - mDragScrollOffsetRounded);
81                mDragScrollOffsetRounded += diff;
82                popup.getListView().smoothScrollBy(diff, 0);
83
84                // Force touch move event to highlight items correctly for the scrolled position.
85                if (!Float.isNaN(mLastTouchX) && !Float.isNaN(mLastTouchY)) {
86                    menuItemAction(Math.round(mLastTouchX), Math.round(mLastTouchY),
87                            ITEM_ACTION_HIGHLIGHT);
88                }
89            }
90        });
91        mGestureSingleTapDetector = new GestureDetector(activity, new SimpleOnGestureListener() {
92            @Override
93            public boolean onSingleTapUp(MotionEvent e) {
94                mIsSingleTapUpHappened = true;
95                return true;
96            }
97        });
98    }
99
100    /**
101     * Sets up all the internal state to prepare for menu dragging.
102     * @param startDragging      Whether dragging is started. For example, if the app menu
103     *                           is showed by tapping on a button, this should be false. If it is
104     *                           showed by start dragging down on the menu button, this should be
105     *                           true.
106     */
107    void onShow(boolean startDragging) {
108        mLastTouchX = Float.NaN;
109        mLastTouchY = Float.NaN;
110        mDragScrollOffset = 0.0f;
111        mDragScrollOffsetRounded = 0;
112        mDragScrollingVelocity = 0.0f;
113        mIsSingleTapUpHappened = false;
114
115        if (startDragging) mDragScrolling.start();
116    }
117
118    /**
119     * Dragging mode will be stopped by calling this function. Note that it will fall back to normal
120     * non-dragging mode.
121     */
122    void finishDragging() {
123        menuItemAction(0, 0, ITEM_ACTION_CLEAR_HIGHLIGHT_ALL);
124        mDragScrolling.cancel();
125    }
126
127    /**
128     * Gets all the touch events and updates dragging related logic. Note that if this app menu
129     * is initiated by software UI control, then the control should set onTouchListener and forward
130     * all the events to this method because the initial UI control that processed ACTION_DOWN will
131     * continue to get all the subsequent events.
132     *
133     * @param event Touch event to be processed.
134     * @return Whether the event is handled.
135     */
136    boolean handleDragging(MotionEvent event) {
137        if (!mAppMenu.isShowing() || !mDragScrolling.isRunning()) return false;
138
139        // We will only use the screen space coordinate (rawX, rawY) to reduce confusion.
140        // This code works across many different controls, so using local coordinates will be
141        // a disaster.
142
143        final float rawX = event.getRawX();
144        final float rawY = event.getRawY();
145        final int roundedRawX = Math.round(rawX);
146        final int roundedRawY = Math.round(rawY);
147        final int eventActionMasked = event.getActionMasked();
148        final ListView listView = mAppMenu.getPopup().getListView();
149
150        mLastTouchX = rawX;
151        mLastTouchY = rawY;
152
153        if (eventActionMasked == MotionEvent.ACTION_CANCEL) {
154            mAppMenu.dismiss();
155            return true;
156        }
157
158        if (!mIsSingleTapUpHappened) {
159            mGestureSingleTapDetector.onTouchEvent(event);
160            if (mIsSingleTapUpHappened) {
161                UmaBridge.usingMenu(false, false);
162                finishDragging();
163            }
164        }
165
166        // After this line, drag scrolling is happening.
167        if (!mDragScrolling.isRunning()) return false;
168
169        boolean didPerformClick = false;
170        int itemAction = ITEM_ACTION_CLEAR_HIGHLIGHT_ALL;
171        switch (eventActionMasked) {
172            case MotionEvent.ACTION_DOWN:
173            case MotionEvent.ACTION_MOVE:
174                itemAction = ITEM_ACTION_HIGHLIGHT;
175                break;
176            case MotionEvent.ACTION_UP:
177                itemAction = ITEM_ACTION_PERFORM;
178                break;
179            default:
180                break;
181        }
182        didPerformClick = menuItemAction(roundedRawX, roundedRawY, itemAction);
183
184        if (eventActionMasked == MotionEvent.ACTION_UP && !didPerformClick) {
185            UmaBridge.usingMenu(false, true);
186            mAppMenu.dismiss();
187        } else if (eventActionMasked == MotionEvent.ACTION_MOVE) {
188            // Auto scrolling on the top or the bottom of the listView.
189            if (listView.getHeight() > 0) {
190                float autoScrollAreaRatio = Math.min(AUTO_SCROLL_AREA_MAX_RATIO,
191                        mItemRowHeight * 1.2f / listView.getHeight());
192                float normalizedY =
193                        (rawY - getScreenVisibleRect(listView).top) / listView.getHeight();
194                if (normalizedY < autoScrollAreaRatio) {
195                    // Top
196                    mDragScrollingVelocity = (normalizedY / autoScrollAreaRatio - 1.0f)
197                            * mAutoScrollFullVelocity;
198                } else if (normalizedY > 1.0f - autoScrollAreaRatio) {
199                    // Bottom
200                    mDragScrollingVelocity = ((normalizedY - 1.0f) / autoScrollAreaRatio + 1.0f)
201                            * mAutoScrollFullVelocity;
202                } else {
203                    // Middle or not scrollable.
204                    mDragScrollingVelocity = 0.0f;
205                }
206            }
207        }
208
209        return true;
210    }
211
212    /**
213     * Performs the specified action on the menu item specified by the screen coordinate position.
214     * @param screenX X in screen space coordinate.
215     * @param screenY Y in screen space coordinate.
216     * @param action  Action type to perform, it should be one of ITEM_ACTION_* constants.
217     * @return true whether or not a menu item is performed (executed).
218     */
219    private boolean menuItemAction(int screenX, int screenY, int action) {
220        ListView listView = mAppMenu.getPopup().getListView();
221
222        ArrayList<View> itemViews = new ArrayList<View>();
223        for (int i = 0; i < listView.getChildCount(); ++i) {
224            boolean hasImageButtons = false;
225            if (listView.getChildAt(i) instanceof LinearLayout) {
226                LinearLayout layout = (LinearLayout) listView.getChildAt(i);
227                for (int j = 0; j < layout.getChildCount(); ++j) {
228                    itemViews.add(layout.getChildAt(j));
229                    if (layout.getChildAt(j) instanceof ImageButton) hasImageButtons = true;
230                }
231            }
232            if (!hasImageButtons) itemViews.add(listView.getChildAt(i));
233        }
234
235        boolean didPerformClick = false;
236        for (int i = 0; i < itemViews.size(); ++i) {
237            View itemView = itemViews.get(i);
238
239            boolean shouldPerform = itemView.isEnabled() && itemView.isShown() &&
240                    getScreenVisibleRect(itemView).contains(screenX, screenY);
241
242            switch (action) {
243                case ITEM_ACTION_HIGHLIGHT:
244                    itemView.setPressed(shouldPerform);
245                    break;
246                case ITEM_ACTION_PERFORM:
247                    if (shouldPerform) {
248                        UmaBridge.usingMenu(false, true);
249                        itemView.performClick();
250                        didPerformClick = true;
251                    }
252                    break;
253                case ITEM_ACTION_CLEAR_HIGHLIGHT_ALL:
254                    itemView.setPressed(false);
255                    break;
256                default:
257                    assert false;
258                    break;
259            }
260        }
261        return didPerformClick;
262    }
263
264    /**
265     * @return Visible rect in screen coordinates for the given View.
266     */
267    private Rect getScreenVisibleRect(View view) {
268        view.getLocalVisibleRect(mScreenVisibleRect);
269        view.getLocationOnScreen(mScreenVisiblePoint);
270        mScreenVisibleRect.offset(mScreenVisiblePoint[0], mScreenVisiblePoint[1]);
271        return mScreenVisibleRect;
272    }
273}
274