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