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