SwipeHelper.java revision b6b174fb3a8f58a2c81e035917ebad8ab45b88ae
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.animation.LinearInterpolator;
29import android.view.MotionEvent;
30import android.view.VelocityTracker;
31import android.view.View;
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
46    public static final int X = 0;
47    public static final int Y = 1;
48
49    private static LinearInterpolator sLinearInterpolator = new LinearInterpolator();
50
51    private float SWIPE_ESCAPE_VELOCITY = 100f; // dp/sec
52    private int DEFAULT_ESCAPE_ANIMATION_DURATION = 200; // ms
53    private int MAX_ESCAPE_ANIMATION_DURATION = 400; // ms
54    private int MAX_DISMISS_VELOCITY = 2000; // dp/sec
55    private static final int SNAP_ANIM_LEN = SLOW_ANIMATIONS ? 1000 : 150; // ms
56
57    public static float ALPHA_FADE_START = 0f; // fraction of thumbnail width
58                                                 // where fade starts
59    static final float ALPHA_FADE_END = 0.5f; // fraction of thumbnail width
60                                              // beyond which alpha->0
61    private float mMinAlpha = 0f;
62
63    private float mPagingTouchSlop;
64    private Callback mCallback;
65    private int mSwipeDirection;
66    private VelocityTracker mVelocityTracker;
67
68    private float mInitialTouchPos;
69    private boolean mDragging;
70    private ConversationItemView mCurrView;
71    private View mCurrAnimView;
72    private boolean mCanCurrViewBeDimissed;
73    private float mDensityScale;
74    private float mLastY;
75    private Collection<ConversationItemView> mAssociatedViews;
76    private final float mScrollSlop;
77
78    public SwipeHelper(int swipeDirection, Callback callback, float densityScale,
79            float pagingTouchSlop, float scrollSlop) {
80        mCallback = callback;
81        mSwipeDirection = swipeDirection;
82        mVelocityTracker = VelocityTracker.obtain();
83        mDensityScale = densityScale;
84        mPagingTouchSlop = pagingTouchSlop;
85        mScrollSlop = scrollSlop;
86    }
87
88    public void setDensityScale(float densityScale) {
89        mDensityScale = densityScale;
90    }
91
92    public void setPagingTouchSlop(float pagingTouchSlop) {
93        mPagingTouchSlop = pagingTouchSlop;
94    }
95
96    private float getPos(MotionEvent ev) {
97        return mSwipeDirection == X ? ev.getX() : ev.getY();
98    }
99
100    private float getTranslation(View v) {
101        return mSwipeDirection == X ? v.getTranslationX() : v.getTranslationY();
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 = getTranslation(view);
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                mCurrView = (ConversationItemView)mCallback.getChildAtPosition(ev);
193                mVelocityTracker.clear();
194                if (mCurrView != null) {
195                    mCurrAnimView = mCallback.getChildContentView(mCurrView);
196                    mCanCurrViewBeDimissed = mCallback.canChildBeDismissed(mCurrView);
197                    mVelocityTracker.addMovement(ev);
198                    mInitialTouchPos = getPos(ev);
199                }
200                break;
201            case MotionEvent.ACTION_MOVE:
202                if (mCurrView != null) {
203                    // Check the movement direction.
204                    if (mLastY >= 0) {
205                        float currY = ev.getY();
206                        if (Math.abs(currY - mLastY) > mScrollSlop) {
207                            mLastY = ev.getY();
208                            mCurrView.cancelTap();
209                            return false;
210                        }
211                    }
212                    mVelocityTracker.addMovement(ev);
213                    float pos = getPos(ev);
214                    float delta = pos - mInitialTouchPos;
215                    if (Math.abs(delta) > mPagingTouchSlop) {
216                        if (mCallback.getSelectionSet().isEmpty()
217                                || (!mCallback.getSelectionSet().isEmpty()
218                                        && mCurrView.isChecked())) {
219                            mCallback.onBeginDrag(mCurrView);
220                            mDragging = true;
221                            mInitialTouchPos = getPos(ev) - getTranslation(mCurrAnimView);
222                        }
223                    }
224                }
225                mLastY = ev.getY();
226                break;
227            case MotionEvent.ACTION_UP:
228            case MotionEvent.ACTION_CANCEL:
229                mDragging = false;
230                mCurrView = null;
231                mCurrAnimView = null;
232                mLastY = -1;
233                break;
234        }
235        return mDragging;
236    }
237
238    public void setAssociatedViews(Collection<ConversationItemView> associated) {
239        mAssociatedViews = associated;
240    }
241
242    public void clearAssociatedViews() {
243        mAssociatedViews = null;
244    }
245
246    /**
247     * @param view The view to be dismissed
248     * @param velocity The desired pixels/second speed at which the view should
249     *            move
250     */
251    private void dismissChild(final View view, float velocity) {
252        final View animView = mCurrView;
253        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(animView);
254        float newPos = determinePos(animView, velocity);
255        int duration = determineDuration(animView, newPos, velocity);
256
257        animView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
258        ObjectAnimator anim = createDismissAnimation(animView, newPos, duration);
259        anim.addListener(new AnimatorListenerAdapter() {
260            public void onAnimationEnd(Animator animation) {
261                mCallback.onChildDismissed(view);
262                mCurrView.setLayerType(View.LAYER_TYPE_NONE, null);
263            }
264        });
265        anim.addUpdateListener(new AnimatorUpdateListener() {
266            public void onAnimationUpdate(ValueAnimator animation) {
267                if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
268                    animView.setAlpha(getAlphaForOffset(animView));
269                }
270                invalidateGlobalRegion(animView);
271            }
272        });
273        anim.start();
274    }
275
276    private void dismissChildren(final Collection<ConversationItemView> views, float velocity) {
277        AnimatorListenerAdapter listener = new AnimatorListenerAdapter() {
278            public void onAnimationEnd(Animator animation) {
279                mCallback.onChildrenDismissed(views);
280                mCurrView.setLayerType(View.LAYER_TYPE_NONE, null);
281            }
282        };
283        final View animView = mCurrView;
284        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(animView);
285        float newPos = determinePos(animView, velocity);
286        int duration = determineDuration(animView, newPos, velocity);
287        ArrayList<Animator> animations = new ArrayList<Animator>();
288        ObjectAnimator anim;
289        for (final ConversationItemView view : views) {
290            view.setLayerType(View.LAYER_TYPE_HARDWARE, null);
291            anim = createDismissAnimation(view, newPos, duration);
292            anim.addUpdateListener(new AnimatorUpdateListener() {
293                public void onAnimationUpdate(ValueAnimator animation) {
294                    if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
295                        view.setAlpha(getAlphaForOffset(view));
296                    }
297                    invalidateGlobalRegion(view);
298                }
299            });
300            animations.add(anim);
301        }
302        AnimatorSet transitionSet = new AnimatorSet();
303        transitionSet.playTogether(animations);
304        transitionSet.addListener(listener);
305        transitionSet.start();
306    }
307
308    private int determineDuration(View animView, float newPos, float velocity) {
309        int duration = MAX_ESCAPE_ANIMATION_DURATION;
310        if (velocity != 0) {
311            duration = Math
312                    .min(duration,
313                            (int) (Math.abs(newPos - getTranslation(animView)) * 1000f / Math
314                                    .abs(velocity)));
315        } else {
316            duration = DEFAULT_ESCAPE_ANIMATION_DURATION;
317        }
318        return duration;
319    }
320
321    private float determinePos(View animView, float velocity) {
322        float newPos = 0;
323        if (velocity < 0 || (velocity == 0 && getTranslation(animView) < 0)
324        // if we use the Menu to dismiss an item in landscape, animate up
325                || (velocity == 0 && getTranslation(animView) == 0 && mSwipeDirection == Y)) {
326            newPos = -getSize(animView);
327        } else {
328            newPos = getSize(animView);
329        }
330        return newPos;
331    }
332
333    public void snapChild(final View view, float velocity) {
334        final View animView = mCallback.getChildContentView(view);
335        final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(animView);
336        ObjectAnimator anim = createTranslationAnimation(animView, 0);
337        int duration = SNAP_ANIM_LEN;
338        anim.setDuration(duration);
339        anim.addUpdateListener(new AnimatorUpdateListener() {
340            public void onAnimationUpdate(ValueAnimator animation) {
341                if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
342                    animView.setAlpha(getAlphaForOffset(animView));
343                }
344                invalidateGlobalRegion(animView);
345            }
346        });
347        anim.start();
348    }
349
350    public boolean onTouchEvent(MotionEvent ev) {
351        if (!mDragging) {
352            return false;
353        }
354        // If this item is being dragged, cancel any tap handlers/ events/
355        // actions for this item.
356        if (mCurrView != null) {
357            mCurrView.cancelTap();
358        }
359        mVelocityTracker.addMovement(ev);
360        final int action = ev.getAction();
361        switch (action) {
362            case MotionEvent.ACTION_OUTSIDE:
363            case MotionEvent.ACTION_MOVE:
364                if (mCurrView != null) {
365                    float delta = getPos(ev) - mInitialTouchPos;
366                    // don't let items that can't be dismissed be dragged more than
367                    // maxScrollDistance
368                    if (CONSTRAIN_SWIPE && !mCallback.canChildBeDismissed(mCurrView)) {
369                        float size = getSize(mCurrAnimView);
370                        float maxScrollDistance = 0.15f * size;
371                        if (Math.abs(delta) >= size) {
372                            delta = delta > 0 ? maxScrollDistance : -maxScrollDistance;
373                        } else {
374                            delta = maxScrollDistance * (float) Math.sin((delta/size)*(Math.PI/2));
375                        }
376                    }
377                    if (mAssociatedViews != null && mAssociatedViews.size() > 1) {
378                        for (View v : mAssociatedViews) {
379                            setTranslation(v, delta);
380                        }
381                    } else {
382                        setTranslation(mCurrAnimView, delta);
383                    }
384                    if (FADE_OUT_DURING_SWIPE && mCanCurrViewBeDimissed) {
385                        if (mAssociatedViews != null  && mAssociatedViews.size() > 1) {
386                            for (View v : mAssociatedViews) {
387                                v.setAlpha(getAlphaForOffset(mCurrAnimView));
388                            }
389                        } else {
390                            mCurrAnimView.setAlpha(getAlphaForOffset(mCurrAnimView));
391                        }
392                    }
393                    invalidateGlobalRegion(mCurrView);
394                }
395                break;
396            case MotionEvent.ACTION_UP:
397            case MotionEvent.ACTION_CANCEL:
398                if (mCurrView != null) {
399                    float maxVelocity = MAX_DISMISS_VELOCITY * mDensityScale;
400                    mVelocityTracker.computeCurrentVelocity(1000 /* px/sec */, maxVelocity);
401                    float escapeVelocity = SWIPE_ESCAPE_VELOCITY * mDensityScale;
402                    float velocity = getVelocity(mVelocityTracker);
403                    float perpendicularVelocity = getPerpendicularVelocity(mVelocityTracker);
404
405                    // Decide whether to dismiss the current view
406                    boolean childSwipedFarEnough = DISMISS_IF_SWIPED_FAR_ENOUGH &&
407                            Math.abs(getTranslation(mCurrAnimView)) > 0.4 * getSize(mCurrAnimView);
408                    boolean childSwipedFastEnough = (Math.abs(velocity) > escapeVelocity) &&
409                            (Math.abs(velocity) > Math.abs(perpendicularVelocity)) &&
410                            (velocity > 0) == (getTranslation(mCurrAnimView) > 0);
411
412                    boolean dismissChild = mCallback.canChildBeDismissed(mCurrView) &&
413                            (childSwipedFastEnough || childSwipedFarEnough);
414
415                    if (dismissChild) {
416                        if (mAssociatedViews != null && mAssociatedViews.size() > 1) {
417                            dismissChildren(mAssociatedViews, childSwipedFastEnough ?
418                                    velocity : 0f);
419                        } else {
420                            dismissChild(mCurrView, childSwipedFastEnough ? velocity : 0f);
421                        }
422                    } else {
423                        // snappity
424                        mCallback.onDragCancelled(mCurrView);
425
426                        if (mAssociatedViews != null && mAssociatedViews.size() > 1) {
427                            for (View v : mAssociatedViews) {
428                                snapChild(v, velocity);
429                            }
430                        } else {
431                            snapChild(mCurrView, velocity);
432                        }
433                    }
434                }
435                break;
436        }
437        return true;
438    }
439
440    public interface Callback {
441        View getChildAtPosition(MotionEvent ev);
442
443        View getChildContentView(View v);
444
445        boolean canChildBeDismissed(View v);
446
447        void onBeginDrag(View v);
448
449        void onChildDismissed(View v);
450
451        void onChildrenDismissed(Collection<ConversationItemView> v);
452
453        void onDragCancelled(View v);
454
455        ConversationSelectionSet getSelectionSet();
456    }
457}