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        final OnGestureListener mListener;
73        OnDoubleTapListener mDoubleTapListener;
74
75        boolean mStillDown;
76        boolean mDeferConfirmSingleTap;
77        private boolean mInLongPress;
78        private boolean mAlwaysInTapRegion;
79        private boolean mAlwaysInBiggerTapRegion;
80
81        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        @Override
192        public void setOnDoubleTapListener(OnDoubleTapListener onDoubleTapListener) {
193            mDoubleTapListener = onDoubleTapListener;
194        }
195
196        /**
197         * Set whether longpress is enabled, if this is enabled when a user
198         * presses and holds down you get a longpress event and nothing further.
199         * If it's disabled the user can press and hold down and then later
200         * moved their finger and you will get scroll events. By default
201         * longpress is enabled.
202         *
203         * @param isLongpressEnabled whether longpress should be enabled.
204         */
205        @Override
206        public void setIsLongpressEnabled(boolean isLongpressEnabled) {
207            mIsLongpressEnabled = isLongpressEnabled;
208        }
209
210        /**
211         * @return true if longpress is enabled, else false.
212         */
213        @Override
214        public boolean isLongpressEnabled() {
215            return mIsLongpressEnabled;
216        }
217
218        /**
219         * Analyzes the given motion event and if applicable triggers the
220         * appropriate callbacks on the {@link OnGestureListener} supplied.
221         *
222         * @param ev The current motion event.
223         * @return true if the {@link OnGestureListener} consumed the event,
224         *              else false.
225         */
226        @Override
227        public boolean onTouchEvent(MotionEvent ev) {
228            final int action = ev.getAction();
229
230            if (mVelocityTracker == null) {
231                mVelocityTracker = VelocityTracker.obtain();
232            }
233            mVelocityTracker.addMovement(ev);
234
235            final boolean pointerUp =
236                    (action & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_POINTER_UP;
237            final int skipIndex = pointerUp ? ev.getActionIndex() : -1;
238
239            // Determine focal point
240            float sumX = 0, sumY = 0;
241            final int count = ev.getPointerCount();
242            for (int i = 0; i < count; i++) {
243                if (skipIndex == i) continue;
244                sumX += ev.getX(i);
245                sumY += ev.getY(i);
246            }
247            final int div = pointerUp ? count - 1 : count;
248            final float focusX = sumX / div;
249            final float focusY = sumY / div;
250
251            boolean handled = false;
252
253            switch (action & MotionEvent.ACTION_MASK) {
254                case MotionEvent.ACTION_POINTER_DOWN:
255                    mDownFocusX = mLastFocusX = focusX;
256                    mDownFocusY = mLastFocusY = focusY;
257                    // Cancel long press and taps
258                    cancelTaps();
259                    break;
260
261                case MotionEvent.ACTION_POINTER_UP:
262                    mDownFocusX = mLastFocusX = focusX;
263                    mDownFocusY = mLastFocusY = focusY;
264
265                    // Check the dot product of current velocities.
266                    // If the pointer that left was opposing another velocity vector, clear.
267                    mVelocityTracker.computeCurrentVelocity(1000, mMaximumFlingVelocity);
268                    final int upIndex = ev.getActionIndex();
269                    final int id1 = ev.getPointerId(upIndex);
270                    final float x1 = mVelocityTracker.getXVelocity(id1);
271                    final float y1 = mVelocityTracker.getYVelocity(id1);
272                    for (int i = 0; i < count; i++) {
273                        if (i == upIndex) continue;
274
275                        final int id2 = ev.getPointerId(i);
276                        final float x = x1 * mVelocityTracker.getXVelocity(id2);
277                        final float y = y1 * mVelocityTracker.getYVelocity(id2);
278
279                        final float dot = x + y;
280                        if (dot < 0) {
281                            mVelocityTracker.clear();
282                            break;
283                        }
284                    }
285                    break;
286
287                case MotionEvent.ACTION_DOWN:
288                    if (mDoubleTapListener != null) {
289                        boolean hadTapMessage = mHandler.hasMessages(TAP);
290                        if (hadTapMessage) mHandler.removeMessages(TAP);
291                        if ((mCurrentDownEvent != null) && (mPreviousUpEvent != null)
292                                && hadTapMessage && isConsideredDoubleTap(
293                                        mCurrentDownEvent, mPreviousUpEvent, ev)) {
294                            // This is a second tap
295                            mIsDoubleTapping = true;
296                            // Give a callback with the first tap of the double-tap
297                            handled |= mDoubleTapListener.onDoubleTap(mCurrentDownEvent);
298                            // Give a callback with down event of the double-tap
299                            handled |= mDoubleTapListener.onDoubleTapEvent(ev);
300                        } else {
301                            // This is a first tap
302                            mHandler.sendEmptyMessageDelayed(TAP, DOUBLE_TAP_TIMEOUT);
303                        }
304                    }
305
306                    mDownFocusX = mLastFocusX = focusX;
307                    mDownFocusY = mLastFocusY = focusY;
308                    if (mCurrentDownEvent != null) {
309                        mCurrentDownEvent.recycle();
310                    }
311                    mCurrentDownEvent = MotionEvent.obtain(ev);
312                    mAlwaysInTapRegion = true;
313                    mAlwaysInBiggerTapRegion = true;
314                    mStillDown = true;
315                    mInLongPress = false;
316                    mDeferConfirmSingleTap = false;
317
318                    if (mIsLongpressEnabled) {
319                        mHandler.removeMessages(LONG_PRESS);
320                        mHandler.sendEmptyMessageAtTime(LONG_PRESS, mCurrentDownEvent.getDownTime()
321                                + TAP_TIMEOUT + LONGPRESS_TIMEOUT);
322                    }
323                    mHandler.sendEmptyMessageAtTime(SHOW_PRESS,
324                            mCurrentDownEvent.getDownTime() + TAP_TIMEOUT);
325                    handled |= mListener.onDown(ev);
326                    break;
327
328                case MotionEvent.ACTION_MOVE:
329                    if (mInLongPress) {
330                        break;
331                    }
332                    final float scrollX = mLastFocusX - focusX;
333                    final float scrollY = mLastFocusY - focusY;
334                    if (mIsDoubleTapping) {
335                        // Give the move events of the double-tap
336                        handled |= mDoubleTapListener.onDoubleTapEvent(ev);
337                    } else if (mAlwaysInTapRegion) {
338                        final int deltaX = (int) (focusX - mDownFocusX);
339                        final int deltaY = (int) (focusY - mDownFocusY);
340                        int distance = (deltaX * deltaX) + (deltaY * deltaY);
341                        if (distance > mTouchSlopSquare) {
342                            handled = mListener.onScroll(mCurrentDownEvent, ev, scrollX, scrollY);
343                            mLastFocusX = focusX;
344                            mLastFocusY = focusY;
345                            mAlwaysInTapRegion = false;
346                            mHandler.removeMessages(TAP);
347                            mHandler.removeMessages(SHOW_PRESS);
348                            mHandler.removeMessages(LONG_PRESS);
349                        }
350                        if (distance > mTouchSlopSquare) {
351                            mAlwaysInBiggerTapRegion = false;
352                        }
353                    } else if ((Math.abs(scrollX) >= 1) || (Math.abs(scrollY) >= 1)) {
354                        handled = mListener.onScroll(mCurrentDownEvent, ev, scrollX, scrollY);
355                        mLastFocusX = focusX;
356                        mLastFocusY = focusY;
357                    }
358                    break;
359
360                case MotionEvent.ACTION_UP:
361                    mStillDown = false;
362                    MotionEvent currentUpEvent = MotionEvent.obtain(ev);
363                    if (mIsDoubleTapping) {
364                        // Finally, give the up event of the double-tap
365                        handled |= mDoubleTapListener.onDoubleTapEvent(ev);
366                    } else if (mInLongPress) {
367                        mHandler.removeMessages(TAP);
368                        mInLongPress = false;
369                    } else if (mAlwaysInTapRegion) {
370                        handled = mListener.onSingleTapUp(ev);
371                        if (mDeferConfirmSingleTap && mDoubleTapListener != null) {
372                            mDoubleTapListener.onSingleTapConfirmed(ev);
373                        }
374                    } else {
375                        // A fling must travel the minimum tap distance
376                        final VelocityTracker velocityTracker = mVelocityTracker;
377                        final int pointerId = ev.getPointerId(0);
378                        velocityTracker.computeCurrentVelocity(1000, mMaximumFlingVelocity);
379                        final float velocityY = velocityTracker.getYVelocity(pointerId);
380                        final float velocityX = velocityTracker.getXVelocity(pointerId);
381
382                        if ((Math.abs(velocityY) > mMinimumFlingVelocity)
383                                || (Math.abs(velocityX) > mMinimumFlingVelocity)) {
384                            handled = mListener.onFling(
385                                    mCurrentDownEvent, ev, velocityX, velocityY);
386                        }
387                    }
388                    if (mPreviousUpEvent != null) {
389                        mPreviousUpEvent.recycle();
390                    }
391                    // Hold the event we obtained above - listeners may have changed the original.
392                    mPreviousUpEvent = currentUpEvent;
393                    if (mVelocityTracker != null) {
394                        // This may have been cleared when we called out to the
395                        // application above.
396                        mVelocityTracker.recycle();
397                        mVelocityTracker = null;
398                    }
399                    mIsDoubleTapping = false;
400                    mDeferConfirmSingleTap = false;
401                    mHandler.removeMessages(SHOW_PRESS);
402                    mHandler.removeMessages(LONG_PRESS);
403                    break;
404
405                case MotionEvent.ACTION_CANCEL:
406                    cancel();
407                    break;
408            }
409
410            return handled;
411        }
412
413        private void cancel() {
414            mHandler.removeMessages(SHOW_PRESS);
415            mHandler.removeMessages(LONG_PRESS);
416            mHandler.removeMessages(TAP);
417            mVelocityTracker.recycle();
418            mVelocityTracker = null;
419            mIsDoubleTapping = false;
420            mStillDown = false;
421            mAlwaysInTapRegion = false;
422            mAlwaysInBiggerTapRegion = false;
423            mDeferConfirmSingleTap = false;
424            if (mInLongPress) {
425                mInLongPress = false;
426            }
427        }
428
429        private void cancelTaps() {
430            mHandler.removeMessages(SHOW_PRESS);
431            mHandler.removeMessages(LONG_PRESS);
432            mHandler.removeMessages(TAP);
433            mIsDoubleTapping = false;
434            mAlwaysInTapRegion = false;
435            mAlwaysInBiggerTapRegion = false;
436            mDeferConfirmSingleTap = false;
437            if (mInLongPress) {
438                mInLongPress = false;
439            }
440        }
441
442        private boolean isConsideredDoubleTap(MotionEvent firstDown, MotionEvent firstUp,
443                MotionEvent secondDown) {
444            if (!mAlwaysInBiggerTapRegion) {
445                return false;
446            }
447
448            if (secondDown.getEventTime() - firstUp.getEventTime() > DOUBLE_TAP_TIMEOUT) {
449                return false;
450            }
451
452            int deltaX = (int) firstDown.getX() - (int) secondDown.getX();
453            int deltaY = (int) firstDown.getY() - (int) secondDown.getY();
454            return (deltaX * deltaX + deltaY * deltaY < mDoubleTapSlopSquare);
455        }
456
457        void dispatchLongPress() {
458            mHandler.removeMessages(TAP);
459            mDeferConfirmSingleTap = false;
460            mInLongPress = true;
461            mListener.onLongPress(mCurrentDownEvent);
462        }
463    }
464
465    static class GestureDetectorCompatImplJellybeanMr2 implements GestureDetectorCompatImpl {
466        private final GestureDetector mDetector;
467
468        public GestureDetectorCompatImplJellybeanMr2(Context context, OnGestureListener listener,
469                Handler handler) {
470            mDetector = new GestureDetector(context, listener, handler);
471        }
472
473        @Override
474        public boolean isLongpressEnabled() {
475            return mDetector.isLongpressEnabled();
476        }
477
478        @Override
479        public boolean onTouchEvent(MotionEvent ev) {
480            return mDetector.onTouchEvent(ev);
481        }
482
483        @Override
484        public void setIsLongpressEnabled(boolean enabled) {
485            mDetector.setIsLongpressEnabled(enabled);
486        }
487
488        @Override
489        public void setOnDoubleTapListener(OnDoubleTapListener listener) {
490            mDetector.setOnDoubleTapListener(listener);
491        }
492    }
493
494    private final GestureDetectorCompatImpl mImpl;
495
496    /**
497     * Creates a GestureDetectorCompat with the supplied listener.
498     * As usual, you may only use this constructor from a UI thread.
499     * @see android.os.Handler#Handler()
500     *
501     * @param context the application's context
502     * @param listener the listener invoked for all the callbacks, this must
503     * not be null.
504     */
505    public GestureDetectorCompat(Context context, OnGestureListener listener) {
506        this(context, listener, null);
507    }
508
509    /**
510     * Creates a GestureDetectorCompat with the supplied listener.
511     * As usual, you may only use this constructor from a UI thread.
512     * @see android.os.Handler#Handler()
513     *
514     * @param context the application's context
515     * @param listener the listener invoked for all the callbacks, this must
516     * not be null.
517     * @param handler the handler that will be used for posting deferred messages
518     */
519    public GestureDetectorCompat(Context context, OnGestureListener listener, Handler handler) {
520        if (Build.VERSION.SDK_INT > 17) {
521            mImpl = new GestureDetectorCompatImplJellybeanMr2(context, listener, handler);
522        } else {
523            mImpl = new GestureDetectorCompatImplBase(context, listener, handler);
524        }
525    }
526
527    /**
528     * @return true if longpress is enabled, else false.
529     */
530    public boolean isLongpressEnabled() {
531        return mImpl.isLongpressEnabled();
532    }
533
534    /**
535     * Analyzes the given motion event and if applicable triggers the
536     * appropriate callbacks on the {@link OnGestureListener} supplied.
537     *
538     * @param event The current motion event.
539     * @return true if the {@link OnGestureListener} consumed the event,
540     *              else false.
541     */
542    public boolean onTouchEvent(MotionEvent event) {
543        return mImpl.onTouchEvent(event);
544    }
545
546    /**
547     * Set whether longpress is enabled, if this is enabled when a user
548     * presses and holds down you get a longpress event and nothing further.
549     * If it's disabled the user can press and hold down and then later
550     * moved their finger and you will get scroll events. By default
551     * longpress is enabled.
552     *
553     * @param enabled whether longpress should be enabled.
554     */
555    public void setIsLongpressEnabled(boolean enabled) {
556        mImpl.setIsLongpressEnabled(enabled);
557    }
558
559    /**
560     * Sets the listener which will be called for double-tap and related
561     * gestures.
562     *
563     * @param listener the listener invoked for all the callbacks, or
564     *        null to stop listening for double-tap gestures.
565     */
566    public void setOnDoubleTapListener(OnDoubleTapListener listener) {
567        mImpl.setOnDoubleTapListener(listener);
568    }
569}
570