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