SwipeHelper.java revision bb6039ed45a5eeccf08d97cb91d1b91069fed5af
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;
37import com.android.mail.utils.Utils;
38
39import java.util.ArrayList;
40import java.util.Collection;
41
42public class SwipeHelper {
43    static final String TAG = "com.android.systemui.SwipeHelper";
44    private static final boolean DEBUG_INVALIDATE = false;
45    private static final boolean CONSTRAIN_SWIPE = true;
46    private static final boolean FADE_OUT_DURING_SWIPE = true;
47    private static final boolean DISMISS_IF_SWIPED_FAR_ENOUGH = true;
48    private static final boolean LOG_SWIPE_DISMISS_VELOCITY = false; // STOPSHIP - DEBUG ONLY
49
50    public static final int X = 0;
51    public static final int Y = 1;
52
53    private static LinearInterpolator sLinearInterpolator = new LinearInterpolator();
54
55    private static int SWIPE_ESCAPE_VELOCITY = -1;
56    private static int DEFAULT_ESCAPE_ANIMATION_DURATION;
57    private static int MAX_ESCAPE_ANIMATION_DURATION;
58    private static int MAX_DISMISS_VELOCITY;
59    private static int SNAP_ANIM_LEN;
60    private static int DISMISS_ANIMATION_DURATION;
61    private static float MIN_SWIPE;
62    private static float MIN_VERT;
63    private static float MIN_LOCK;
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            MIN_SWIPE = res.getDimension(R.dimen.min_swipe);
102            MIN_VERT = res.getDimension(R.dimen.min_vert);
103            MIN_LOCK = res.getDimension(R.dimen.min_lock);
104        }
105    }
106
107    public void setDensityScale(float densityScale) {
108        mDensityScale = densityScale;
109    }
110
111    public void setPagingTouchSlop(float pagingTouchSlop) {
112        mPagingTouchSlop = pagingTouchSlop;
113    }
114
115    private float getVelocity(VelocityTracker vt) {
116        return mSwipeDirection == X ? vt.getXVelocity() :
117                vt.getYVelocity();
118    }
119
120    private ObjectAnimator createTranslationAnimation(View v, float newPos) {
121        ObjectAnimator anim = ObjectAnimator.ofFloat(v,
122                mSwipeDirection == X ? "translationX" : "translationY", newPos);
123        return anim;
124    }
125
126    private ObjectAnimator createDismissAnimation(View v, float newPos, int duration) {
127        ObjectAnimator anim = createTranslationAnimation(v, newPos);
128        anim.setInterpolator(sLinearInterpolator);
129        anim.setDuration(duration);
130        return anim;
131    }
132
133    private float getPerpendicularVelocity(VelocityTracker vt) {
134        return mSwipeDirection == X ? vt.getYVelocity() :
135                vt.getXVelocity();
136    }
137
138    private void setTranslation(View v, float translate) {
139        if (mSwipeDirection == X) {
140            v.setTranslationX(translate);
141        } else {
142            v.setTranslationY(translate);
143        }
144    }
145
146    private float getSize(View v) {
147        return mSwipeDirection == X ? v.getMeasuredWidth() :
148                v.getMeasuredHeight();
149    }
150
151    public void setMinAlpha(float minAlpha) {
152        mMinAlpha = minAlpha;
153    }
154
155    private float getAlphaForOffset(View view) {
156        float viewSize = getSize(view);
157        final float fadeSize = ALPHA_FADE_END * viewSize;
158        float result = 1.0f;
159        float pos = view.getTranslationX();
160        if (pos >= viewSize * ALPHA_FADE_START) {
161            result = 1.0f - (pos - viewSize * ALPHA_FADE_START) / fadeSize;
162        } else if (pos < viewSize * (1.0f - ALPHA_FADE_START)) {
163            result = 1.0f + (viewSize * ALPHA_FADE_START + pos) / fadeSize;
164        }
165        return Math.max(mMinAlpha, result);
166    }
167
168    // invalidate the view's own bounds all the way up the view hierarchy
169    public static void invalidateGlobalRegion(View view) {
170        invalidateGlobalRegion(
171            view,
172            new RectF(view.getLeft(), view.getTop(), view.getRight(), view.getBottom()));
173    }
174
175    // invalidate a rectangle relative to the view's coordinate system all the way up the view
176    // hierarchy
177    public static void invalidateGlobalRegion(View view, RectF childBounds) {
178        //childBounds.offset(view.getTranslationX(), view.getTranslationY());
179        if (DEBUG_INVALIDATE)
180            Log.v(TAG, "-------------");
181        while (view.getParent() != null && view.getParent() instanceof View) {
182            view = (View) view.getParent();
183            view.getMatrix().mapRect(childBounds);
184            view.invalidate((int) Math.floor(childBounds.left),
185                            (int) Math.floor(childBounds.top),
186                            (int) Math.ceil(childBounds.right),
187                            (int) Math.ceil(childBounds.bottom));
188            if (DEBUG_INVALIDATE) {
189                Log.v(TAG, "INVALIDATE(" + (int) Math.floor(childBounds.left)
190                        + "," + (int) Math.floor(childBounds.top)
191                        + "," + (int) Math.ceil(childBounds.right)
192                        + "," + (int) Math.ceil(childBounds.bottom));
193            }
194        }
195    }
196
197    public boolean onInterceptTouchEvent(MotionEvent ev) {
198        final int action = ev.getAction();
199        switch (action) {
200            case MotionEvent.ACTION_DOWN:
201                mLastY = ev.getY();
202                mDragging = false;
203                View view = mCallback.getChildAtPosition(ev);
204                if (view instanceof SwipeableItemView) {
205                    mCurrView = (SwipeableItemView) view;
206                }
207                mVelocityTracker.clear();
208                if (mCurrView != null) {
209                    mCurrAnimView = mCurrView.getSwipeableView();
210                    mCanCurrViewBeDimissed = mCallback.canChildBeDismissed(mCurrView);
211                    mVelocityTracker.addMovement(ev);
212                    mInitialTouchPosX = ev.getX();
213                    mInitialTouchPosY = ev.getY();
214                }
215                break;
216            case MotionEvent.ACTION_MOVE:
217                if (mCurrView != null) {
218                    // Check the movement direction.
219                    if (mLastY >= 0 && !mDragging) {
220                        float currY = ev.getY();
221                        float currX = ev.getX();
222                        float deltaY = Math.abs(currY - mInitialTouchPosY);
223                        float deltaX = Math.abs(currX - mInitialTouchPosX);
224                        if (deltaY > mCurrView.getMinAllowScrollDistance()
225                                && deltaY > (FACTOR * deltaX)) {
226                            mLastY = ev.getY();
227                            mCallback.onScroll();
228                            return false;
229                        }
230                    }
231                    mVelocityTracker.addMovement(ev);
232                    float pos = ev.getX();
233                    float delta = pos - mInitialTouchPosX;
234                    if (Math.abs(delta) > mPagingTouchSlop) {
235                        mCallback.onBeginDrag(mCurrView.getSwipeableView());
236                        mDragging = true;
237                        mInitialTouchPosX = ev.getX() - mCurrAnimView.getTranslationX();
238                        mInitialTouchPosY = ev.getY();
239                    }
240                }
241                mLastY = ev.getY();
242                break;
243            case MotionEvent.ACTION_UP:
244            case MotionEvent.ACTION_CANCEL:
245                mDragging = false;
246                mCurrView = null;
247                mCurrAnimView = null;
248                mLastY = -1;
249                break;
250        }
251        return mDragging;
252    }
253
254    /**
255     * @param view The view to be dismissed
256     * @param velocity The desired pixels/second speed at which the view should
257     *            move
258     */
259    private void dismissChild(final SwipeableItemView view, float velocity) {
260        final View animView = mCurrView.getSwipeableView();
261        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view);
262        float newPos = determinePos(animView, velocity);
263        int duration = determineDuration(animView, newPos, velocity);
264
265        Utils.enableHardwareLayer(animView);
266        ObjectAnimator anim = createDismissAnimation(animView, newPos, duration);
267        anim.addListener(new AnimatorListenerAdapter() {
268            @Override
269            public void onAnimationEnd(Animator animation) {
270                mCallback.onChildDismissed(mCurrView);
271                animView.setLayerType(View.LAYER_TYPE_NONE, null);
272            }
273        });
274        anim.addUpdateListener(new AnimatorUpdateListener() {
275            @Override
276            public void onAnimationUpdate(ValueAnimator animation) {
277                if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
278                    animView.setAlpha(getAlphaForOffset(animView));
279                }
280                invalidateGlobalRegion(animView);
281            }
282        });
283        anim.start();
284    }
285
286    private void dismissChildren(final Collection<ConversationItemView> views, float velocity,
287            AnimatorListenerAdapter listener) {
288        final View animView = mCurrView.getSwipeableView();
289        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(mCurrView);
290        float newPos = determinePos(animView, velocity);
291        int duration = DISMISS_ANIMATION_DURATION;
292        ArrayList<Animator> animations = new ArrayList<Animator>();
293        ObjectAnimator anim;
294        for (final ConversationItemView view : views) {
295            Utils.enableHardwareLayer(view);
296            anim = createDismissAnimation(view, newPos, duration);
297            anim.addUpdateListener(new AnimatorUpdateListener() {
298                @Override
299                public void onAnimationUpdate(ValueAnimator animation) {
300                    if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
301                        view.setAlpha(getAlphaForOffset(view));
302                    }
303                    invalidateGlobalRegion(view);
304                }
305            });
306            anim.addListener(new AnimatorListenerAdapter() {
307                @Override
308                public void onAnimationEnd(Animator animation) {
309                    view.setLayerType(View.LAYER_TYPE_NONE, null);
310                }
311            });
312            animations.add(anim);
313        }
314        AnimatorSet transitionSet = new AnimatorSet();
315        transitionSet.playTogether(animations);
316        transitionSet.addListener(listener);
317        transitionSet.start();
318    }
319
320    public void dismissChildren(ConversationItemView first,
321            final Collection<ConversationItemView> views, AnimatorListenerAdapter listener) {
322        mCurrView = first;
323        dismissChildren(views, 0f, listener);
324    }
325
326    private int determineDuration(View animView, float newPos, float velocity) {
327        int duration = MAX_ESCAPE_ANIMATION_DURATION;
328        if (velocity != 0) {
329            duration = Math
330                    .min(duration,
331                            (int) (Math.abs(newPos - animView.getTranslationX()) * 1000f / Math
332                                    .abs(velocity)));
333        } else {
334            duration = DEFAULT_ESCAPE_ANIMATION_DURATION;
335        }
336        return duration;
337    }
338
339    private float determinePos(View animView, float velocity) {
340        float newPos = 0;
341        if (velocity < 0 || (velocity == 0 && animView.getTranslationX() < 0)
342        // if we use the Menu to dismiss an item in landscape, animate up
343                || (velocity == 0 && animView.getTranslationX() == 0 && mSwipeDirection == Y)) {
344            newPos = -getSize(animView);
345        } else {
346            newPos = getSize(animView);
347        }
348        return newPos;
349    }
350
351    public void snapChild(final SwipeableItemView view, float velocity) {
352        final View animView = view.getSwipeableView();
353        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view);
354        ObjectAnimator anim = createTranslationAnimation(animView, 0);
355        int duration = SNAP_ANIM_LEN;
356        anim.setDuration(duration);
357        anim.addUpdateListener(new AnimatorUpdateListener() {
358            @Override
359            public void onAnimationUpdate(ValueAnimator animation) {
360                if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
361                    animView.setAlpha(getAlphaForOffset(animView));
362                }
363                invalidateGlobalRegion(animView);
364            }
365        });
366        anim.addListener(new Animator.AnimatorListener() {
367            @Override
368            public void onAnimationStart(Animator animation) {
369            }
370            @Override
371            public void onAnimationEnd(Animator animation) {
372                animView.setAlpha(1.0f);
373                mCallback.onDragCancelled(mCurrView);
374            }
375            @Override
376            public void onAnimationCancel(Animator animation) {
377            }
378            @Override
379            public void onAnimationRepeat(Animator animation) {
380            }
381        });
382        anim.start();
383    }
384
385    public boolean onTouchEvent(MotionEvent ev) {
386        if (!mDragging) {
387            return false;
388        }
389        mVelocityTracker.addMovement(ev);
390        final int action = ev.getAction();
391        switch (action) {
392            case MotionEvent.ACTION_OUTSIDE:
393            case MotionEvent.ACTION_MOVE:
394                if (mCurrView != null) {
395                    float deltaX = ev.getX() - mInitialTouchPosX;
396                    float deltaY = Math.abs(ev.getY() - mInitialTouchPosY);
397                    // If the user has gone vertical and not gone horizontalish AT
398                    // LEAST minBeforeLock, switch to scroll. Otherwise, cancel
399                    // the swipe.
400                    if (!mDragging && deltaY > MIN_VERT && (Math.abs(deltaX)) < MIN_LOCK
401                            && deltaY > (FACTOR * Math.abs(deltaX))) {
402                        mCallback.onScroll();
403                        return false;
404                    }
405                    float minDistance = MIN_SWIPE;
406                    if (Math.abs(deltaX) < minDistance) {
407                        // Don't start the drag until at least X distance has
408                        // occurred.
409                        return true;
410                    }
411                    // don't let items that can't be dismissed be dragged more
412                    // than maxScrollDistance
413                    if (CONSTRAIN_SWIPE && !mCallback.canChildBeDismissed(mCurrView)) {
414                        float size = getSize(mCurrAnimView);
415                        float maxScrollDistance = 0.15f * size;
416                        if (Math.abs(deltaX) >= size) {
417                            deltaX = deltaX > 0 ? maxScrollDistance : -maxScrollDistance;
418                        } else {
419                            deltaX = maxScrollDistance
420                                    * (float) Math.sin((deltaX / size) * (Math.PI / 2));
421                        }
422                    }
423                    setTranslation(mCurrAnimView, deltaX);
424                    if (FADE_OUT_DURING_SWIPE && mCanCurrViewBeDimissed) {
425                        mCurrAnimView.setAlpha(getAlphaForOffset(mCurrAnimView));
426                    }
427                    invalidateGlobalRegion(mCurrView.getSwipeableView());
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                        dismissChild(mCurrView, childSwipedFastEnough ? velocity : 0f);
464                    } else {
465                        snapChild(mCurrView, velocity);
466                    }
467                }
468                break;
469        }
470        return true;
471    }
472
473    public interface Callback {
474        View getChildAtPosition(MotionEvent ev);
475
476        void onScroll();
477
478        boolean canChildBeDismissed(SwipeableItemView v);
479
480        void onBeginDrag(View v);
481
482        void onChildDismissed(SwipeableItemView v);
483
484        void onDragCancelled(SwipeableItemView v);
485
486        ConversationSelectionSet getSelectionSet();
487    }
488}
489