SwipeHelper.java revision 4d4531a63cff536d2ee1a2929d0820981df8516a
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    private static final boolean LOG_SWIPE_DISMISS_VELOCITY = false; // STOPSHIP - DEBUG ONLY
49
50    public static final int X = 0;
51    public static final int Y = 1;
52
53    private static DecelerateInterpolator sDecelerateInterpolator =
54                                                        new DecelerateInterpolator(1.0f);
55
56    private static int SWIPE_ESCAPE_VELOCITY = -1;
57    private static int DEFAULT_ESCAPE_ANIMATION_DURATION;
58    private static int MAX_ESCAPE_ANIMATION_DURATION;
59    private static int MAX_DISMISS_VELOCITY;
60    private static int SNAP_ANIM_LEN;
61    private static int DISMISS_ANIMATION_DURATION;
62    private static float MIN_SWIPE;
63    private static float MIN_VERT;
64    private static float MIN_LOCK;
65
66    public static float ALPHA_FADE_START = 0f; // fraction of thumbnail width
67                                                 // where fade starts
68    public static float ALPHA_TEXT_FADE_START = 0.4f;
69    static final float ALPHA_FADE_END = 0.7f; // fraction of thumbnail width
70                                              // beyond which alpha->0
71    private static final float FACTOR = 1.2f;
72    private float mMinAlpha = 0.5f;
73
74    private float mPagingTouchSlop;
75    private Callback mCallback;
76    private int mSwipeDirection;
77    private VelocityTracker mVelocityTracker;
78
79    private float mInitialTouchPosX;
80    private boolean mDragging;
81    private SwipeableItemView mCurrView;
82    private View mCurrAnimView;
83    private boolean mCanCurrViewBeDimissed;
84    private float mDensityScale;
85    private float mLastY;
86    private float mInitialTouchPosY;
87    private LeaveBehindItem mPrevView;
88
89    public SwipeHelper(Context context, int swipeDirection, Callback callback, float densityScale,
90            float pagingTouchSlop) {
91        mCallback = callback;
92        mSwipeDirection = swipeDirection;
93        mVelocityTracker = VelocityTracker.obtain();
94        mDensityScale = densityScale;
95        mPagingTouchSlop = pagingTouchSlop;
96        if (SWIPE_ESCAPE_VELOCITY == -1) {
97            Resources res = context.getResources();
98            SWIPE_ESCAPE_VELOCITY = res.getInteger(R.integer.swipe_escape_velocity);
99            DEFAULT_ESCAPE_ANIMATION_DURATION = res.getInteger(R.integer.escape_animation_duration);
100            MAX_ESCAPE_ANIMATION_DURATION = res.getInteger(R.integer.max_escape_animation_duration);
101            MAX_DISMISS_VELOCITY = res.getInteger(R.integer.max_dismiss_velocity);
102            SNAP_ANIM_LEN = res.getInteger(R.integer.snap_animation_duration);
103            DISMISS_ANIMATION_DURATION = res.getInteger(R.integer.dismiss_animation_duration);
104            MIN_SWIPE = res.getDimension(R.dimen.min_swipe);
105            MIN_VERT = res.getDimension(R.dimen.min_vert);
106            MIN_LOCK = res.getDimension(R.dimen.min_lock);
107        }
108    }
109
110    public void setDensityScale(float densityScale) {
111        mDensityScale = densityScale;
112    }
113
114    public void setPagingTouchSlop(float pagingTouchSlop) {
115        mPagingTouchSlop = pagingTouchSlop;
116    }
117
118    private float getVelocity(VelocityTracker vt) {
119        return mSwipeDirection == X ? vt.getXVelocity() :
120                vt.getYVelocity();
121    }
122
123    private ObjectAnimator createTranslationAnimation(View v, float newPos) {
124        ObjectAnimator anim = ObjectAnimator.ofFloat(v,
125                mSwipeDirection == X ? "translationX" : "translationY", newPos);
126        return anim;
127    }
128
129    private ObjectAnimator createDismissAnimation(View v, float newPos, int duration) {
130        ObjectAnimator anim = createTranslationAnimation(v, newPos);
131        anim.setInterpolator(sDecelerateInterpolator);
132        anim.setDuration(duration);
133        return anim;
134    }
135
136    private float getPerpendicularVelocity(VelocityTracker vt) {
137        return mSwipeDirection == X ? vt.getYVelocity() :
138                vt.getXVelocity();
139    }
140
141    private void setTranslation(View v, float translate) {
142        if (mSwipeDirection == X) {
143            v.setTranslationX(translate);
144        } else {
145            v.setTranslationY(translate);
146        }
147    }
148
149    private float getSize(View v) {
150        return mSwipeDirection == X ? v.getMeasuredWidth() :
151                v.getMeasuredHeight();
152    }
153
154    public void setMinAlpha(float minAlpha) {
155        mMinAlpha = minAlpha;
156    }
157
158    private float getAlphaForOffset(View view) {
159        float viewSize = getSize(view);
160        final float fadeSize = ALPHA_FADE_END * viewSize;
161        float result = 1.0f;
162        float pos = view.getTranslationX();
163        if (pos >= viewSize * ALPHA_FADE_START) {
164            result = 1.0f - (pos - viewSize * ALPHA_FADE_START) / fadeSize;
165        } else if (pos < viewSize * (1.0f - ALPHA_FADE_START)) {
166            result = 1.0f + (viewSize * ALPHA_FADE_START + pos) / fadeSize;
167        }
168        return Math.max(mMinAlpha, result);
169    }
170
171    private float getTextAlphaForOffset(View view) {
172        float viewSize = getSize(view);
173        final float fadeSize = ALPHA_TEXT_FADE_START * viewSize;
174        float result = 1.0f;
175        float pos = view.getTranslationX();
176        if (pos >= 0) {
177            result = 1.0f - pos / fadeSize;
178        } else if (pos < 0) {
179            result = 1.0f + pos / fadeSize;
180        }
181        return Math.max(0, result);
182    }
183
184    // invalidate the view's own bounds all the way up the view hierarchy
185    public static void invalidateGlobalRegion(View view) {
186        invalidateGlobalRegion(
187            view,
188            new RectF(view.getLeft(), view.getTop(), view.getRight(), view.getBottom()));
189    }
190
191    // invalidate a rectangle relative to the view's coordinate system all the way up the view
192    // hierarchy
193    public static void invalidateGlobalRegion(View view, RectF childBounds) {
194        //childBounds.offset(view.getTranslationX(), view.getTranslationY());
195        if (DEBUG_INVALIDATE)
196            Log.v(TAG, "-------------");
197        while (view.getParent() != null && view.getParent() instanceof View) {
198            view = (View) view.getParent();
199            view.getMatrix().mapRect(childBounds);
200            view.invalidate((int) Math.floor(childBounds.left),
201                            (int) Math.floor(childBounds.top),
202                            (int) Math.ceil(childBounds.right),
203                            (int) Math.ceil(childBounds.bottom));
204            if (DEBUG_INVALIDATE) {
205                Log.v(TAG, "INVALIDATE(" + (int) Math.floor(childBounds.left)
206                        + "," + (int) Math.floor(childBounds.top)
207                        + "," + (int) Math.ceil(childBounds.right)
208                        + "," + (int) Math.ceil(childBounds.bottom));
209            }
210        }
211    }
212
213    public boolean onInterceptTouchEvent(MotionEvent ev) {
214        final int action = ev.getAction();
215        switch (action) {
216            case MotionEvent.ACTION_DOWN:
217                mLastY = ev.getY();
218                mDragging = false;
219                View view = mCallback.getChildAtPosition(ev);
220                if (view instanceof SwipeableItemView) {
221                    mCurrView = (SwipeableItemView) view;
222                }
223                mVelocityTracker.clear();
224                if (mCurrView != null) {
225                    mCurrAnimView = mCurrView.getSwipeableView();
226                    mCanCurrViewBeDimissed = mCallback.canChildBeDismissed(mCurrView);
227                    mVelocityTracker.addMovement(ev);
228                    mInitialTouchPosX = ev.getX();
229                    mInitialTouchPosY = ev.getY();
230                }
231                mCallback.cancelDismissCounter();
232                break;
233            case MotionEvent.ACTION_MOVE:
234                if (mCurrView != null) {
235                    // Check the movement direction.
236                    if (mLastY >= 0 && !mDragging) {
237                        float currY = ev.getY();
238                        float currX = ev.getX();
239                        float deltaY = Math.abs(currY - mInitialTouchPosY);
240                        float deltaX = Math.abs(currX - mInitialTouchPosX);
241                        if (deltaY > mCurrView.getMinAllowScrollDistance()
242                                && deltaY > (FACTOR * deltaX)) {
243                            mLastY = ev.getY();
244                            mCallback.onScroll();
245                            return false;
246                        }
247                    }
248                    mVelocityTracker.addMovement(ev);
249                    float pos = ev.getX();
250                    float delta = pos - mInitialTouchPosX;
251                    if (Math.abs(delta) > mPagingTouchSlop) {
252                        mCallback.onBeginDrag(mCurrView.getSwipeableView());
253                        mPrevView = mCallback.getLastSwipedItem();
254                        mDragging = true;
255                        mInitialTouchPosX = ev.getX() - mCurrAnimView.getTranslationX();
256                        mInitialTouchPosY = ev.getY();
257                    }
258                }
259                mLastY = ev.getY();
260                break;
261            case MotionEvent.ACTION_UP:
262            case MotionEvent.ACTION_CANCEL:
263                mDragging = false;
264                mCurrView = null;
265                mCurrAnimView = null;
266                mLastY = -1;
267                break;
268        }
269        return mDragging;
270    }
271
272    /**
273     * @param view The view to be dismissed
274     * @param velocity The desired pixels/second speed at which the view should
275     *            move
276     */
277    private void dismissChild(final SwipeableItemView view, float velocity) {
278        final View animView = mCurrView.getSwipeableView();
279        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view);
280        float newPos = determinePos(animView, velocity);
281        int duration = determineDuration(animView, newPos, velocity);
282
283        Utils.enableHardwareLayer(animView);
284        ObjectAnimator anim = createDismissAnimation(animView, newPos, duration);
285        anim.addListener(new AnimatorListenerAdapter() {
286            @Override
287            public void onAnimationEnd(Animator animation) {
288                mCallback.onChildDismissed(mCurrView);
289                animView.setLayerType(View.LAYER_TYPE_NONE, null);
290            }
291        });
292        anim.addUpdateListener(new AnimatorUpdateListener() {
293            @Override
294            public void onAnimationUpdate(ValueAnimator animation) {
295                if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
296                    animView.setAlpha(getAlphaForOffset(animView));
297                }
298                invalidateGlobalRegion(animView);
299            }
300        });
301        anim.start();
302    }
303
304    private void dismissChildren(final Collection<ConversationItemView> views, float velocity,
305            AnimatorListenerAdapter listener) {
306        final View animView = mCurrView.getSwipeableView();
307        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(mCurrView);
308        float newPos = determinePos(animView, velocity);
309        int duration = DISMISS_ANIMATION_DURATION;
310        ArrayList<Animator> animations = new ArrayList<Animator>();
311        ObjectAnimator anim;
312        for (final ConversationItemView view : views) {
313            Utils.enableHardwareLayer(view);
314            anim = createDismissAnimation(view, newPos, duration);
315            anim.addUpdateListener(new AnimatorUpdateListener() {
316                @Override
317                public void onAnimationUpdate(ValueAnimator animation) {
318                    if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
319                        view.setAlpha(getAlphaForOffset(view));
320                    }
321                    invalidateGlobalRegion(view);
322                }
323            });
324            anim.addListener(new AnimatorListenerAdapter() {
325                @Override
326                public void onAnimationEnd(Animator animation) {
327                    view.setLayerType(View.LAYER_TYPE_NONE, null);
328                }
329            });
330            animations.add(anim);
331        }
332        AnimatorSet transitionSet = new AnimatorSet();
333        transitionSet.playTogether(animations);
334        transitionSet.addListener(listener);
335        transitionSet.start();
336    }
337
338    public void dismissChildren(ConversationItemView first,
339            final Collection<ConversationItemView> views, AnimatorListenerAdapter listener) {
340        mCurrView = first;
341        dismissChildren(views, 0f, listener);
342    }
343
344    private int determineDuration(View animView, float newPos, float velocity) {
345        int duration = MAX_ESCAPE_ANIMATION_DURATION;
346        if (velocity != 0) {
347            duration = Math
348                    .min(duration,
349                            (int) (Math.abs(newPos - animView.getTranslationX()) * 1000f / Math
350                                    .abs(velocity)));
351        } else {
352            duration = DEFAULT_ESCAPE_ANIMATION_DURATION;
353        }
354        return duration;
355    }
356
357    private float determinePos(View animView, float velocity) {
358        float newPos = 0;
359        if (velocity < 0 || (velocity == 0 && animView.getTranslationX() < 0)
360        // if we use the Menu to dismiss an item in landscape, animate up
361                || (velocity == 0 && animView.getTranslationX() == 0 && mSwipeDirection == Y)) {
362            newPos = -getSize(animView);
363        } else {
364            newPos = getSize(animView);
365        }
366        return newPos;
367    }
368
369    public void snapChild(final SwipeableItemView view, float velocity) {
370        final View animView = view.getSwipeableView();
371        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view);
372        ObjectAnimator anim = createTranslationAnimation(animView, 0);
373        int duration = SNAP_ANIM_LEN;
374        anim.setDuration(duration);
375        anim.addUpdateListener(new AnimatorUpdateListener() {
376            @Override
377            public void onAnimationUpdate(ValueAnimator animation) {
378                if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
379                    animView.setAlpha(getAlphaForOffset(animView));
380                }
381                invalidateGlobalRegion(animView);
382            }
383        });
384        anim.addListener(new Animator.AnimatorListener() {
385            @Override
386            public void onAnimationStart(Animator animation) {
387            }
388            @Override
389            public void onAnimationEnd(Animator animation) {
390                animView.setAlpha(1.0f);
391                mCallback.onDragCancelled(mCurrView);
392            }
393            @Override
394            public void onAnimationCancel(Animator animation) {
395            }
396            @Override
397            public void onAnimationRepeat(Animator animation) {
398            }
399        });
400        anim.start();
401    }
402
403    public boolean onTouchEvent(MotionEvent ev) {
404        if (!mDragging) {
405            return false;
406        }
407        mVelocityTracker.addMovement(ev);
408        final int action = ev.getAction();
409        switch (action) {
410            case MotionEvent.ACTION_OUTSIDE:
411            case MotionEvent.ACTION_MOVE:
412                if (mCurrView != null) {
413                    float deltaX = ev.getX() - mInitialTouchPosX;
414                    float deltaY = Math.abs(ev.getY() - mInitialTouchPosY);
415                    // If the user has gone vertical and not gone horizontalish AT
416                    // LEAST minBeforeLock, switch to scroll. Otherwise, cancel
417                    // the swipe.
418                    if (!mDragging && deltaY > MIN_VERT && (Math.abs(deltaX)) < MIN_LOCK
419                            && deltaY > (FACTOR * Math.abs(deltaX))) {
420                        mCallback.onScroll();
421                        return false;
422                    }
423                    float minDistance = MIN_SWIPE;
424                    if (Math.abs(deltaX) < minDistance) {
425                        // Don't start the drag until at least X distance has
426                        // occurred.
427                        return true;
428                    }
429                    // don't let items that can't be dismissed be dragged more
430                    // than maxScrollDistance
431                    if (CONSTRAIN_SWIPE && !mCallback.canChildBeDismissed(mCurrView)) {
432                        float size = getSize(mCurrAnimView);
433                        float maxScrollDistance = 0.15f * size;
434                        if (Math.abs(deltaX) >= size) {
435                            deltaX = deltaX > 0 ? maxScrollDistance : -maxScrollDistance;
436                        } else {
437                            deltaX = maxScrollDistance
438                                    * (float) Math.sin((deltaX / size) * (Math.PI / 2));
439                        }
440                    }
441                    setTranslation(mCurrAnimView, deltaX);
442                    if (FADE_OUT_DURING_SWIPE && mCanCurrViewBeDimissed) {
443                        mCurrAnimView.setAlpha(getAlphaForOffset(mCurrAnimView));
444                        if (mPrevView != null) {
445                            // Base how much the text of the prev item is faded
446                            // on how far the current item has moved.
447                            mPrevView.setTextAlpha(getTextAlphaForOffset(mCurrAnimView));
448                        }
449                    }
450                    invalidateGlobalRegion(mCurrView.getSwipeableView());
451                }
452                break;
453            case MotionEvent.ACTION_UP:
454            case MotionEvent.ACTION_CANCEL:
455                if (mCurrView != null) {
456                    float maxVelocity = MAX_DISMISS_VELOCITY * mDensityScale;
457                    mVelocityTracker.computeCurrentVelocity(1000 /* px/sec */, maxVelocity);
458                    float escapeVelocity = SWIPE_ESCAPE_VELOCITY * mDensityScale;
459                    float velocity = getVelocity(mVelocityTracker);
460                    float perpendicularVelocity = getPerpendicularVelocity(mVelocityTracker);
461
462                    // Decide whether to dismiss the current view
463                    // Tweak constants below as required to prevent erroneous
464                    // swipe/dismiss
465                    float translation = Math.abs(mCurrAnimView.getTranslationX());
466                    float currAnimViewSize = getSize(mCurrAnimView);
467                    // Long swipe = translation of .4 * width
468                    boolean childSwipedFarEnough = DISMISS_IF_SWIPED_FAR_ENOUGH
469                            && translation > 0.4 * currAnimViewSize;
470                    // Fast swipe = > escapeVelocity and translation of .1 *
471                    // width
472                    boolean childSwipedFastEnough = (Math.abs(velocity) > escapeVelocity)
473                            && (Math.abs(velocity) > Math.abs(perpendicularVelocity))
474                            && (velocity > 0) == (mCurrAnimView.getTranslationX() > 0)
475                            && translation > 0.05 * currAnimViewSize;
476                    if (LOG_SWIPE_DISMISS_VELOCITY) {
477                        Log.v(TAG, "Swipe/Dismiss: " + velocity + "/" + escapeVelocity + "/"
478                                + perpendicularVelocity + ", x: " + translation + "/"
479                                + currAnimViewSize);
480                    }
481
482                    boolean dismissChild = mCallback.canChildBeDismissed(mCurrView)
483                            && (childSwipedFastEnough || childSwipedFarEnough);
484
485                    if (dismissChild) {
486                        dismissChild(mCurrView, childSwipedFastEnough ? velocity : 0f);
487                    } else {
488                        snapChild(mCurrView, velocity);
489                    }
490                }
491                break;
492        }
493        return true;
494    }
495
496    public interface Callback {
497        View getChildAtPosition(MotionEvent ev);
498
499        void cancelDismissCounter();
500
501        void onScroll();
502
503        boolean canChildBeDismissed(SwipeableItemView v);
504
505        void onBeginDrag(View v);
506
507        void onChildDismissed(SwipeableItemView v);
508
509        void onDragCancelled(SwipeableItemView v);
510
511        ConversationSelectionSet getSelectionSet();
512
513        LeaveBehindItem getLastSwipedItem();
514    }
515}
516