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 
18 package com.android.mail.ui;
19 
20 import android.animation.Animator;
21 import android.animation.AnimatorListenerAdapter;
22 import android.animation.ObjectAnimator;
23 import android.animation.ValueAnimator;
24 import android.animation.ValueAnimator.AnimatorUpdateListener;
25 import android.content.Context;
26 import android.content.res.Resources;
27 import android.graphics.RectF;
28 import android.view.MotionEvent;
29 import android.view.VelocityTracker;
30 import android.view.View;
31 import android.view.animation.DecelerateInterpolator;
32 
33 import com.android.mail.R;
34 import com.android.mail.utils.LogUtils;
35 import com.android.mail.utils.Utils;
36 
37 public class SwipeHelper {
38     static final String TAG = "com.android.systemui.SwipeHelper";
39     private static final boolean DEBUG_INVALIDATE = false;
40     private static final boolean CONSTRAIN_SWIPE = true;
41     private static final boolean FADE_OUT_DURING_SWIPE = true;
42     private static final boolean DISMISS_IF_SWIPED_FAR_ENOUGH = true;
43     // Turn on for debugging only during development.
44     private static final boolean LOG_SWIPE_DISMISS_VELOCITY = false;
45 
46     public static final int X = 0;
47     public static final int Y = 1;
48 
49     private static DecelerateInterpolator sDecelerateInterpolator =
50                                                         new DecelerateInterpolator(1.0f);
51 
52     private static int SWIPE_ESCAPE_VELOCITY = -1;
53     private static int DEFAULT_ESCAPE_ANIMATION_DURATION;
54     private static int MAX_ESCAPE_ANIMATION_DURATION;
55     private static int MAX_DISMISS_VELOCITY;
56     private static int SNAP_ANIM_LEN;
57     private static float MIN_SWIPE;
58 
59     public static float ALPHA_FADE_START = 0f; // fraction of thumbnail width
60                                                  // where fade starts
61     public static float ALPHA_TEXT_FADE_START = 0.4f;
62     static final float ALPHA_FADE_END = 0.7f; // fraction of thumbnail width
63                                               // beyond which alpha->0
64     private static final float FACTOR = 1.2f;
65 
66     /* Dead region where swipe cannot be initiated. */
67     private final static int DEAD_REGION_FOR_SWIPE = 56;
68 
69     private float mPagingTouchSlop;
70     private final Callback mCallback;
71     private final int mSwipeDirection;
72     private final VelocityTracker mVelocityTracker;
73 
74     private float mInitialTouchPosX;
75     private boolean mDragging;
76     private SwipeableItemView mCurrView;
77     private View mCurrAnimView;
78     private boolean mCanCurrViewBeDimissed;
79     private float mDensityScale;
80     private float mLastY;
81     private float mInitialTouchPosY;
82     private LeaveBehindItem mPrevView;
83 
SwipeHelper(Context context, int swipeDirection, Callback callback, float densityScale, float pagingTouchSlop)84     public SwipeHelper(Context context, int swipeDirection, Callback callback, float densityScale,
85             float pagingTouchSlop) {
86         mCallback = callback;
87         mSwipeDirection = swipeDirection;
88         mVelocityTracker = VelocityTracker.obtain();
89         mDensityScale = densityScale;
90         mPagingTouchSlop = pagingTouchSlop;
91         if (SWIPE_ESCAPE_VELOCITY == -1) {
92             Resources res = context.getResources();
93             SWIPE_ESCAPE_VELOCITY = res.getInteger(R.integer.swipe_escape_velocity);
94             DEFAULT_ESCAPE_ANIMATION_DURATION = res.getInteger(R.integer.escape_animation_duration);
95             MAX_ESCAPE_ANIMATION_DURATION = res.getInteger(R.integer.max_escape_animation_duration);
96             MAX_DISMISS_VELOCITY = res.getInteger(R.integer.max_dismiss_velocity);
97             SNAP_ANIM_LEN = res.getInteger(R.integer.snap_animation_duration);
98             MIN_SWIPE = res.getDimension(R.dimen.min_swipe);
99         }
100     }
101 
setDensityScale(float densityScale)102     public void setDensityScale(float densityScale) {
103         mDensityScale = densityScale;
104     }
105 
setPagingTouchSlop(float pagingTouchSlop)106     public void setPagingTouchSlop(float pagingTouchSlop) {
107         mPagingTouchSlop = pagingTouchSlop;
108     }
109 
getVelocity(VelocityTracker vt)110     private float getVelocity(VelocityTracker vt) {
111         return mSwipeDirection == X ? vt.getXVelocity() :
112                 vt.getYVelocity();
113     }
114 
createTranslationAnimation(View v, float newPos)115     private ObjectAnimator createTranslationAnimation(View v, float newPos) {
116         ObjectAnimator anim = ObjectAnimator.ofFloat(v,
117                 mSwipeDirection == X ? "translationX" : "translationY", newPos);
118         return anim;
119     }
120 
createDismissAnimation(View v, float newPos, int duration)121     private ObjectAnimator createDismissAnimation(View v, float newPos, int duration) {
122         ObjectAnimator anim = createTranslationAnimation(v, newPos);
123         anim.setInterpolator(sDecelerateInterpolator);
124         anim.setDuration(duration);
125         return anim;
126     }
127 
getPerpendicularVelocity(VelocityTracker vt)128     private float getPerpendicularVelocity(VelocityTracker vt) {
129         return mSwipeDirection == X ? vt.getYVelocity() :
130                 vt.getXVelocity();
131     }
132 
setTranslation(View v, float translate)133     private void setTranslation(View v, float translate) {
134         if (mSwipeDirection == X) {
135             v.setTranslationX(translate);
136         } else {
137             v.setTranslationY(translate);
138         }
139     }
140 
getSize(View v)141     private float getSize(View v) {
142         return mSwipeDirection == X ? v.getMeasuredWidth() :
143                 v.getMeasuredHeight();
144     }
145 
getAlphaForOffset(View view)146     private float getAlphaForOffset(View view) {
147         float viewSize = getSize(view);
148         final float fadeSize = ALPHA_FADE_END * viewSize;
149         float result = 1.0f;
150         float pos = view.getTranslationX();
151         if (pos >= viewSize * ALPHA_FADE_START) {
152             result = 1.0f - (pos - viewSize * ALPHA_FADE_START) / fadeSize;
153         } else if (pos < viewSize * (1.0f - ALPHA_FADE_START)) {
154             result = 1.0f + (viewSize * ALPHA_FADE_START + pos) / fadeSize;
155         }
156         float minAlpha = 0.5f;
157         return Math.max(minAlpha, result);
158     }
159 
getTextAlphaForOffset(View view)160     private float getTextAlphaForOffset(View view) {
161         float viewSize = getSize(view);
162         final float fadeSize = ALPHA_TEXT_FADE_START * viewSize;
163         float result = 1.0f;
164         float pos = view.getTranslationX();
165         if (pos >= 0) {
166             result = 1.0f - pos / fadeSize;
167         } else if (pos < 0) {
168             result = 1.0f + pos / fadeSize;
169         }
170         return Math.max(0, result);
171     }
172 
173     // invalidate the view's own bounds all the way up the view hierarchy
invalidateGlobalRegion(View view)174     public static void invalidateGlobalRegion(View view) {
175         invalidateGlobalRegion(
176             view,
177             new RectF(view.getLeft(), view.getTop(), view.getRight(), view.getBottom()));
178     }
179 
180     // invalidate a rectangle relative to the view's coordinate system all the way up the view
181     // hierarchy
invalidateGlobalRegion(View view, RectF childBounds)182     public static void invalidateGlobalRegion(View view, RectF childBounds) {
183         //childBounds.offset(view.getTranslationX(), view.getTranslationY());
184         if (DEBUG_INVALIDATE)
185             LogUtils.v(TAG, "-------------");
186         while (view.getParent() != null && view.getParent() instanceof View) {
187             view = (View) view.getParent();
188             view.getMatrix().mapRect(childBounds);
189             view.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             if (DEBUG_INVALIDATE) {
194                 LogUtils.v(TAG, "INVALIDATE(" + (int) Math.floor(childBounds.left)
195                         + "," + (int) Math.floor(childBounds.top)
196                         + "," + (int) Math.ceil(childBounds.right)
197                         + "," + (int) Math.ceil(childBounds.bottom));
198             }
199         }
200     }
201 
onInterceptTouchEvent(MotionEvent ev)202     public boolean onInterceptTouchEvent(MotionEvent ev) {
203         final int action = ev.getAction();
204         switch (action) {
205             case MotionEvent.ACTION_DOWN:
206                 mLastY = ev.getY();
207                 mDragging = false;
208                 View view = mCallback.getChildAtPosition(ev);
209                 if (view instanceof SwipeableItemView) {
210                     mCurrView = (SwipeableItemView) view;
211                 }
212                 mVelocityTracker.clear();
213                 if (mCurrView != null) {
214                     mCurrAnimView = mCurrView.getSwipeableView().getView();
215                     mCanCurrViewBeDimissed = mCallback.canChildBeDismissed(mCurrView);
216                     mVelocityTracker.addMovement(ev);
217                     mInitialTouchPosX = ev.getX();
218                     mInitialTouchPosY = ev.getY();
219                 }
220                 mCallback.cancelDismissCounter();
221                 break;
222             case MotionEvent.ACTION_MOVE:
223                 if (mCurrView != null) {
224                     // Check the movement direction.
225                     if (mLastY >= 0 && !mDragging) {
226                         float currY = ev.getY();
227                         float currX = ev.getX();
228                         float deltaY = Math.abs(currY - mInitialTouchPosY);
229                         float deltaX = Math.abs(currX - mInitialTouchPosX);
230                         if (deltaY > mCurrView.getMinAllowScrollDistance()
231                                 && deltaY > (FACTOR * deltaX)) {
232                             mLastY = ev.getY();
233                             mCallback.onScroll();
234                             return false;
235                         }
236                     }
237                     mVelocityTracker.addMovement(ev);
238                     float pos = ev.getX();
239                     float delta = pos - mInitialTouchPosX;
240                     if (Math.abs(delta) > mPagingTouchSlop) {
241                         mCallback.onBeginDrag(mCurrView.getSwipeableView().getView());
242                         mPrevView = mCallback.getLastSwipedItem();
243                         mDragging = true;
244                         mInitialTouchPosX = ev.getX() - mCurrAnimView.getTranslationX();
245                         mInitialTouchPosY = ev.getY();
246                     }
247                 }
248                 mLastY = ev.getY();
249                 break;
250             case MotionEvent.ACTION_UP:
251             case MotionEvent.ACTION_CANCEL:
252                 mDragging = false;
253                 mCurrView = null;
254                 mCurrAnimView = null;
255                 mLastY = -1;
256                 break;
257         }
258         return mDragging;
259     }
260 
261     /**
262      * @param view The view to be dismissed
263      * @param velocity The desired pixels/second speed at which the view should
264      *            move
265      */
dismissChild(final SwipeableItemView view, float velocity)266     private void dismissChild(final SwipeableItemView view, float velocity) {
267         final View animView = view.getSwipeableView().getView();
268         final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view);
269         float newPos = determinePos(animView, velocity);
270         int duration = determineDuration(animView, newPos, velocity);
271 
272         Utils.enableHardwareLayer(animView);
273         ObjectAnimator anim = createDismissAnimation(animView, newPos, duration);
274         anim.addListener(new AnimatorListenerAdapter() {
275             @Override
276             public void onAnimationEnd(Animator animation) {
277                 mCallback.onChildDismissed(view);
278                 animView.setLayerType(View.LAYER_TYPE_NONE, null);
279             }
280         });
281         anim.addUpdateListener(new AnimatorUpdateListener() {
282             @Override
283             public void onAnimationUpdate(ValueAnimator animation) {
284                 if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
285                     animView.setAlpha(getAlphaForOffset(animView));
286                 }
287                 invalidateGlobalRegion(animView);
288             }
289         });
290         anim.start();
291     }
292 
determineDuration(View animView, float newPos, float velocity)293     private static int determineDuration(View animView, float newPos, float velocity) {
294         int duration = MAX_ESCAPE_ANIMATION_DURATION;
295         if (velocity != 0) {
296             duration = Math
297                     .min(duration,
298                             (int) (Math.abs(newPos - animView.getTranslationX()) * 1000f / Math
299                                     .abs(velocity)));
300         } else {
301             duration = DEFAULT_ESCAPE_ANIMATION_DURATION;
302         }
303         return duration;
304     }
305 
determinePos(View animView, float velocity)306     private float determinePos(View animView, float velocity) {
307         final float newPos;
308         if (velocity < 0 || (velocity == 0 && animView.getTranslationX() < 0)
309         // if we use the Menu to dismiss an item in landscape, animate up
310                 || (velocity == 0 && animView.getTranslationX() == 0 && mSwipeDirection == Y)) {
311             newPos = -getSize(animView);
312         } else {
313             newPos = getSize(animView);
314         }
315         return newPos;
316     }
317 
snapChild(final SwipeableItemView view)318     public void snapChild(final SwipeableItemView view) {
319         final View animView = view.getSwipeableView().getView();
320         final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view);
321         final ObjectAnimator anim = createTranslationAnimation(animView, 0);
322         final int duration = SNAP_ANIM_LEN;
323         anim.setDuration(duration);
324         anim.addUpdateListener(new AnimatorUpdateListener() {
325             @Override
326             public void onAnimationUpdate(ValueAnimator animation) {
327                 if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) {
328                     animView.setAlpha(getAlphaForOffset(animView));
329                 }
330                 invalidateGlobalRegion(animView);
331             }
332         });
333         anim.addListener(new Animator.AnimatorListener() {
334             @Override
335             public void onAnimationStart(Animator animation) {
336             }
337             @Override
338             public void onAnimationEnd(Animator animation) {
339                 animView.setAlpha(1.0f);
340                 mCallback.onDragCancelled(mCurrView);
341             }
342             @Override
343             public void onAnimationCancel(Animator animation) {
344             }
345             @Override
346             public void onAnimationRepeat(Animator animation) {
347             }
348         });
349         anim.start();
350     }
351 
onTouchEvent(MotionEvent ev)352     public boolean onTouchEvent(MotionEvent ev) {
353         if (!mDragging) {
354             return false;
355         }
356         mVelocityTracker.addMovement(ev);
357         final int action = ev.getAction();
358         switch (action) {
359             case MotionEvent.ACTION_OUTSIDE:
360             case MotionEvent.ACTION_MOVE:
361                 if (mCurrView != null) {
362                     float deltaX = ev.getX() - mInitialTouchPosX;
363                     // If the swipe started in the dead region, ignore it.
364                     if (mInitialTouchPosX <= (DEAD_REGION_FOR_SWIPE * mDensityScale)){
365                             return true;
366                     }
367                     // If the user has gone vertical and not gone horizontalish AT
368                     // LEAST minBeforeLock, switch to scroll. Otherwise, cancel
369                     // the swipe.
370                     float minDistance = MIN_SWIPE;
371                     if (Math.abs(deltaX) < minDistance) {
372                         // Don't start the drag until at least X distance has
373                         // occurred.
374                         return true;
375                     }
376                     // don't let items that can't be dismissed be dragged more
377                     // than maxScrollDistance
378                     if (CONSTRAIN_SWIPE && !mCallback.canChildBeDismissed(mCurrView)) {
379                         float size = getSize(mCurrAnimView);
380                         float maxScrollDistance = 0.15f * size;
381                         if (Math.abs(deltaX) >= size) {
382                             deltaX = deltaX > 0 ? maxScrollDistance : -maxScrollDistance;
383                         } else {
384                             deltaX = maxScrollDistance
385                                     * (float) Math.sin((deltaX / size) * (Math.PI / 2));
386                         }
387                     }
388                     setTranslation(mCurrAnimView, deltaX);
389                     if (FADE_OUT_DURING_SWIPE && mCanCurrViewBeDimissed) {
390                         mCurrAnimView.setAlpha(getAlphaForOffset(mCurrAnimView));
391                         if (mPrevView != null) {
392                             // Base how much the text of the prev item is faded
393                             // on how far the current item has moved.
394                             mPrevView.setTextAlpha(getTextAlphaForOffset(mCurrAnimView));
395                         }
396                     }
397                     invalidateGlobalRegion(mCurrView.getSwipeableView().getView());
398                 }
399                 break;
400             case MotionEvent.ACTION_UP:
401             case MotionEvent.ACTION_CANCEL:
402                 if (mCurrView != null) {
403                     float maxVelocity = MAX_DISMISS_VELOCITY * mDensityScale;
404                     mVelocityTracker.computeCurrentVelocity(1000 /* px/sec */, maxVelocity);
405                     float escapeVelocity = SWIPE_ESCAPE_VELOCITY * mDensityScale;
406                     float velocity = getVelocity(mVelocityTracker);
407                     float perpendicularVelocity = getPerpendicularVelocity(mVelocityTracker);
408 
409                     // Decide whether to dismiss the current view
410                     // Tweak constants below as required to prevent erroneous
411                     // swipe/dismiss
412                     float translation = Math.abs(mCurrAnimView.getTranslationX());
413                     float currAnimViewSize = getSize(mCurrAnimView);
414                     // Long swipe = translation of .4 * width
415                     boolean childSwipedFarEnough = DISMISS_IF_SWIPED_FAR_ENOUGH
416                             && translation > 0.4 * currAnimViewSize;
417                     // Fast swipe = > escapeVelocity and translation of .1 *
418                     // width
419                     boolean childSwipedFastEnough = (Math.abs(velocity) > escapeVelocity)
420                             && (Math.abs(velocity) > Math.abs(perpendicularVelocity))
421                             && (velocity > 0) == (mCurrAnimView.getTranslationX() > 0)
422                             && translation > 0.05 * currAnimViewSize;
423                     if (LOG_SWIPE_DISMISS_VELOCITY) {
424                         LogUtils.v(TAG, "Swipe/Dismiss: " + velocity + "/" + escapeVelocity + "/"
425                                 + perpendicularVelocity + ", x: " + translation + "/"
426                                 + currAnimViewSize);
427                     }
428 
429                     boolean dismissChild = mCallback.canChildBeDismissed(mCurrView)
430                             && (childSwipedFastEnough || childSwipedFarEnough);
431 
432                     if (dismissChild) {
433                         dismissChild(mCurrView, childSwipedFastEnough ? velocity : 0f);
434                     } else {
435                         snapChild(mCurrView);
436                     }
437                 }
438                 break;
439         }
440         return true;
441     }
442 
443     public interface Callback {
getChildAtPosition(MotionEvent ev)444         View getChildAtPosition(MotionEvent ev);
445 
cancelDismissCounter()446         void cancelDismissCounter();
447 
onScroll()448         void onScroll();
449 
canChildBeDismissed(SwipeableItemView v)450         boolean canChildBeDismissed(SwipeableItemView v);
451 
onBeginDrag(View v)452         void onBeginDrag(View v);
453 
onChildDismissed(SwipeableItemView v)454         void onChildDismissed(SwipeableItemView v);
455 
onDragCancelled(SwipeableItemView v)456         void onDragCancelled(SwipeableItemView v);
457 
getCheckedSet()458         ConversationCheckedSet getCheckedSet();
459 
getLastSwipedItem()460         LeaveBehindItem getLastSwipedItem();
461     }
462 }
463