SwipeHelper.java revision 6c72a787b58a0bc3afcb71093eddf8c29d1cf5ed
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 SwipeableItemView 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 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                        if (mCurrView.canSwipe()) {
221                            mCallback.onBeginDrag(mCurrView.getView());
222                            mDragging = true;
223                            mInitialTouchPosX = ev.getX() - mCurrAnimView.getTranslationX();
224                            mInitialTouchPosY = ev.getY();
225                            mCurrView.cancelTap();
226                        }
227                    }
228                }
229                mLastY = ev.getY();
230                break;
231            case MotionEvent.ACTION_UP:
232            case MotionEvent.ACTION_CANCEL:
233                mDragging = false;
234                mCurrView = null;
235                mCurrAnimView = null;
236                mLastY = -1;
237                break;
238        }
239        return mDragging;
240    }
241
242    public void setAssociatedViews(Collection<ConversationItemView> associated) {
243        mAssociatedViews = associated;
244    }
245
246    public void clearAssociatedViews() {
247        mAssociatedViews = null;
248    }
249
250    /**
251     * @param view The view to be dismissed
252     * @param velocity The desired pixels/second speed at which the view should
253     *            move
254     */
255    private void dismissChild(final SwipeableItemView view, float velocity) {
256        final View animView = mCurrView.getView();
257        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view);
258        float newPos = determinePos(animView, velocity);
259        int duration = determineDuration(animView, newPos, velocity);
260
261        animView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
262        ObjectAnimator anim = createDismissAnimation(animView, newPos, duration);
263        anim.addListener(new AnimatorListenerAdapter() {
264            @Override
265            public void onAnimationEnd(Animator animation) {
266                mCallback.onChildDismissed(mCurrView);
267                animView.setLayerType(View.LAYER_TYPE_NONE, null);
268            }
269        });
270        anim.addUpdateListener(new AnimatorUpdateListener() {
271            @Override
272            public void onAnimationUpdate(ValueAnimator animation) {
273                if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
274                    animView.setAlpha(getAlphaForOffset(animView));
275                }
276                invalidateGlobalRegion(animView);
277            }
278        });
279        anim.start();
280    }
281
282    private void dismissChildren(final Collection<ConversationItemView> views, float velocity) {
283        AnimatorListenerAdapter listener = new AnimatorListenerAdapter() {
284            @Override
285            public void onAnimationEnd(Animator animation) {
286                mCallback.onChildrenDismissed(mCurrView, views);
287                mCurrView.getView().setLayerType(View.LAYER_TYPE_NONE, null);
288            }
289        };
290        dismissChildren(views, velocity, listener);
291    }
292
293    private void dismissChildren(final Collection<ConversationItemView> views, float velocity,
294            AnimatorListenerAdapter listener) {
295        final View animView = mCurrView.getView();
296        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(mCurrView);
297        float newPos = determinePos(animView, velocity);
298        int duration = determineDuration(animView, newPos, velocity);
299        ArrayList<Animator> animations = new ArrayList<Animator>();
300        ObjectAnimator anim;
301        for (final ConversationItemView view : views) {
302            view.setLayerType(View.LAYER_TYPE_HARDWARE, null);
303            anim = createDismissAnimation(view, newPos, duration);
304            anim.addUpdateListener(new AnimatorUpdateListener() {
305                @Override
306                public void onAnimationUpdate(ValueAnimator animation) {
307                    if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
308                        view.setAlpha(getAlphaForOffset(view));
309                    }
310                    invalidateGlobalRegion(view);
311                }
312            });
313            animations.add(anim);
314        }
315        AnimatorSet transitionSet = new AnimatorSet();
316        transitionSet.playTogether(animations);
317        transitionSet.addListener(listener);
318        transitionSet.start();
319    }
320
321    public void dismissChildren(ConversationItemView first,
322            final Collection<ConversationItemView> views, AnimatorListenerAdapter listener) {
323        mCurrView = first;
324        dismissChildren(views, 0f, listener);
325    }
326
327    private int determineDuration(View animView, float newPos, float velocity) {
328        int duration = MAX_ESCAPE_ANIMATION_DURATION;
329        if (velocity != 0) {
330            duration = Math
331                    .min(duration,
332                            (int) (Math.abs(newPos - animView.getTranslationX()) * 1000f / Math
333                                    .abs(velocity)));
334        } else {
335            duration = DEFAULT_ESCAPE_ANIMATION_DURATION;
336        }
337        return duration;
338    }
339
340    private float determinePos(View animView, float velocity) {
341        float newPos = 0;
342        if (velocity < 0 || (velocity == 0 && animView.getTranslationX() < 0)
343        // if we use the Menu to dismiss an item in landscape, animate up
344                || (velocity == 0 && animView.getTranslationX() == 0 && mSwipeDirection == Y)) {
345            newPos = -getSize(animView);
346        } else {
347            newPos = getSize(animView);
348        }
349        return newPos;
350    }
351
352    public void snapChild(final SwipeableItemView view, float velocity) {
353        final View animView = view.getView();
354        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view);
355        ObjectAnimator anim = createTranslationAnimation(animView, 0);
356        int duration = SNAP_ANIM_LEN;
357        anim.setDuration(duration);
358        anim.addUpdateListener(new AnimatorUpdateListener() {
359            @Override
360            public void onAnimationUpdate(ValueAnimator animation) {
361                if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
362                    animView.setAlpha(getAlphaForOffset(animView));
363                }
364                invalidateGlobalRegion(animView);
365            }
366        });
367        anim.start();
368    }
369
370    public boolean onTouchEvent(MotionEvent ev) {
371        if (!mDragging) {
372            return false;
373        }
374        // If this item is being dragged, cancel any tap handlers/ events/
375        // actions for this item.
376        if (mCurrView != null) {
377            mCurrView.cancelTap();
378        }
379        mVelocityTracker.addMovement(ev);
380        final int action = ev.getAction();
381        switch (action) {
382            case MotionEvent.ACTION_OUTSIDE:
383            case MotionEvent.ACTION_MOVE:
384                if (mCurrView != null) {
385                    float deltaX = ev.getX() - mInitialTouchPosX;
386                    float deltaY = Math.abs(ev.getY() - mInitialTouchPosY);
387                    // If the user has gone vertical and not gone horizontal AT
388                    // LEAST minBeforeLock, switch to scroll. Otherwise, cancel
389                    // the swipe.
390                    if (deltaY > mMinVert && (Math.abs(deltaX)) < mMinLock) {
391                        return false;
392                    }
393                    float minDistance = mMinSwipe;
394                    if (Math.abs(deltaX) < minDistance) {
395                        // Don't start the drag until at least X distance has
396                        // occurred.
397                        return true;
398                    }
399                    // don't let items that can't be dismissed be dragged more
400                    // than maxScrollDistance
401                    if (CONSTRAIN_SWIPE && !mCallback.canChildBeDismissed(mCurrView)) {
402                        float size = getSize(mCurrAnimView);
403                        float maxScrollDistance = 0.15f * size;
404                        if (Math.abs(deltaX) >= size) {
405                            deltaX = deltaX > 0 ? maxScrollDistance : -maxScrollDistance;
406                        } else {
407                            deltaX = maxScrollDistance
408                                    * (float) Math.sin((deltaX / size) * (Math.PI / 2));
409                        }
410                    }
411                    if (mAssociatedViews != null && mAssociatedViews.size() > 1) {
412                        for (View v : mAssociatedViews) {
413                            setTranslation(v, deltaX);
414                        }
415                    } else {
416                        setTranslation(mCurrAnimView, deltaX);
417                    }
418                    if (FADE_OUT_DURING_SWIPE && mCanCurrViewBeDimissed) {
419                        if (mAssociatedViews != null && mAssociatedViews.size() > 1) {
420                            for (View v : mAssociatedViews) {
421                                v.setAlpha(getAlphaForOffset(mCurrAnimView));
422                            }
423                        } else {
424                            mCurrAnimView.setAlpha(getAlphaForOffset(mCurrAnimView));
425                        }
426                    }
427                    invalidateGlobalRegion(mCurrView.getView());
428                }
429                break;
430            case MotionEvent.ACTION_UP:
431            case MotionEvent.ACTION_CANCEL:
432                if (mCurrView != null) {
433                    float maxVelocity = MAX_DISMISS_VELOCITY * mDensityScale;
434                    mVelocityTracker.computeCurrentVelocity(1000 /* px/sec */, maxVelocity);
435                    float escapeVelocity = SWIPE_ESCAPE_VELOCITY * mDensityScale;
436                    float velocity = getVelocity(mVelocityTracker);
437                    float perpendicularVelocity = getPerpendicularVelocity(mVelocityTracker);
438
439                    // Decide whether to dismiss the current view
440                    // Tweak constants below as required to prevent erroneous
441                    // swipe/dismiss
442                    float translation = Math.abs(mCurrAnimView.getTranslationX());
443                    float currAnimViewSize = getSize(mCurrAnimView);
444                    // Long swipe = translation of .4 * width
445                    boolean childSwipedFarEnough = DISMISS_IF_SWIPED_FAR_ENOUGH
446                            && translation > 0.4 * currAnimViewSize;
447                    // Fast swipe = > escapeVelocity and translation of .1 *
448                    // width
449                    boolean childSwipedFastEnough = (Math.abs(velocity) > escapeVelocity)
450                            && (Math.abs(velocity) > Math.abs(perpendicularVelocity))
451                            && (velocity > 0) == (mCurrAnimView.getTranslationX() > 0)
452                            && translation > 0.05 * currAnimViewSize;
453                    if (LOG_SWIPE_DISMISS_VELOCITY) {
454                        Log.v(TAG, "Swipe/Dismiss: " + velocity + "/" + escapeVelocity + "/"
455                                + perpendicularVelocity + ", x: " + translation + "/"
456                                + currAnimViewSize);
457                    }
458
459                    boolean dismissChild = mCallback.canChildBeDismissed(mCurrView)
460                            && (childSwipedFastEnough || childSwipedFarEnough);
461
462                    if (dismissChild) {
463                        if (mAssociatedViews != null && mAssociatedViews.size() > 1) {
464                            dismissChildren(mAssociatedViews, childSwipedFastEnough ?
465                                    velocity : 0f);
466                        } else {
467                            dismissChild(mCurrView, childSwipedFastEnough ? velocity : 0f);
468                        }
469                    } else {
470                        // snappity
471                        mCallback.onDragCancelled(mCurrView);
472
473                        if (mAssociatedViews != null && mAssociatedViews.size() > 1) {
474                            for (SwipeableItemView v : mAssociatedViews) {
475                                snapChild(v, velocity);
476                            }
477                        } else {
478                            snapChild(mCurrView, velocity);
479                        }
480                    }
481                }
482                break;
483        }
484        return true;
485    }
486
487    public interface Callback {
488        View getChildAtPosition(MotionEvent ev);
489
490        boolean canChildBeDismissed(SwipeableItemView v);
491
492        void onBeginDrag(View v);
493
494        void onChildDismissed(SwipeableItemView v);
495
496        void onChildrenDismissed(SwipeableItemView target, Collection<ConversationItemView> v);
497
498        void onDragCancelled(SwipeableItemView v);
499
500        ConversationSelectionSet getSelectionSet();
501    }
502}
503