SwipeHelper.java revision 2102b2c87a6cb39bc63fb7eff13dda64da56b72e
1/*
2 * Copyright (C) 2012 Google Inc.
3 * Licensed to The Android Open Source Project.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 *      http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18package com.android.mail.ui;
19
20import android.animation.Animator;
21import android.animation.AnimatorListenerAdapter;
22import android.animation.AnimatorSet;
23import android.animation.ObjectAnimator;
24import android.animation.ValueAnimator;
25import android.animation.ValueAnimator.AnimatorUpdateListener;
26import android.content.Context;
27import android.content.res.Resources;
28import android.graphics.RectF;
29import android.util.Log;
30import android.view.MotionEvent;
31import android.view.VelocityTracker;
32import android.view.View;
33import android.view.animation.DecelerateInterpolator;
34
35import com.android.mail.R;
36import com.android.mail.browse.ConversationItemView;
37import com.android.mail.utils.Utils;
38
39import java.util.ArrayList;
40import java.util.Collection;
41
42public class SwipeHelper {
43    static final String TAG = "com.android.systemui.SwipeHelper";
44    private static final boolean DEBUG_INVALIDATE = false;
45    private static final boolean CONSTRAIN_SWIPE = true;
46    private static final boolean FADE_OUT_DURING_SWIPE = true;
47    private static final boolean DISMISS_IF_SWIPED_FAR_ENOUGH = true;
48    // Turn on for debugging only during development.
49    private static final boolean LOG_SWIPE_DISMISS_VELOCITY = false;
50
51    public static final int X = 0;
52    public static final int Y = 1;
53
54    private static DecelerateInterpolator sDecelerateInterpolator =
55                                                        new DecelerateInterpolator(1.0f);
56
57    private static int SWIPE_ESCAPE_VELOCITY = -1;
58    private static int DEFAULT_ESCAPE_ANIMATION_DURATION;
59    private static int MAX_ESCAPE_ANIMATION_DURATION;
60    private static int MAX_DISMISS_VELOCITY;
61    private static int SNAP_ANIM_LEN;
62    private static int DISMISS_ANIMATION_DURATION;
63    private static float MIN_SWIPE;
64    private static float MIN_VERT;
65    private static float MIN_LOCK;
66
67    public static float ALPHA_FADE_START = 0f; // fraction of thumbnail width
68                                                 // where fade starts
69    public static float ALPHA_TEXT_FADE_START = 0.4f;
70    static final float ALPHA_FADE_END = 0.7f; // fraction of thumbnail width
71                                              // beyond which alpha->0
72    private static final float FACTOR = 1.2f;
73    private float mMinAlpha = 0.5f;
74
75    /* Dead region where swipe cannot be initiated. */
76    private final static int DEAD_REGION_FOR_SWIPE = 56;
77
78    private float mPagingTouchSlop;
79    private final Callback mCallback;
80    private final int mSwipeDirection;
81    private final VelocityTracker mVelocityTracker;
82
83    private float mInitialTouchPosX;
84    private boolean mDragging;
85    private SwipeableItemView mCurrView;
86    private View mCurrAnimView;
87    private boolean mCanCurrViewBeDimissed;
88    private float mDensityScale;
89    private float mLastY;
90    private float mInitialTouchPosY;
91    private LeaveBehindItem mPrevView;
92
93    public SwipeHelper(Context context, int swipeDirection, Callback callback, float densityScale,
94            float pagingTouchSlop) {
95        mCallback = callback;
96        mSwipeDirection = swipeDirection;
97        mVelocityTracker = VelocityTracker.obtain();
98        mDensityScale = densityScale;
99        mPagingTouchSlop = pagingTouchSlop;
100        if (SWIPE_ESCAPE_VELOCITY == -1) {
101            Resources res = context.getResources();
102            SWIPE_ESCAPE_VELOCITY = res.getInteger(R.integer.swipe_escape_velocity);
103            DEFAULT_ESCAPE_ANIMATION_DURATION = res.getInteger(R.integer.escape_animation_duration);
104            MAX_ESCAPE_ANIMATION_DURATION = res.getInteger(R.integer.max_escape_animation_duration);
105            MAX_DISMISS_VELOCITY = res.getInteger(R.integer.max_dismiss_velocity);
106            SNAP_ANIM_LEN = res.getInteger(R.integer.snap_animation_duration);
107            DISMISS_ANIMATION_DURATION = res.getInteger(R.integer.dismiss_animation_duration);
108            MIN_SWIPE = res.getDimension(R.dimen.min_swipe);
109            MIN_VERT = res.getDimension(R.dimen.min_vert);
110            MIN_LOCK = res.getDimension(R.dimen.min_lock);
111        }
112    }
113
114    public void setDensityScale(float densityScale) {
115        mDensityScale = densityScale;
116    }
117
118    public void setPagingTouchSlop(float pagingTouchSlop) {
119        mPagingTouchSlop = pagingTouchSlop;
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 ObjectAnimator createDismissAnimation(View v, float newPos, int duration) {
134        ObjectAnimator anim = createTranslationAnimation(v, newPos);
135        anim.setInterpolator(sDecelerateInterpolator);
136        anim.setDuration(duration);
137        return anim;
138    }
139
140    private float getPerpendicularVelocity(VelocityTracker vt) {
141        return mSwipeDirection == X ? vt.getYVelocity() :
142                vt.getXVelocity();
143    }
144
145    private void setTranslation(View v, float translate) {
146        if (mSwipeDirection == X) {
147            v.setTranslationX(translate);
148        } else {
149            v.setTranslationY(translate);
150        }
151    }
152
153    private float getSize(View v) {
154        return mSwipeDirection == X ? v.getMeasuredWidth() :
155                v.getMeasuredHeight();
156    }
157
158    public void setMinAlpha(float minAlpha) {
159        mMinAlpha = minAlpha;
160    }
161
162    private float getAlphaForOffset(View view) {
163        float viewSize = getSize(view);
164        final float fadeSize = ALPHA_FADE_END * viewSize;
165        float result = 1.0f;
166        float pos = view.getTranslationX();
167        if (pos >= viewSize * ALPHA_FADE_START) {
168            result = 1.0f - (pos - viewSize * ALPHA_FADE_START) / fadeSize;
169        } else if (pos < viewSize * (1.0f - ALPHA_FADE_START)) {
170            result = 1.0f + (viewSize * ALPHA_FADE_START + pos) / fadeSize;
171        }
172        return Math.max(mMinAlpha, result);
173    }
174
175    private float getTextAlphaForOffset(View view) {
176        float viewSize = getSize(view);
177        final float fadeSize = ALPHA_TEXT_FADE_START * viewSize;
178        float result = 1.0f;
179        float pos = view.getTranslationX();
180        if (pos >= 0) {
181            result = 1.0f - pos / fadeSize;
182        } else if (pos < 0) {
183            result = 1.0f + pos / fadeSize;
184        }
185        return Math.max(0, result);
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 boolean onInterceptTouchEvent(MotionEvent ev) {
218        final int action = ev.getAction();
219        switch (action) {
220            case MotionEvent.ACTION_DOWN:
221                mLastY = ev.getY();
222                mDragging = false;
223                View view = mCallback.getChildAtPosition(ev);
224                if (view instanceof SwipeableItemView) {
225                    mCurrView = (SwipeableItemView) view;
226                }
227                mVelocityTracker.clear();
228                if (mCurrView != null) {
229                    mCurrAnimView = mCurrView.getSwipeableView().getView();
230                    mCanCurrViewBeDimissed = mCallback.canChildBeDismissed(mCurrView);
231                    mVelocityTracker.addMovement(ev);
232                    mInitialTouchPosX = ev.getX();
233                    mInitialTouchPosY = ev.getY();
234                }
235                mCallback.cancelDismissCounter();
236                break;
237            case MotionEvent.ACTION_MOVE:
238                if (mCurrView != null) {
239                    // Check the movement direction.
240                    if (mLastY >= 0 && !mDragging) {
241                        float currY = ev.getY();
242                        float currX = ev.getX();
243                        float deltaY = Math.abs(currY - mInitialTouchPosY);
244                        float deltaX = Math.abs(currX - mInitialTouchPosX);
245                        if (deltaY > mCurrView.getMinAllowScrollDistance()
246                                && deltaY > (FACTOR * deltaX)) {
247                            mLastY = ev.getY();
248                            mCallback.onScroll();
249                            return false;
250                        }
251                    }
252                    mVelocityTracker.addMovement(ev);
253                    float pos = ev.getX();
254                    float delta = pos - mInitialTouchPosX;
255                    if (Math.abs(delta) > mPagingTouchSlop) {
256                        mCallback.onBeginDrag(mCurrView.getSwipeableView().getView());
257                        mPrevView = mCallback.getLastSwipedItem();
258                        mDragging = true;
259                        mInitialTouchPosX = ev.getX() - mCurrAnimView.getTranslationX();
260                        mInitialTouchPosY = ev.getY();
261                    }
262                }
263                mLastY = ev.getY();
264                break;
265            case MotionEvent.ACTION_UP:
266            case MotionEvent.ACTION_CANCEL:
267                mDragging = false;
268                mCurrView = null;
269                mCurrAnimView = null;
270                mLastY = -1;
271                break;
272        }
273        return mDragging;
274    }
275
276    /**
277     * @param view The view to be dismissed
278     * @param velocity The desired pixels/second speed at which the view should
279     *            move
280     */
281    private void dismissChild(final SwipeableItemView view, float velocity) {
282        final View animView = mCurrView.getSwipeableView().getView();
283        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view);
284        float newPos = determinePos(animView, velocity);
285        int duration = determineDuration(animView, newPos, velocity);
286
287        Utils.enableHardwareLayer(animView);
288        ObjectAnimator anim = createDismissAnimation(animView, newPos, duration);
289        anim.addListener(new AnimatorListenerAdapter() {
290            @Override
291            public void onAnimationEnd(Animator animation) {
292                mCallback.onChildDismissed(mCurrView);
293                animView.setLayerType(View.LAYER_TYPE_NONE, null);
294            }
295        });
296        anim.addUpdateListener(new AnimatorUpdateListener() {
297            @Override
298            public void onAnimationUpdate(ValueAnimator animation) {
299                if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
300                    animView.setAlpha(getAlphaForOffset(animView));
301                }
302                invalidateGlobalRegion(animView);
303            }
304        });
305        anim.start();
306    }
307
308    private void dismissChildren(final Collection<ConversationItemView> views, float velocity,
309            AnimatorListenerAdapter listener) {
310        final View animView = mCurrView.getSwipeableView().getView();
311        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(mCurrView);
312        float newPos = determinePos(animView, velocity);
313        int duration = DISMISS_ANIMATION_DURATION;
314        ArrayList<Animator> animations = new ArrayList<Animator>();
315        ObjectAnimator anim;
316        for (final ConversationItemView view : views) {
317            Utils.enableHardwareLayer(view);
318            anim = createDismissAnimation(view, newPos, duration);
319            anim.addUpdateListener(new AnimatorUpdateListener() {
320                @Override
321                public void onAnimationUpdate(ValueAnimator animation) {
322                    if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
323                        view.setAlpha(getAlphaForOffset(view));
324                    }
325                    invalidateGlobalRegion(view);
326                }
327            });
328            anim.addListener(new AnimatorListenerAdapter() {
329                @Override
330                public void onAnimationEnd(Animator animation) {
331                    view.setLayerType(View.LAYER_TYPE_NONE, null);
332                }
333            });
334            animations.add(anim);
335        }
336        AnimatorSet transitionSet = new AnimatorSet();
337        transitionSet.playTogether(animations);
338        transitionSet.addListener(listener);
339        transitionSet.start();
340    }
341
342    public void dismissChildren(ConversationItemView first,
343            final Collection<ConversationItemView> views, AnimatorListenerAdapter listener) {
344        mCurrView = first;
345        dismissChildren(views, 0f, listener);
346    }
347
348    private static int determineDuration(View animView, float newPos, float velocity) {
349        int duration = MAX_ESCAPE_ANIMATION_DURATION;
350        if (velocity != 0) {
351            duration = Math
352                    .min(duration,
353                            (int) (Math.abs(newPos - animView.getTranslationX()) * 1000f / Math
354                                    .abs(velocity)));
355        } else {
356            duration = DEFAULT_ESCAPE_ANIMATION_DURATION;
357        }
358        return duration;
359    }
360
361    private float determinePos(View animView, float velocity) {
362        final float newPos;
363        if (velocity < 0 || (velocity == 0 && animView.getTranslationX() < 0)
364        // if we use the Menu to dismiss an item in landscape, animate up
365                || (velocity == 0 && animView.getTranslationX() == 0 && mSwipeDirection == Y)) {
366            newPos = -getSize(animView);
367        } else {
368            newPos = getSize(animView);
369        }
370        return newPos;
371    }
372
373    public void snapChild(final SwipeableItemView view) {
374        final View animView = view.getSwipeableView().getView();
375        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view);
376        final ObjectAnimator anim = createTranslationAnimation(animView, 0);
377        final int duration = SNAP_ANIM_LEN;
378        anim.setDuration(duration);
379        anim.addUpdateListener(new AnimatorUpdateListener() {
380            @Override
381            public void onAnimationUpdate(ValueAnimator animation) {
382                if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
383                    animView.setAlpha(getAlphaForOffset(animView));
384                }
385                invalidateGlobalRegion(animView);
386            }
387        });
388        anim.addListener(new Animator.AnimatorListener() {
389            @Override
390            public void onAnimationStart(Animator animation) {
391            }
392            @Override
393            public void onAnimationEnd(Animator animation) {
394                animView.setAlpha(1.0f);
395                mCallback.onDragCancelled(mCurrView);
396            }
397            @Override
398            public void onAnimationCancel(Animator animation) {
399            }
400            @Override
401            public void onAnimationRepeat(Animator animation) {
402            }
403        });
404        anim.start();
405    }
406
407    public boolean onTouchEvent(MotionEvent ev) {
408        if (!mDragging) {
409            return false;
410        }
411        mVelocityTracker.addMovement(ev);
412        final int action = ev.getAction();
413        switch (action) {
414            case MotionEvent.ACTION_OUTSIDE:
415            case MotionEvent.ACTION_MOVE:
416                if (mCurrView != null) {
417                    float deltaX = ev.getX() - mInitialTouchPosX;
418                    float deltaY = Math.abs(ev.getY() - mInitialTouchPosY);
419                    // If the swipe started in the dead region, ignore it.
420                    if (mInitialTouchPosX <= (DEAD_REGION_FOR_SWIPE * mDensityScale)){
421                            return true;
422                    }
423                    // If the user has gone vertical and not gone horizontalish AT
424                    // LEAST minBeforeLock, switch to scroll. Otherwise, cancel
425                    // the swipe.
426                    if (!mDragging && deltaY > MIN_VERT && (Math.abs(deltaX)) < MIN_LOCK
427                            && deltaY > (FACTOR * Math.abs(deltaX))) {
428                        mCallback.onScroll();
429                        return false;
430                    }
431                    float minDistance = MIN_SWIPE;
432                    if (Math.abs(deltaX) < minDistance) {
433                        // Don't start the drag until at least X distance has
434                        // occurred.
435                        return true;
436                    }
437                    // don't let items that can't be dismissed be dragged more
438                    // than maxScrollDistance
439                    if (CONSTRAIN_SWIPE && !mCallback.canChildBeDismissed(mCurrView)) {
440                        float size = getSize(mCurrAnimView);
441                        float maxScrollDistance = 0.15f * size;
442                        if (Math.abs(deltaX) >= size) {
443                            deltaX = deltaX > 0 ? maxScrollDistance : -maxScrollDistance;
444                        } else {
445                            deltaX = maxScrollDistance
446                                    * (float) Math.sin((deltaX / size) * (Math.PI / 2));
447                        }
448                    }
449                    setTranslation(mCurrAnimView, deltaX);
450                    if (FADE_OUT_DURING_SWIPE && mCanCurrViewBeDimissed) {
451                        mCurrAnimView.setAlpha(getAlphaForOffset(mCurrAnimView));
452                        if (mPrevView != null) {
453                            // Base how much the text of the prev item is faded
454                            // on how far the current item has moved.
455                            mPrevView.setTextAlpha(getTextAlphaForOffset(mCurrAnimView));
456                        }
457                    }
458                    invalidateGlobalRegion(mCurrView.getSwipeableView().getView());
459                }
460                break;
461            case MotionEvent.ACTION_UP:
462            case MotionEvent.ACTION_CANCEL:
463                if (mCurrView != null) {
464                    float maxVelocity = MAX_DISMISS_VELOCITY * mDensityScale;
465                    mVelocityTracker.computeCurrentVelocity(1000 /* px/sec */, maxVelocity);
466                    float escapeVelocity = SWIPE_ESCAPE_VELOCITY * mDensityScale;
467                    float velocity = getVelocity(mVelocityTracker);
468                    float perpendicularVelocity = getPerpendicularVelocity(mVelocityTracker);
469
470                    // Decide whether to dismiss the current view
471                    // Tweak constants below as required to prevent erroneous
472                    // swipe/dismiss
473                    float translation = Math.abs(mCurrAnimView.getTranslationX());
474                    float currAnimViewSize = getSize(mCurrAnimView);
475                    // Long swipe = translation of .4 * width
476                    boolean childSwipedFarEnough = DISMISS_IF_SWIPED_FAR_ENOUGH
477                            && translation > 0.4 * currAnimViewSize;
478                    // Fast swipe = > escapeVelocity and translation of .1 *
479                    // width
480                    boolean childSwipedFastEnough = (Math.abs(velocity) > escapeVelocity)
481                            && (Math.abs(velocity) > Math.abs(perpendicularVelocity))
482                            && (velocity > 0) == (mCurrAnimView.getTranslationX() > 0)
483                            && translation > 0.05 * currAnimViewSize;
484                    if (LOG_SWIPE_DISMISS_VELOCITY) {
485                        Log.v(TAG, "Swipe/Dismiss: " + velocity + "/" + escapeVelocity + "/"
486                                + perpendicularVelocity + ", x: " + translation + "/"
487                                + currAnimViewSize);
488                    }
489
490                    boolean dismissChild = mCallback.canChildBeDismissed(mCurrView)
491                            && (childSwipedFastEnough || childSwipedFarEnough);
492
493                    if (dismissChild) {
494                        dismissChild(mCurrView, childSwipedFastEnough ? velocity : 0f);
495                    } else {
496                        snapChild(mCurrView);
497                    }
498                }
499                break;
500        }
501        return true;
502    }
503
504    public interface Callback {
505        View getChildAtPosition(MotionEvent ev);
506
507        void cancelDismissCounter();
508
509        void onScroll();
510
511        boolean canChildBeDismissed(SwipeableItemView v);
512
513        void onBeginDrag(View v);
514
515        void onChildDismissed(SwipeableItemView v);
516
517        void onDragCancelled(SwipeableItemView v);
518
519        ConversationSelectionSet getSelectionSet();
520
521        LeaveBehindItem getLastSwipedItem();
522    }
523}
524