1/*
2 * Copyright (C) 2012 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
17package android.support.v4.view;
18
19import android.content.Context;
20import android.os.Build;
21import android.os.Handler;
22import android.os.Message;
23import android.view.GestureDetector;
24import android.view.GestureDetector.OnDoubleTapListener;
25import android.view.GestureDetector.OnGestureListener;
26import android.view.MotionEvent;
27import android.view.VelocityTracker;
28import android.view.View;
29import android.view.ViewConfiguration;
30
31/**
32 * Detects various gestures and events using the supplied {@link MotionEvent}s.
33 * The {@link OnGestureListener} callback will notify users when a particular
34 * motion event has occurred. This class should only be used with {@link MotionEvent}s
35 * reported via touch (don't use for trackball events).
36 *
37 * <p>This compatibility implementation of the framework's GestureDetector guarantees
38 * the newer focal point scrolling behavior from Jellybean MR1 on all platform versions.</p>
39 *
40 * To use this class:
41 * <ul>
42 *  <li>Create an instance of the {@code GestureDetectorCompat} for your {@link View}
43 *  <li>In the {@link View#onTouchEvent(MotionEvent)} method ensure you call
44 *          {@link #onTouchEvent(MotionEvent)}. The methods defined in your callback
45 *          will be executed when the events occur.
46 * </ul>
47 */
48public final class GestureDetectorCompat {
49    interface GestureDetectorCompatImpl {
50        boolean isLongpressEnabled();
51        boolean onTouchEvent(MotionEvent ev);
52        void setIsLongpressEnabled(boolean enabled);
53        void setOnDoubleTapListener(OnDoubleTapListener listener);
54    }
55
56    static class GestureDetectorCompatImplBase implements GestureDetectorCompatImpl {
57        private int mTouchSlopSquare;
58        private int mDoubleTapSlopSquare;
59        private int mMinimumFlingVelocity;
60        private int mMaximumFlingVelocity;
61
62        private static final int LONGPRESS_TIMEOUT = ViewConfiguration.getLongPressTimeout();
63        private static final int TAP_TIMEOUT = ViewConfiguration.getTapTimeout();
64        private static final int DOUBLE_TAP_TIMEOUT = ViewConfiguration.getDoubleTapTimeout();
65
66        // constants for Message.what used by GestureHandler below
67        private static final int SHOW_PRESS = 1;
68        private static final int LONG_PRESS = 2;
69        private static final int TAP = 3;
70
71        private final Handler mHandler;
72        private final OnGestureListener mListener;
73        private OnDoubleTapListener mDoubleTapListener;
74
75        private boolean mStillDown;
76        private boolean mDeferConfirmSingleTap;
77        private boolean mInLongPress;
78        private boolean mAlwaysInTapRegion;
79        private boolean mAlwaysInBiggerTapRegion;
80
81        private MotionEvent mCurrentDownEvent;
82        private MotionEvent mPreviousUpEvent;
83
84        /**
85         * True when the user is still touching for the second tap (down, move, and
86         * up events). Can only be true if there is a double tap listener attached.
87         */
88        private boolean mIsDoubleTapping;
89
90        private float mLastFocusX;
91        private float mLastFocusY;
92        private float mDownFocusX;
93        private float mDownFocusY;
94
95        private boolean mIsLongpressEnabled;
96
97        /**
98         * Determines speed during touch scrolling
99         */
100        private VelocityTracker mVelocityTracker;
101
102        private class GestureHandler extends Handler {
103            GestureHandler() {
104                super();
105            }
106
107            GestureHandler(Handler handler) {
108                super(handler.getLooper());
109            }
110
111            @Override
112            public void handleMessage(Message msg) {
113                switch (msg.what) {
114                case SHOW_PRESS:
115                    mListener.onShowPress(mCurrentDownEvent);
116                    break;
117
118                case LONG_PRESS:
119                    dispatchLongPress();
120                    break;
121
122                case TAP:
123                    // If the user's finger is still down, do not count it as a tap
124                    if (mDoubleTapListener != null) {
125                        if (!mStillDown) {
126                            mDoubleTapListener.onSingleTapConfirmed(mCurrentDownEvent);
127                        } else {
128                            mDeferConfirmSingleTap = true;
129                        }
130                    }
131                    break;
132
133                default:
134                    throw new RuntimeException("Unknown message " + msg); //never
135                }
136            }
137        }
138
139        /**
140         * Creates a GestureDetector with the supplied listener.
141         * You may only use this constructor from a UI thread (this is the usual situation).
142         * @see android.os.Handler#Handler()
143         *
144         * @param context the application's context
145         * @param listener the listener invoked for all the callbacks, this must
146         * not be null.
147         * @param handler the handler to use
148         *
149         * @throws NullPointerException if {@code listener} is null.
150         */
151        public GestureDetectorCompatImplBase(Context context, OnGestureListener listener,
152                Handler handler) {
153            if (handler != null) {
154                mHandler = new GestureHandler(handler);
155            } else {
156                mHandler = new GestureHandler();
157            }
158            mListener = listener;
159            if (listener instanceof OnDoubleTapListener) {
160                setOnDoubleTapListener((OnDoubleTapListener) listener);
161            }
162            init(context);
163        }
164
165        private void init(Context context) {
166            if (context == null) {
167                throw new IllegalArgumentException("Context must not be null");
168            }
169            if (mListener == null) {
170                throw new IllegalArgumentException("OnGestureListener must not be null");
171            }
172            mIsLongpressEnabled = true;
173
174            final ViewConfiguration configuration = ViewConfiguration.get(context);
175            final int touchSlop = configuration.getScaledTouchSlop();
176            final int doubleTapSlop = configuration.getScaledDoubleTapSlop();
177            mMinimumFlingVelocity = configuration.getScaledMinimumFlingVelocity();
178            mMaximumFlingVelocity = configuration.getScaledMaximumFlingVelocity();
179
180            mTouchSlopSquare = touchSlop * touchSlop;
181            mDoubleTapSlopSquare = doubleTapSlop * doubleTapSlop;
182        }
183
184        /**
185         * Sets the listener which will be called for double-tap and related
186         * gestures.
187         *
188         * @param onDoubleTapListener the listener invoked for all the callbacks, or
189         *        null to stop listening for double-tap gestures.
190         */
191        public void setOnDoubleTapListener(OnDoubleTapListener onDoubleTapListener) {
192            mDoubleTapListener = onDoubleTapListener;
193        }
194
195        /**
196         * Set whether longpress is enabled, if this is enabled when a user
197         * presses and holds down you get a longpress event and nothing further.
198         * If it's disabled the user can press and hold down and then later
199         * moved their finger and you will get scroll events. By default
200         * longpress is enabled.
201         *
202         * @param isLongpressEnabled whether longpress should be enabled.
203         */
204        public void setIsLongpressEnabled(boolean isLongpressEnabled) {
205            mIsLongpressEnabled = isLongpressEnabled;
206        }
207
208        /**
209         * @return true if longpress is enabled, else false.
210         */
211        public boolean isLongpressEnabled() {
212            return mIsLongpressEnabled;
213        }
214
215        /**
216         * Analyzes the given motion event and if applicable triggers the
217         * appropriate callbacks on the {@link OnGestureListener} supplied.
218         *
219         * @param ev The current motion event.
220         * @return true if the {@link OnGestureListener} consumed the event,
221         *              else false.
222         */
223        public boolean onTouchEvent(MotionEvent ev) {
224            final int action = ev.getAction();
225
226            if (mVelocityTracker == null) {
227                mVelocityTracker = VelocityTracker.obtain();
228            }
229            mVelocityTracker.addMovement(ev);
230
231            final boolean pointerUp =
232                    (action & MotionEventCompat.ACTION_MASK) == MotionEventCompat.ACTION_POINTER_UP;
233            final int skipIndex = pointerUp ? MotionEventCompat.getActionIndex(ev) : -1;
234
235            // Determine focal point
236            float sumX = 0, sumY = 0;
237            final int count = MotionEventCompat.getPointerCount(ev);
238            for (int i = 0; i < count; i++) {
239                if (skipIndex == i) continue;
240                sumX += MotionEventCompat.getX(ev, i);
241                sumY += MotionEventCompat.getY(ev, i);
242            }
243            final int div = pointerUp ? count - 1 : count;
244            final float focusX = sumX / div;
245            final float focusY = sumY / div;
246
247            boolean handled = false;
248
249            switch (action & MotionEventCompat.ACTION_MASK) {
250            case MotionEventCompat.ACTION_POINTER_DOWN:
251                mDownFocusX = mLastFocusX = focusX;
252                mDownFocusY = mLastFocusY = focusY;
253                // Cancel long press and taps
254                cancelTaps();
255                break;
256
257            case MotionEventCompat.ACTION_POINTER_UP:
258                mDownFocusX = mLastFocusX = focusX;
259                mDownFocusY = mLastFocusY = focusY;
260
261                // Check the dot product of current velocities.
262                // If the pointer that left was opposing another velocity vector, clear.
263                mVelocityTracker.computeCurrentVelocity(1000, mMaximumFlingVelocity);
264                final int upIndex = MotionEventCompat.getActionIndex(ev);
265                final int id1 = MotionEventCompat.getPointerId(ev, upIndex);
266                final float x1 = VelocityTrackerCompat.getXVelocity(mVelocityTracker, id1);
267                final float y1 = VelocityTrackerCompat.getYVelocity(mVelocityTracker, id1);
268                for (int i = 0; i < count; i++) {
269                    if (i == upIndex) continue;
270
271                    final int id2 = MotionEventCompat.getPointerId(ev, i);
272                    final float x = x1 * VelocityTrackerCompat.getXVelocity(mVelocityTracker, id2);
273                    final float y = y1 * VelocityTrackerCompat.getYVelocity(mVelocityTracker, id2);
274
275                    final float dot = x + y;
276                    if (dot < 0) {
277                        mVelocityTracker.clear();
278                        break;
279                    }
280                }
281                break;
282
283            case MotionEvent.ACTION_DOWN:
284                if (mDoubleTapListener != null) {
285                    boolean hadTapMessage = mHandler.hasMessages(TAP);
286                    if (hadTapMessage) mHandler.removeMessages(TAP);
287                    if ((mCurrentDownEvent != null) && (mPreviousUpEvent != null) && hadTapMessage &&
288                            isConsideredDoubleTap(mCurrentDownEvent, mPreviousUpEvent, ev)) {
289                        // This is a second tap
290                        mIsDoubleTapping = true;
291                        // Give a callback with the first tap of the double-tap
292                        handled |= mDoubleTapListener.onDoubleTap(mCurrentDownEvent);
293                        // Give a callback with down event of the double-tap
294                        handled |= mDoubleTapListener.onDoubleTapEvent(ev);
295                    } else {
296                        // This is a first tap
297                        mHandler.sendEmptyMessageDelayed(TAP, DOUBLE_TAP_TIMEOUT);
298                    }
299                }
300
301                mDownFocusX = mLastFocusX = focusX;
302                mDownFocusY = mLastFocusY = focusY;
303                if (mCurrentDownEvent != null) {
304                    mCurrentDownEvent.recycle();
305                }
306                mCurrentDownEvent = MotionEvent.obtain(ev);
307                mAlwaysInTapRegion = true;
308                mAlwaysInBiggerTapRegion = true;
309                mStillDown = true;
310                mInLongPress = false;
311                mDeferConfirmSingleTap = false;
312
313                if (mIsLongpressEnabled) {
314                    mHandler.removeMessages(LONG_PRESS);
315                    mHandler.sendEmptyMessageAtTime(LONG_PRESS, mCurrentDownEvent.getDownTime()
316                            + TAP_TIMEOUT + LONGPRESS_TIMEOUT);
317                }
318                mHandler.sendEmptyMessageAtTime(SHOW_PRESS, mCurrentDownEvent.getDownTime() + TAP_TIMEOUT);
319                handled |= mListener.onDown(ev);
320                break;
321
322            case MotionEvent.ACTION_MOVE:
323                if (mInLongPress) {
324                    break;
325                }
326                final float scrollX = mLastFocusX - focusX;
327                final float scrollY = mLastFocusY - focusY;
328                if (mIsDoubleTapping) {
329                    // Give the move events of the double-tap
330                    handled |= mDoubleTapListener.onDoubleTapEvent(ev);
331                } else if (mAlwaysInTapRegion) {
332                    final int deltaX = (int) (focusX - mDownFocusX);
333                    final int deltaY = (int) (focusY - mDownFocusY);
334                    int distance = (deltaX * deltaX) + (deltaY * deltaY);
335                    if (distance > mTouchSlopSquare) {
336                        handled = mListener.onScroll(mCurrentDownEvent, ev, scrollX, scrollY);
337                        mLastFocusX = focusX;
338                        mLastFocusY = focusY;
339                        mAlwaysInTapRegion = false;
340                        mHandler.removeMessages(TAP);
341                        mHandler.removeMessages(SHOW_PRESS);
342                        mHandler.removeMessages(LONG_PRESS);
343                    }
344                    if (distance > mTouchSlopSquare) {
345                        mAlwaysInBiggerTapRegion = false;
346                    }
347                } else if ((Math.abs(scrollX) >= 1) || (Math.abs(scrollY) >= 1)) {
348                    handled = mListener.onScroll(mCurrentDownEvent, ev, scrollX, scrollY);
349                    mLastFocusX = focusX;
350                    mLastFocusY = focusY;
351                }
352                break;
353
354            case MotionEvent.ACTION_UP:
355                mStillDown = false;
356                MotionEvent currentUpEvent = MotionEvent.obtain(ev);
357                if (mIsDoubleTapping) {
358                    // Finally, give the up event of the double-tap
359                    handled |= mDoubleTapListener.onDoubleTapEvent(ev);
360                } else if (mInLongPress) {
361                    mHandler.removeMessages(TAP);
362                    mInLongPress = false;
363                } else if (mAlwaysInTapRegion) {
364                    handled = mListener.onSingleTapUp(ev);
365                    if (mDeferConfirmSingleTap && mDoubleTapListener != null) {
366                        mDoubleTapListener.onSingleTapConfirmed(ev);
367                    }
368                } else {
369                    // A fling must travel the minimum tap distance
370                    final VelocityTracker velocityTracker = mVelocityTracker;
371                    final int pointerId = MotionEventCompat.getPointerId(ev, 0);
372                    velocityTracker.computeCurrentVelocity(1000, mMaximumFlingVelocity);
373                    final float velocityY = VelocityTrackerCompat.getYVelocity(
374                            velocityTracker, pointerId);
375                    final float velocityX = VelocityTrackerCompat.getXVelocity(
376                            velocityTracker, pointerId);
377
378                    if ((Math.abs(velocityY) > mMinimumFlingVelocity)
379                            || (Math.abs(velocityX) > mMinimumFlingVelocity)){
380                        handled = mListener.onFling(mCurrentDownEvent, ev, velocityX, velocityY);
381                    }
382                }
383                if (mPreviousUpEvent != null) {
384                    mPreviousUpEvent.recycle();
385                }
386                // Hold the event we obtained above - listeners may have changed the original.
387                mPreviousUpEvent = currentUpEvent;
388                if (mVelocityTracker != null) {
389                    // This may have been cleared when we called out to the
390                    // application above.
391                    mVelocityTracker.recycle();
392                    mVelocityTracker = null;
393                }
394                mIsDoubleTapping = false;
395                mDeferConfirmSingleTap = false;
396                mHandler.removeMessages(SHOW_PRESS);
397                mHandler.removeMessages(LONG_PRESS);
398                break;
399
400            case MotionEvent.ACTION_CANCEL:
401                cancel();
402                break;
403            }
404
405            return handled;
406        }
407
408        private void cancel() {
409            mHandler.removeMessages(SHOW_PRESS);
410            mHandler.removeMessages(LONG_PRESS);
411            mHandler.removeMessages(TAP);
412            mVelocityTracker.recycle();
413            mVelocityTracker = null;
414            mIsDoubleTapping = false;
415            mStillDown = false;
416            mAlwaysInTapRegion = false;
417            mAlwaysInBiggerTapRegion = false;
418            mDeferConfirmSingleTap = false;
419            if (mInLongPress) {
420                mInLongPress = false;
421            }
422        }
423
424        private void cancelTaps() {
425            mHandler.removeMessages(SHOW_PRESS);
426            mHandler.removeMessages(LONG_PRESS);
427            mHandler.removeMessages(TAP);
428            mIsDoubleTapping = false;
429            mAlwaysInTapRegion = false;
430            mAlwaysInBiggerTapRegion = false;
431            mDeferConfirmSingleTap = false;
432            if (mInLongPress) {
433                mInLongPress = false;
434            }
435        }
436
437        private boolean isConsideredDoubleTap(MotionEvent firstDown, MotionEvent firstUp,
438                MotionEvent secondDown) {
439            if (!mAlwaysInBiggerTapRegion) {
440                return false;
441            }
442
443            if (secondDown.getEventTime() - firstUp.getEventTime() > DOUBLE_TAP_TIMEOUT) {
444                return false;
445            }
446
447            int deltaX = (int) firstDown.getX() - (int) secondDown.getX();
448            int deltaY = (int) firstDown.getY() - (int) secondDown.getY();
449            return (deltaX * deltaX + deltaY * deltaY < mDoubleTapSlopSquare);
450        }
451
452        private void dispatchLongPress() {
453            mHandler.removeMessages(TAP);
454            mDeferConfirmSingleTap = false;
455            mInLongPress = true;
456            mListener.onLongPress(mCurrentDownEvent);
457        }
458    }
459
460    static class GestureDetectorCompatImplJellybeanMr2 implements GestureDetectorCompatImpl {
461        private final GestureDetector mDetector;
462
463        public GestureDetectorCompatImplJellybeanMr2(Context context, OnGestureListener listener,
464                Handler handler) {
465            mDetector = new GestureDetector(context, listener, handler);
466        }
467
468        @Override
469        public boolean isLongpressEnabled() {
470            return mDetector.isLongpressEnabled();
471        }
472
473        @Override
474        public boolean onTouchEvent(MotionEvent ev) {
475            return mDetector.onTouchEvent(ev);
476        }
477
478        @Override
479        public void setIsLongpressEnabled(boolean enabled) {
480            mDetector.setIsLongpressEnabled(enabled);
481        }
482
483        @Override
484        public void setOnDoubleTapListener(OnDoubleTapListener listener) {
485            mDetector.setOnDoubleTapListener(listener);
486        }
487    }
488
489    private final GestureDetectorCompatImpl mImpl;
490
491    /**
492     * Creates a GestureDetectorCompat with the supplied listener.
493     * As usual, you may only use this constructor from a UI thread.
494     * @see android.os.Handler#Handler()
495     *
496     * @param context the application's context
497     * @param listener the listener invoked for all the callbacks, this must
498     * not be null.
499     */
500    public GestureDetectorCompat(Context context, OnGestureListener listener) {
501        this(context, listener, null);
502    }
503
504    /**
505     * Creates a GestureDetectorCompat with the supplied listener.
506     * As usual, you may only use this constructor from a UI thread.
507     * @see android.os.Handler#Handler()
508     *
509     * @param context the application's context
510     * @param listener the listener invoked for all the callbacks, this must
511     * not be null.
512     * @param handler the handler that will be used for posting deferred messages
513     */
514    public GestureDetectorCompat(Context context, OnGestureListener listener, Handler handler) {
515        if (Build.VERSION.SDK_INT > 17) {
516            mImpl = new GestureDetectorCompatImplJellybeanMr2(context, listener, handler);
517        } else {
518            mImpl = new GestureDetectorCompatImplBase(context, listener, handler);
519        }
520    }
521
522    /**
523     * @return true if longpress is enabled, else false.
524     */
525    public boolean isLongpressEnabled() {
526        return mImpl.isLongpressEnabled();
527    }
528
529    /**
530     * Analyzes the given motion event and if applicable triggers the
531     * appropriate callbacks on the {@link OnGestureListener} supplied.
532     *
533     * @param event The current motion event.
534     * @return true if the {@link OnGestureListener} consumed the event,
535     *              else false.
536     */
537    public boolean onTouchEvent(MotionEvent event) {
538        return mImpl.onTouchEvent(event);
539    }
540
541    /**
542     * Set whether longpress is enabled, if this is enabled when a user
543     * presses and holds down you get a longpress event and nothing further.
544     * If it's disabled the user can press and hold down and then later
545     * moved their finger and you will get scroll events. By default
546     * longpress is enabled.
547     *
548     * @param enabled whether longpress should be enabled.
549     */
550    public void setIsLongpressEnabled(boolean enabled) {
551        mImpl.setIsLongpressEnabled(enabled);
552    }
553
554    /**
555     * Sets the listener which will be called for double-tap and related
556     * gestures.
557     *
558     * @param listener the listener invoked for all the callbacks, or
559     *        null to stop listening for double-tap gestures.
560     */
561    public void setOnDoubleTapListener(OnDoubleTapListener listener) {
562        mImpl.setOnDoubleTapListener(listener);
563    }
564}
565