1/*
2 * Copyright (C) 2017 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 */
16package com.android.launcher3.touch;
17
18import static android.view.MotionEvent.INVALID_POINTER_ID;
19
20import android.content.Context;
21import android.graphics.PointF;
22import android.support.annotation.NonNull;
23import android.support.annotation.VisibleForTesting;
24import android.util.Log;
25import android.view.MotionEvent;
26import android.view.ViewConfiguration;
27
28/**
29 * One dimensional scroll/drag/swipe gesture detector.
30 *
31 * Definition of swipe is different from android system in that this detector handles
32 * 'swipe to dismiss', 'swiping up/down a container' but also keeps scrolling state before
33 * swipe action happens
34 */
35public class SwipeDetector {
36
37    private static final boolean DBG = false;
38    private static final String TAG = "SwipeDetector";
39
40    private int mScrollConditions;
41    public static final int DIRECTION_POSITIVE = 1 << 0;
42    public static final int DIRECTION_NEGATIVE = 1 << 1;
43    public static final int DIRECTION_BOTH = DIRECTION_NEGATIVE | DIRECTION_POSITIVE;
44
45    private static final float ANIMATION_DURATION = 1200;
46
47    protected int mActivePointerId = INVALID_POINTER_ID;
48
49    /**
50     * The minimum release velocity in pixels per millisecond that triggers fling..
51     */
52    public static final float RELEASE_VELOCITY_PX_MS = 1.0f;
53
54    /**
55     * The time constant used to calculate dampening in the low-pass filter of scroll velocity.
56     * Cutoff frequency is set at 10 Hz.
57     */
58    public static final float SCROLL_VELOCITY_DAMPENING_RC = 1000f / (2f * (float) Math.PI * 10);
59
60    /* Scroll state, this is set to true during dragging and animation. */
61    private ScrollState mState = ScrollState.IDLE;
62
63    enum ScrollState {
64        IDLE,
65        DRAGGING,      // onDragStart, onDrag
66        SETTLING       // onDragEnd
67    }
68
69    public static abstract class Direction {
70
71        abstract float getDisplacement(MotionEvent ev, int pointerIndex, PointF refPoint);
72
73        /**
74         * Distance in pixels a touch can wander before we think the user is scrolling.
75         */
76        abstract float getActiveTouchSlop(MotionEvent ev, int pointerIndex, PointF downPos);
77    }
78
79    public static final Direction VERTICAL = new Direction() {
80
81        @Override
82        float getDisplacement(MotionEvent ev, int pointerIndex, PointF refPoint) {
83            return ev.getY(pointerIndex) - refPoint.y;
84        }
85
86        @Override
87        float getActiveTouchSlop(MotionEvent ev, int pointerIndex, PointF downPos) {
88            return Math.abs(ev.getX(pointerIndex) - downPos.x);
89        }
90    };
91
92    public static final Direction HORIZONTAL = new Direction() {
93
94        @Override
95        float getDisplacement(MotionEvent ev, int pointerIndex, PointF refPoint) {
96            return ev.getX(pointerIndex) - refPoint.x;
97        }
98
99        @Override
100        float getActiveTouchSlop(MotionEvent ev, int pointerIndex, PointF downPos) {
101            return Math.abs(ev.getY(pointerIndex) - downPos.y);
102        }
103    };
104
105    //------------------- ScrollState transition diagram -----------------------------------
106    //
107    // IDLE ->      (mDisplacement > mTouchSlop) -> DRAGGING
108    // DRAGGING -> (MotionEvent#ACTION_UP, MotionEvent#ACTION_CANCEL) -> SETTLING
109    // SETTLING -> (MotionEvent#ACTION_DOWN) -> DRAGGING
110    // SETTLING -> (View settled) -> IDLE
111
112    private void setState(ScrollState newState) {
113        if (DBG) {
114            Log.d(TAG, "setState:" + mState + "->" + newState);
115        }
116        // onDragStart and onDragEnd is reported ONLY on state transition
117        if (newState == ScrollState.DRAGGING) {
118            initializeDragging();
119            if (mState == ScrollState.IDLE) {
120                reportDragStart(false /* recatch */);
121            } else if (mState == ScrollState.SETTLING) {
122                reportDragStart(true /* recatch */);
123            }
124        }
125        if (newState == ScrollState.SETTLING) {
126            reportDragEnd();
127        }
128
129        mState = newState;
130    }
131
132    public boolean isDraggingOrSettling() {
133        return mState == ScrollState.DRAGGING || mState == ScrollState.SETTLING;
134    }
135
136    /**
137     * There's no touch and there's no animation.
138     */
139    public boolean isIdleState() {
140        return mState == ScrollState.IDLE;
141    }
142
143    public boolean isSettlingState() {
144        return mState == ScrollState.SETTLING;
145    }
146
147    public boolean isDraggingState() {
148        return mState == ScrollState.DRAGGING;
149    }
150
151    private final PointF mDownPos = new PointF();
152    private final PointF mLastPos = new PointF();
153    private Direction mDir;
154
155    private final float mTouchSlop;
156
157    /* Client of this gesture detector can register a callback. */
158    private final Listener mListener;
159
160    private long mCurrentMillis;
161
162    private float mVelocity;
163    private float mLastDisplacement;
164    private float mDisplacement;
165
166    private float mSubtractDisplacement;
167    private boolean mIgnoreSlopWhenSettling;
168
169    public interface Listener {
170        void onDragStart(boolean start);
171
172        boolean onDrag(float displacement, float velocity);
173
174        void onDragEnd(float velocity, boolean fling);
175    }
176
177    public SwipeDetector(@NonNull Context context, @NonNull Listener l, @NonNull Direction dir) {
178        this(ViewConfiguration.get(context).getScaledTouchSlop(), l, dir);
179    }
180
181    @VisibleForTesting
182    protected SwipeDetector(float touchSlope, @NonNull Listener l, @NonNull Direction dir) {
183        mTouchSlop = touchSlope;
184        mListener = l;
185        mDir = dir;
186    }
187
188    public void updateDirection(Direction dir) {
189        mDir = dir;
190    }
191
192    public void setDetectableScrollConditions(int scrollDirectionFlags, boolean ignoreSlop) {
193        mScrollConditions = scrollDirectionFlags;
194        mIgnoreSlopWhenSettling = ignoreSlop;
195    }
196
197    public int getScrollDirections() {
198        return mScrollConditions;
199    }
200
201    private boolean shouldScrollStart(MotionEvent ev, int pointerIndex) {
202        // reject cases where the angle or slop condition is not met.
203        if (Math.max(mDir.getActiveTouchSlop(ev, pointerIndex, mDownPos), mTouchSlop)
204                > Math.abs(mDisplacement)) {
205            return false;
206        }
207
208        // Check if the client is interested in scroll in current direction.
209        if (((mScrollConditions & DIRECTION_NEGATIVE) > 0 && mDisplacement > 0) ||
210                ((mScrollConditions & DIRECTION_POSITIVE) > 0 && mDisplacement < 0)) {
211            return true;
212        }
213        return false;
214    }
215
216    public boolean onTouchEvent(MotionEvent ev) {
217        switch (ev.getActionMasked()) {
218            case MotionEvent.ACTION_DOWN:
219                mActivePointerId = ev.getPointerId(0);
220                mDownPos.set(ev.getX(), ev.getY());
221                mLastPos.set(mDownPos);
222                mLastDisplacement = 0;
223                mDisplacement = 0;
224                mVelocity = 0;
225
226                if (mState == ScrollState.SETTLING && mIgnoreSlopWhenSettling) {
227                    setState(ScrollState.DRAGGING);
228                }
229                break;
230            //case MotionEvent.ACTION_POINTER_DOWN:
231            case MotionEvent.ACTION_POINTER_UP:
232                int ptrIdx = ev.getActionIndex();
233                int ptrId = ev.getPointerId(ptrIdx);
234                if (ptrId == mActivePointerId) {
235                    final int newPointerIdx = ptrIdx == 0 ? 1 : 0;
236                    mDownPos.set(
237                            ev.getX(newPointerIdx) - (mLastPos.x - mDownPos.x),
238                            ev.getY(newPointerIdx) - (mLastPos.y - mDownPos.y));
239                    mLastPos.set(ev.getX(newPointerIdx), ev.getY(newPointerIdx));
240                    mActivePointerId = ev.getPointerId(newPointerIdx);
241                }
242                break;
243            case MotionEvent.ACTION_MOVE:
244                int pointerIndex = ev.findPointerIndex(mActivePointerId);
245                if (pointerIndex == INVALID_POINTER_ID) {
246                    break;
247                }
248                mDisplacement = mDir.getDisplacement(ev, pointerIndex, mDownPos);
249                computeVelocity(mDir.getDisplacement(ev, pointerIndex, mLastPos),
250                        ev.getEventTime());
251
252                // handle state and listener calls.
253                if (mState != ScrollState.DRAGGING && shouldScrollStart(ev, pointerIndex)) {
254                    setState(ScrollState.DRAGGING);
255                }
256                if (mState == ScrollState.DRAGGING) {
257                    reportDragging();
258                }
259                mLastPos.set(ev.getX(pointerIndex), ev.getY(pointerIndex));
260                break;
261            case MotionEvent.ACTION_CANCEL:
262            case MotionEvent.ACTION_UP:
263                // These are synthetic events and there is no need to update internal values.
264                if (mState == ScrollState.DRAGGING) {
265                    setState(ScrollState.SETTLING);
266                }
267                break;
268            default:
269                break;
270        }
271        return true;
272    }
273
274    public void finishedScrolling() {
275        setState(ScrollState.IDLE);
276    }
277
278    private boolean reportDragStart(boolean recatch) {
279        mListener.onDragStart(!recatch);
280        if (DBG) {
281            Log.d(TAG, "onDragStart recatch:" + recatch);
282        }
283        return true;
284    }
285
286    private void initializeDragging() {
287        if (mState == ScrollState.SETTLING && mIgnoreSlopWhenSettling) {
288            mSubtractDisplacement = 0;
289        }
290        if (mDisplacement > 0) {
291            mSubtractDisplacement = mTouchSlop;
292        } else {
293            mSubtractDisplacement = -mTouchSlop;
294        }
295    }
296
297    /**
298     * Returns if the start drag was towards the positive direction or negative.
299     *
300     * @see #setDetectableScrollConditions(int, boolean)
301     * @see #DIRECTION_BOTH
302     */
303    public boolean wasInitialTouchPositive() {
304        return mSubtractDisplacement < 0;
305    }
306
307    private boolean reportDragging() {
308        if (mDisplacement != mLastDisplacement) {
309            if (DBG) {
310                Log.d(TAG, String.format("onDrag disp=%.1f, velocity=%.1f",
311                        mDisplacement, mVelocity));
312            }
313
314            mLastDisplacement = mDisplacement;
315            return mListener.onDrag(mDisplacement - mSubtractDisplacement, mVelocity);
316        }
317        return true;
318    }
319
320    private void reportDragEnd() {
321        if (DBG) {
322            Log.d(TAG, String.format("onScrollEnd disp=%.1f, velocity=%.1f",
323                    mDisplacement, mVelocity));
324        }
325        mListener.onDragEnd(mVelocity, Math.abs(mVelocity) > RELEASE_VELOCITY_PX_MS);
326
327    }
328
329    /**
330     * Computes the damped velocity.
331     */
332    public float computeVelocity(float delta, long currentMillis) {
333        long previousMillis = mCurrentMillis;
334        mCurrentMillis = currentMillis;
335
336        float deltaTimeMillis = mCurrentMillis - previousMillis;
337        float velocity = (deltaTimeMillis > 0) ? (delta / deltaTimeMillis) : 0;
338        if (Math.abs(mVelocity) < 0.001f) {
339            mVelocity = velocity;
340        } else {
341            float alpha = computeDampeningFactor(deltaTimeMillis);
342            mVelocity = interpolate(mVelocity, velocity, alpha);
343        }
344        return mVelocity;
345    }
346
347    /**
348     * Returns a time-dependent dampening factor using delta time.
349     */
350    private static float computeDampeningFactor(float deltaTime) {
351        return deltaTime / (SCROLL_VELOCITY_DAMPENING_RC + deltaTime);
352    }
353
354    /**
355     * Returns the linear interpolation between two values
356     */
357    public static float interpolate(float from, float to, float alpha) {
358        return (1.0f - alpha) * from + alpha * to;
359    }
360
361    public static long calculateDuration(float velocity, float progressNeeded) {
362        // TODO: make these values constants after tuning.
363        float velocityDivisor = Math.max(2f, Math.abs(0.5f * velocity));
364        float travelDistance = Math.max(0.2f, progressNeeded);
365        long duration = (long) Math.max(100, ANIMATION_DURATION / velocityDivisor * travelDistance);
366        if (DBG) {
367            Log.d(TAG, String.format("calculateDuration=%d, v=%f, d=%f", duration, velocity, progressNeeded));
368        }
369        return duration;
370    }
371}
372