SwipeHelper.java revision 1d59af45f699f11ee559c38698369fd6d159e87a
1/*
2 * Copyright (C) 2011 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 com.android.systemui;
18
19import android.animation.Animator;
20import android.animation.AnimatorListenerAdapter;
21import android.animation.ObjectAnimator;
22import android.animation.ValueAnimator;
23import android.animation.ValueAnimator.AnimatorUpdateListener;
24import android.content.Context;
25import android.graphics.RectF;
26import android.os.Handler;
27import android.util.Log;
28import android.view.MotionEvent;
29import android.view.VelocityTracker;
30import android.view.View;
31import android.view.ViewConfiguration;
32import android.view.accessibility.AccessibilityEvent;
33import android.view.animation.AnimationUtils;
34import android.view.animation.Interpolator;
35import android.view.animation.LinearInterpolator;
36
37public class SwipeHelper implements Gefingerpoken {
38    static final String TAG = "com.android.systemui.SwipeHelper";
39    private static final boolean DEBUG = false;
40    private static final boolean DEBUG_INVALIDATE = false;
41    private static final boolean SLOW_ANIMATIONS = false; // DEBUG;
42    private static final boolean CONSTRAIN_SWIPE = true;
43    private static final boolean FADE_OUT_DURING_SWIPE = true;
44    private static final boolean DISMISS_IF_SWIPED_FAR_ENOUGH = true;
45
46    public static final int X = 0;
47    public static final int Y = 1;
48
49    private static LinearInterpolator sLinearInterpolator = new LinearInterpolator();
50    private final Interpolator mFastOutLinearInInterpolator;
51
52    private float SWIPE_ESCAPE_VELOCITY = 100f; // dp/sec
53    private int DEFAULT_ESCAPE_ANIMATION_DURATION = 200; // ms
54    private int MAX_ESCAPE_ANIMATION_DURATION = 400; // ms
55    private int MAX_DISMISS_VELOCITY = 2000; // dp/sec
56    private static final int SNAP_ANIM_LEN = SLOW_ANIMATIONS ? 1000 : 150; // ms
57
58    public static float SWIPE_PROGRESS_FADE_START = 0f; // fraction of thumbnail width
59                                                 // where fade starts
60    static final float SWIPE_PROGRESS_FADE_END = 0.5f; // fraction of thumbnail width
61                                              // beyond which swipe progress->0
62    private float mMinSwipeProgress = 0f;
63    private float mMaxSwipeProgress = 1f;
64
65    private float mPagingTouchSlop;
66    private Callback mCallback;
67    private Handler mHandler;
68    private int mSwipeDirection;
69    private VelocityTracker mVelocityTracker;
70
71    private float mInitialTouchPos;
72    private boolean mDragging;
73    private View mCurrView;
74    private View mCurrAnimView;
75    private boolean mCanCurrViewBeDimissed;
76    private float mDensityScale;
77
78    private boolean mLongPressSent;
79    private LongPressListener mLongPressListener;
80    private Runnable mWatchLongPress;
81    private long mLongPressTimeout;
82
83    final private int[] mTmpPos = new int[2];
84    private int mFalsingThreshold;
85    private boolean mTouchAboveFalsingThreshold;
86
87    public SwipeHelper(int swipeDirection, Callback callback, Context context) {
88        mCallback = callback;
89        mHandler = new Handler();
90        mSwipeDirection = swipeDirection;
91        mVelocityTracker = VelocityTracker.obtain();
92        mDensityScale =  context.getResources().getDisplayMetrics().density;
93        mPagingTouchSlop = ViewConfiguration.get(context).getScaledPagingTouchSlop();
94
95        mLongPressTimeout = (long) (ViewConfiguration.getLongPressTimeout() * 1.5f); // extra long-press!
96        mFastOutLinearInInterpolator = AnimationUtils.loadInterpolator(context,
97                android.R.interpolator.fast_out_linear_in);
98        mFalsingThreshold = context.getResources().getDimensionPixelSize(
99                R.dimen.swipe_helper_falsing_threshold);
100    }
101
102    public void setLongPressListener(LongPressListener listener) {
103        mLongPressListener = listener;
104    }
105
106    public void setDensityScale(float densityScale) {
107        mDensityScale = densityScale;
108    }
109
110    public void setPagingTouchSlop(float pagingTouchSlop) {
111        mPagingTouchSlop = pagingTouchSlop;
112    }
113
114    private float getPos(MotionEvent ev) {
115        return mSwipeDirection == X ? ev.getX() : ev.getY();
116    }
117
118    private float getTranslation(View v) {
119        return mSwipeDirection == X ? v.getTranslationX() : v.getTranslationY();
120    }
121
122    private float getVelocity(VelocityTracker vt) {
123        return mSwipeDirection == X ? vt.getXVelocity() :
124                vt.getYVelocity();
125    }
126
127    private ObjectAnimator createTranslationAnimation(View v, float newPos) {
128        ObjectAnimator anim = ObjectAnimator.ofFloat(v,
129                mSwipeDirection == X ? "translationX" : "translationY", newPos);
130        return anim;
131    }
132
133    private float getPerpendicularVelocity(VelocityTracker vt) {
134        return mSwipeDirection == X ? vt.getYVelocity() :
135                vt.getXVelocity();
136    }
137
138    private void setTranslation(View v, float translate) {
139        if (mSwipeDirection == X) {
140            v.setTranslationX(translate);
141        } else {
142            v.setTranslationY(translate);
143        }
144    }
145
146    private float getSize(View v) {
147        return mSwipeDirection == X ? v.getMeasuredWidth() :
148                v.getMeasuredHeight();
149    }
150
151    public void setMinSwipeProgress(float minSwipeProgress) {
152        mMinSwipeProgress = minSwipeProgress;
153    }
154
155    public void setMaxSwipeProgress(float maxSwipeProgress) {
156        mMaxSwipeProgress = maxSwipeProgress;
157    }
158
159    private float getSwipeProgressForOffset(View view) {
160        float viewSize = getSize(view);
161        final float fadeSize = SWIPE_PROGRESS_FADE_END * viewSize;
162        float result = 1.0f;
163        float pos = getTranslation(view);
164        if (pos >= viewSize * SWIPE_PROGRESS_FADE_START) {
165            result = 1.0f - (pos - viewSize * SWIPE_PROGRESS_FADE_START) / fadeSize;
166        } else if (pos < viewSize * (1.0f - SWIPE_PROGRESS_FADE_START)) {
167            result = 1.0f + (viewSize * SWIPE_PROGRESS_FADE_START + pos) / fadeSize;
168        }
169        return Math.min(Math.max(mMinSwipeProgress, result), mMaxSwipeProgress);
170    }
171
172    private void updateSwipeProgressFromOffset(View animView, boolean dismissable) {
173        float swipeProgress = getSwipeProgressForOffset(animView);
174        if (!mCallback.updateSwipeProgress(animView, dismissable, swipeProgress)) {
175            if (FADE_OUT_DURING_SWIPE && dismissable) {
176                float alpha = swipeProgress;
177                if (alpha != 0f && alpha != 1f) {
178                    animView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
179                } else {
180                    animView.setLayerType(View.LAYER_TYPE_NONE, null);
181                }
182                animView.setAlpha(getSwipeProgressForOffset(animView));
183            }
184        }
185        invalidateGlobalRegion(animView);
186    }
187
188    // invalidate the view's own bounds all the way up the view hierarchy
189    public static void invalidateGlobalRegion(View view) {
190        invalidateGlobalRegion(
191            view,
192            new RectF(view.getLeft(), view.getTop(), view.getRight(), view.getBottom()));
193    }
194
195    // invalidate a rectangle relative to the view's coordinate system all the way up the view
196    // hierarchy
197    public static void invalidateGlobalRegion(View view, RectF childBounds) {
198        //childBounds.offset(view.getTranslationX(), view.getTranslationY());
199        if (DEBUG_INVALIDATE)
200            Log.v(TAG, "-------------");
201        while (view.getParent() != null && view.getParent() instanceof View) {
202            view = (View) view.getParent();
203            view.getMatrix().mapRect(childBounds);
204            view.invalidate((int) Math.floor(childBounds.left),
205                            (int) Math.floor(childBounds.top),
206                            (int) Math.ceil(childBounds.right),
207                            (int) Math.ceil(childBounds.bottom));
208            if (DEBUG_INVALIDATE) {
209                Log.v(TAG, "INVALIDATE(" + (int) Math.floor(childBounds.left)
210                        + "," + (int) Math.floor(childBounds.top)
211                        + "," + (int) Math.ceil(childBounds.right)
212                        + "," + (int) Math.ceil(childBounds.bottom));
213            }
214        }
215    }
216
217    public void removeLongPressCallback() {
218        if (mWatchLongPress != null) {
219            mHandler.removeCallbacks(mWatchLongPress);
220            mWatchLongPress = null;
221        }
222    }
223
224    public boolean onInterceptTouchEvent(final MotionEvent ev) {
225        final int action = ev.getAction();
226
227        switch (action) {
228            case MotionEvent.ACTION_DOWN:
229                mTouchAboveFalsingThreshold = false;
230                mDragging = false;
231                mLongPressSent = false;
232                mCurrView = mCallback.getChildAtPosition(ev);
233                mVelocityTracker.clear();
234                if (mCurrView != null) {
235                    mCurrAnimView = mCallback.getChildContentView(mCurrView);
236                    mCanCurrViewBeDimissed = mCallback.canChildBeDismissed(mCurrView);
237                    mVelocityTracker.addMovement(ev);
238                    mInitialTouchPos = getPos(ev);
239
240                    if (mLongPressListener != null) {
241                        if (mWatchLongPress == null) {
242                            mWatchLongPress = new Runnable() {
243                                @Override
244                                public void run() {
245                                    if (mCurrView != null && !mLongPressSent) {
246                                        mLongPressSent = true;
247                                        mCurrView.sendAccessibilityEvent(
248                                                AccessibilityEvent.TYPE_VIEW_LONG_CLICKED);
249                                        mCurrView.getLocationOnScreen(mTmpPos);
250                                        final int x = (int) ev.getRawX() - mTmpPos[0];
251                                        final int y = (int) ev.getRawY() - mTmpPos[1];
252                                        mLongPressListener.onLongPress(mCurrView, x, y);
253                                    }
254                                }
255                            };
256                        }
257                        mHandler.postDelayed(mWatchLongPress, mLongPressTimeout);
258                    }
259
260                }
261                break;
262
263            case MotionEvent.ACTION_MOVE:
264                if (mCurrView != null && !mLongPressSent) {
265                    mVelocityTracker.addMovement(ev);
266                    float pos = getPos(ev);
267                    float delta = pos - mInitialTouchPos;
268                    if (Math.abs(delta) > mPagingTouchSlop) {
269                        mCallback.onBeginDrag(mCurrView);
270                        mDragging = true;
271                        mInitialTouchPos = getPos(ev) - getTranslation(mCurrAnimView);
272
273                        removeLongPressCallback();
274                    }
275                }
276
277                break;
278
279            case MotionEvent.ACTION_UP:
280            case MotionEvent.ACTION_CANCEL:
281                final boolean captured = (mDragging || mLongPressSent);
282                mDragging = false;
283                mCurrView = null;
284                mCurrAnimView = null;
285                mLongPressSent = false;
286                removeLongPressCallback();
287                if (captured) return true;
288                break;
289        }
290        return mDragging || mLongPressSent;
291    }
292
293    /**
294     * @param view The view to be dismissed
295     * @param velocity The desired pixels/second speed at which the view should move
296     */
297    public void dismissChild(final View view, float velocity) {
298        dismissChild(view, velocity, null, 0, false, 0);
299    }
300
301    /**
302     * @param view The view to be dismissed
303     * @param velocity The desired pixels/second speed at which the view should move
304     * @param endAction The action to perform at the end
305     * @param delay The delay after which we should start
306     * @param useAccelerateInterpolator Should an accelerating Interpolator be used
307     * @param fixedDuration If not 0, this exact duration will be taken
308     */
309    public void dismissChild(final View view, float velocity, final Runnable endAction,
310            long delay, boolean useAccelerateInterpolator, long fixedDuration) {
311        final View animView = mCallback.getChildContentView(view);
312        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view);
313        float newPos;
314        boolean isLayoutRtl = view.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
315
316        if (velocity < 0
317                || (velocity == 0 && getTranslation(animView) < 0)
318                // if we use the Menu to dismiss an item in landscape, animate up
319                || (velocity == 0 && getTranslation(animView) == 0 && mSwipeDirection == Y)
320                // if the language is rtl we prefer swiping to the left
321                || (velocity == 0 && getTranslation(animView) == 0 && isLayoutRtl)) {
322            newPos = -getSize(animView);
323        } else {
324            newPos = getSize(animView);
325        }
326        long duration;
327        if (fixedDuration == 0) {
328            duration = MAX_ESCAPE_ANIMATION_DURATION;
329            if (velocity != 0) {
330                duration = Math.min(duration,
331                        (int) (Math.abs(newPos - getTranslation(animView)) * 1000f / Math
332                                .abs(velocity))
333                );
334            } else {
335                duration = DEFAULT_ESCAPE_ANIMATION_DURATION;
336            }
337        } else {
338            duration = fixedDuration;
339        }
340
341        animView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
342        ObjectAnimator anim = createTranslationAnimation(animView, newPos);
343        if (useAccelerateInterpolator) {
344            anim.setInterpolator(mFastOutLinearInInterpolator);
345        } else {
346            anim.setInterpolator(sLinearInterpolator);
347        }
348        anim.setDuration(duration);
349        if (delay > 0) {
350            anim.setStartDelay(delay);
351        }
352        anim.addListener(new AnimatorListenerAdapter() {
353            public void onAnimationEnd(Animator animation) {
354                mCallback.onChildDismissed(view);
355                if (endAction != null) {
356                    endAction.run();
357                }
358                animView.setLayerType(View.LAYER_TYPE_NONE, null);
359            }
360        });
361        anim.addUpdateListener(new AnimatorUpdateListener() {
362            public void onAnimationUpdate(ValueAnimator animation) {
363                updateSwipeProgressFromOffset(animView, canAnimViewBeDismissed);
364            }
365        });
366        anim.start();
367    }
368
369    public void snapChild(final View view, float velocity) {
370        final View animView = mCallback.getChildContentView(view);
371        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(animView);
372        ObjectAnimator anim = createTranslationAnimation(animView, 0);
373        int duration = SNAP_ANIM_LEN;
374        anim.setDuration(duration);
375        anim.addUpdateListener(new AnimatorUpdateListener() {
376            public void onAnimationUpdate(ValueAnimator animation) {
377                updateSwipeProgressFromOffset(animView, canAnimViewBeDismissed);
378            }
379        });
380        anim.addListener(new AnimatorListenerAdapter() {
381            public void onAnimationEnd(Animator animator) {
382                updateSwipeProgressFromOffset(animView, canAnimViewBeDismissed);
383                mCallback.onChildSnappedBack(animView);
384            }
385        });
386        anim.start();
387    }
388
389    public boolean onTouchEvent(MotionEvent ev) {
390        if (mLongPressSent) {
391            return true;
392        }
393
394        if (!mDragging) {
395            if (mCallback.getChildAtPosition(ev) != null) {
396
397                // We are dragging directly over a card, make sure that we also catch the gesture
398                // even if nobody else wants the touch event.
399                onInterceptTouchEvent(ev);
400                return true;
401            } else {
402
403                // We are not doing anything, make sure the long press callback
404                // is not still ticking like a bomb waiting to go off.
405                removeLongPressCallback();
406                return false;
407            }
408        }
409
410        mVelocityTracker.addMovement(ev);
411        final int action = ev.getAction();
412        switch (action) {
413            case MotionEvent.ACTION_OUTSIDE:
414            case MotionEvent.ACTION_MOVE:
415                if (mCurrView != null) {
416                    float delta = getPos(ev) - mInitialTouchPos;
417                    float absDelta = Math.abs(delta);
418                    if (absDelta >= getFalsingThreshold()) {
419                        mTouchAboveFalsingThreshold = true;
420                    }
421                    // don't let items that can't be dismissed be dragged more than
422                    // maxScrollDistance
423                    if (CONSTRAIN_SWIPE && !mCallback.canChildBeDismissed(mCurrView)) {
424                        float size = getSize(mCurrAnimView);
425                        float maxScrollDistance = 0.15f * size;
426                        if (absDelta >= size) {
427                            delta = delta > 0 ? maxScrollDistance : -maxScrollDistance;
428                        } else {
429                            delta = maxScrollDistance * (float) Math.sin((delta/size)*(Math.PI/2));
430                        }
431                    }
432                    setTranslation(mCurrAnimView, delta);
433
434                    updateSwipeProgressFromOffset(mCurrAnimView, mCanCurrViewBeDimissed);
435                }
436                break;
437            case MotionEvent.ACTION_UP:
438            case MotionEvent.ACTION_CANCEL:
439                if (mCurrView != null) {
440                    float maxVelocity = MAX_DISMISS_VELOCITY * mDensityScale;
441                    mVelocityTracker.computeCurrentVelocity(1000 /* px/sec */, maxVelocity);
442                    float escapeVelocity = SWIPE_ESCAPE_VELOCITY * mDensityScale;
443                    float velocity = getVelocity(mVelocityTracker);
444                    float perpendicularVelocity = getPerpendicularVelocity(mVelocityTracker);
445
446                    // Decide whether to dismiss the current view
447                    boolean childSwipedFarEnough = DISMISS_IF_SWIPED_FAR_ENOUGH &&
448                            Math.abs(getTranslation(mCurrAnimView)) > 0.4 * getSize(mCurrAnimView);
449                    boolean childSwipedFastEnough = (Math.abs(velocity) > escapeVelocity) &&
450                            (Math.abs(velocity) > Math.abs(perpendicularVelocity)) &&
451                            (velocity > 0) == (getTranslation(mCurrAnimView) > 0);
452                    boolean falsingDetected = mCallback.isAntiFalsingNeeded()
453                            && !mTouchAboveFalsingThreshold;
454
455                    boolean dismissChild = mCallback.canChildBeDismissed(mCurrView)
456                            && !falsingDetected && (childSwipedFastEnough || childSwipedFarEnough);
457
458                    if (dismissChild) {
459                        // flingadingy
460                        dismissChild(mCurrView, childSwipedFastEnough ? velocity : 0f);
461                    } else {
462                        // snappity
463                        mCallback.onDragCancelled(mCurrView);
464                        snapChild(mCurrView, velocity);
465                    }
466                }
467                break;
468        }
469        return true;
470    }
471
472    private int getFalsingThreshold() {
473        float factor = mCallback.getFalsingThresholdFactor();
474        return (int) (mFalsingThreshold * factor);
475    }
476
477    public interface Callback {
478        View getChildAtPosition(MotionEvent ev);
479
480        View getChildContentView(View v);
481
482        boolean canChildBeDismissed(View v);
483
484        boolean isAntiFalsingNeeded();
485
486        void onBeginDrag(View v);
487
488        void onChildDismissed(View v);
489
490        void onDragCancelled(View v);
491
492        void onChildSnappedBack(View animView);
493
494        /**
495         * Updates the swipe progress on a child.
496         *
497         * @return if true, prevents the default alpha fading.
498         */
499        boolean updateSwipeProgress(View animView, boolean dismissable, float swipeProgress);
500
501        /**
502         * @return The factor the falsing threshold should be multiplied with
503         */
504        float getFalsingThresholdFactor();
505    }
506
507    /**
508     * Equivalent to View.OnLongClickListener with coordinates
509     */
510    public interface LongPressListener {
511        /**
512         * Equivalent to {@link View.OnLongClickListener#onLongClick(View)} with coordinates
513         * @return whether the longpress was handled
514         */
515        boolean onLongPress(View v, int x, int y);
516    }
517}
518