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