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