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