1/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16package com.android.messaging.ui.conversationlist;
17
18import android.animation.Animator;
19import android.animation.AnimatorListenerAdapter;
20import android.animation.ObjectAnimator;
21import android.animation.TimeInterpolator;
22import android.content.Context;
23import android.content.res.Resources;
24import android.support.v4.view.ViewCompat;
25import android.support.v7.widget.RecyclerView;
26import android.support.v7.widget.RecyclerView.OnItemTouchListener;
27import android.view.MotionEvent;
28import android.view.VelocityTracker;
29import android.view.View;
30import android.view.ViewConfiguration;
31
32import com.android.messaging.R;
33import com.android.messaging.util.Assert;
34import com.android.messaging.util.UiUtils;
35
36/**
37 * Animation and touch helper class for Conversation List swipe.
38 */
39public class ConversationListSwipeHelper implements OnItemTouchListener {
40    private static final int UNIT_SECONDS = 1000;
41    private static final boolean ANIMATING = true;
42
43    private static final float ERROR_FACTOR_MULTIPLIER = 1.2f;
44    private static final float PERCENTAGE_OF_WIDTH_TO_DISMISS = 0.4f;
45    private static final float FLING_PERCENTAGE_OF_WIDTH_TO_DISMISS = 0.05f;
46
47    private static final int SWIPE_DIRECTION_NONE = 0;
48    private static final int SWIPE_DIRECTION_LEFT = 1;
49    private static final int SWIPE_DIRECTION_RIGHT = 2;
50
51    private final RecyclerView mRecyclerView;
52    private final long mDefaultRestoreAnimationDuration;
53    private final long mDefaultDismissAnimationDuration;
54    private final long mMaxTranslationAnimationDuration;
55    private final int mTouchSlop;
56    private final int mMinimumFlingVelocity;
57    private final int mMaximumFlingVelocity;
58
59    /* Valid throughout a single gesture. */
60    private VelocityTracker mVelocityTracker;
61    private float mInitialX;
62    private float mInitialY;
63    private boolean mIsSwiping;
64    private ConversationListItemView mListItemView;
65
66    public ConversationListSwipeHelper(final RecyclerView recyclerView) {
67        mRecyclerView = recyclerView;
68
69        final Context context = mRecyclerView.getContext();
70        final Resources res = context.getResources();
71        mDefaultRestoreAnimationDuration = res.getInteger(R.integer.swipe_duration_ms);
72        mDefaultDismissAnimationDuration = res.getInteger(R.integer.swipe_duration_ms);
73        mMaxTranslationAnimationDuration = res.getInteger(R.integer.swipe_duration_ms);
74
75        final ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
76        mTouchSlop = viewConfiguration.getScaledPagingTouchSlop();
77        mMaximumFlingVelocity = Math.min(
78                viewConfiguration.getScaledMaximumFlingVelocity(),
79                res.getInteger(R.integer.swipe_max_fling_velocity_px_per_s));
80        mMinimumFlingVelocity = viewConfiguration.getScaledMinimumFlingVelocity();
81    }
82
83    @Override
84    public boolean onInterceptTouchEvent(final RecyclerView recyclerView, final MotionEvent event) {
85        if (event.getPointerCount() > 1) {
86            // Ignore subsequent pointers.
87            return false;
88        }
89
90        // We are not yet tracking a swipe gesture. Begin detection by spying on
91        // touch events bubbling down to our children.
92        final int action = event.getActionMasked();
93        switch (action) {
94            case MotionEvent.ACTION_DOWN:
95                if (!hasGestureSwipeTarget()) {
96                    onGestureStart();
97
98                    mVelocityTracker.addMovement(event);
99                    mInitialX = event.getX();
100                    mInitialY = event.getY();
101
102                    final View viewAtPoint = mRecyclerView.findChildViewUnder(mInitialX, mInitialY);
103                    final ConversationListItemView child = (ConversationListItemView) viewAtPoint;
104                    if (viewAtPoint instanceof ConversationListItemView &&
105                            child != null && child.isSwipeAnimatable()) {
106                        // Begin detecting swipe on the target for the rest of the gesture.
107                        mListItemView = child;
108                        if (mListItemView.isAnimating()) {
109                            mListItemView = null;
110                        }
111                    } else {
112                        mListItemView = null;
113                    }
114                }
115                break;
116            case MotionEvent.ACTION_MOVE:
117                if (hasValidGestureSwipeTarget()) {
118                    mVelocityTracker.addMovement(event);
119
120                    final int historicalCount = event.getHistorySize();
121                    // First consume the historical events, then consume the current ones.
122                    for (int i = 0; i < historicalCount + 1; i++) {
123                        float currX;
124                        float currY;
125                        if (i < historicalCount) {
126                            currX = event.getHistoricalX(i);
127                            currY = event.getHistoricalY(i);
128                        } else {
129                            currX = event.getX();
130                            currY = event.getY();
131                        }
132                        final float deltaX = currX - mInitialX;
133                        final float deltaY = currY - mInitialY;
134                        final float absDeltaX = Math.abs(deltaX);
135                        final float absDeltaY = Math.abs(deltaY);
136
137                        if (!mIsSwiping && absDeltaY > mTouchSlop
138                                && absDeltaY > (ERROR_FACTOR_MULTIPLIER * absDeltaX)) {
139                            // Stop detecting swipe for the remainder of this gesture.
140                            onGestureEnd();
141                            return false;
142                        }
143
144                        if (absDeltaX > mTouchSlop) {
145                            // Swipe detected. Return true so we can handle the gesture in
146                            // onTouchEvent.
147                            mIsSwiping = true;
148
149                            // We don't want to suddenly jump the slop distance.
150                            mInitialX = event.getX();
151                            mInitialY = event.getY();
152
153                            onSwipeGestureStart(mListItemView);
154                            return true;
155                        }
156                    }
157                }
158                break;
159            case MotionEvent.ACTION_UP:
160            case MotionEvent.ACTION_CANCEL:
161                if (hasGestureSwipeTarget()) {
162                    onGestureEnd();
163                }
164                break;
165        }
166
167        // Start intercepting touch events from children if we detect a swipe.
168        return mIsSwiping;
169    }
170
171    @Override
172    public void onTouchEvent(final RecyclerView recyclerView, final MotionEvent event) {
173        // We should only be here if we intercepted the touch due to swipe.
174        Assert.isTrue(mIsSwiping);
175
176        // We are now tracking a swipe gesture.
177        mVelocityTracker.addMovement(event);
178
179        final int action = event.getActionMasked();
180        switch (action) {
181            case MotionEvent.ACTION_OUTSIDE:
182            case MotionEvent.ACTION_MOVE:
183                if (hasValidGestureSwipeTarget()) {
184                    mListItemView.setSwipeTranslationX(event.getX() - mInitialX);
185                }
186                break;
187            case MotionEvent.ACTION_UP:
188                if (hasValidGestureSwipeTarget()) {
189                    final float maxVelocity = mMaximumFlingVelocity;
190                    mVelocityTracker.computeCurrentVelocity(UNIT_SECONDS, maxVelocity);
191                    final float velocityX = getLastComputedXVelocity();
192
193                    final float translationX = mListItemView.getSwipeTranslationX();
194
195                    int swipeDirection = SWIPE_DIRECTION_NONE;
196                    if (translationX != 0) {
197                        swipeDirection =
198                                translationX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT;
199                    } else if (velocityX != 0) {
200                        swipeDirection =
201                                velocityX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT;
202                    }
203
204                    final boolean fastEnough = isTargetSwipedFastEnough();
205                    final boolean farEnough = isTargetSwipedFarEnough();
206
207                    final boolean shouldDismiss =  (fastEnough || farEnough);
208
209                    if (shouldDismiss) {
210                        if (fastEnough) {
211                            animateDismiss(mListItemView, velocityX);
212                        } else {
213                            animateDismiss(mListItemView, swipeDirection);
214                        }
215                    } else {
216                        animateRestore(mListItemView, velocityX);
217                    }
218
219                    onSwipeGestureEnd(mListItemView,
220                            shouldDismiss ? swipeDirection : SWIPE_DIRECTION_NONE);
221                } else {
222                    onGestureEnd();
223                }
224                break;
225            case MotionEvent.ACTION_CANCEL:
226                if (hasValidGestureSwipeTarget()) {
227                    animateRestore(mListItemView, 0f);
228                    onSwipeGestureEnd(mListItemView, SWIPE_DIRECTION_NONE);
229                } else {
230                    onGestureEnd();
231                }
232                break;
233        }
234    }
235
236
237    @Override
238    public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
239    }
240
241    /**
242     * We have started to intercept a series of touch events.
243     */
244    private void onGestureStart() {
245        mIsSwiping = false;
246        // Work around bug in RecyclerView that sends two identical ACTION_DOWN
247        // events to #onInterceptTouchEvent.
248        if (mVelocityTracker == null) {
249            mVelocityTracker = VelocityTracker.obtain();
250        }
251        mVelocityTracker.clear();
252    }
253
254    /**
255     * The series of touch events has been detected as a swipe.
256     *
257     * Now that the gesture is a swipe, we will begin translating the view of the
258     * given viewHolder.
259     */
260    private void onSwipeGestureStart(final ConversationListItemView itemView) {
261        mRecyclerView.getParent().requestDisallowInterceptTouchEvent(true);
262        setHardwareAnimatingLayerType(itemView, ANIMATING);
263        itemView.setAnimating(true);
264    }
265
266    /**
267     * The current swipe gesture is complete.
268     */
269    private void onSwipeGestureEnd(final ConversationListItemView itemView,
270            final int swipeDirection) {
271        if (swipeDirection == SWIPE_DIRECTION_RIGHT || swipeDirection == SWIPE_DIRECTION_LEFT) {
272            itemView.onSwipeComplete();
273        }
274
275        // Balances out onSwipeGestureStart.
276        itemView.setAnimating(false);
277
278        onGestureEnd();
279    }
280
281    /**
282     * The series of touch events has ended in an {@link MotionEvent#ACTION_UP}
283     * or {@link MotionEvent#ACTION_CANCEL}.
284     */
285    private void onGestureEnd() {
286        mVelocityTracker.recycle();
287        mVelocityTracker = null;
288        mIsSwiping = false;
289        mListItemView = null;
290    }
291
292    /**
293     * A swipe animation has started.
294     */
295    private void onSwipeAnimationStart(final ConversationListItemView itemView) {
296        // Disallow interactions.
297        itemView.setAnimating(true);
298        ViewCompat.setHasTransientState(itemView, true);
299        setHardwareAnimatingLayerType(itemView, ANIMATING);
300    }
301
302    /**
303     * The swipe animation has ended.
304     */
305    private void onSwipeAnimationEnd(final ConversationListItemView itemView) {
306        // Restore interactions.
307        itemView.setAnimating(false);
308        ViewCompat.setHasTransientState(itemView, false);
309        setHardwareAnimatingLayerType(itemView, !ANIMATING);
310    }
311
312    /**
313     * Animate the dismissal of the given item. The given velocityX is taken into consideration for
314     * the animation duration. Whether the item is dismissed to the left or right is dependent on
315     * the given velocityX.
316     */
317    private void animateDismiss(final ConversationListItemView itemView, final float velocityX) {
318        Assert.isTrue(velocityX != 0);
319        final int direction = velocityX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT;
320        animateDismiss(itemView, direction, velocityX);
321    }
322
323    /**
324     * Animate the dismissal of the given item. The velocityX is assumed to be 0.
325     */
326    private void animateDismiss(final ConversationListItemView itemView, final int swipeDirection) {
327        animateDismiss(itemView, swipeDirection, 0f);
328    }
329
330    /**
331     * Animate the dismissal of the given item.
332     */
333    private void animateDismiss(final ConversationListItemView itemView,
334            final int swipeDirection, final float velocityX) {
335        Assert.isTrue(swipeDirection != SWIPE_DIRECTION_NONE);
336
337        onSwipeAnimationStart(itemView);
338
339        final float animateTo = (swipeDirection == SWIPE_DIRECTION_RIGHT) ?
340                mRecyclerView.getWidth() : -mRecyclerView.getWidth();
341        final long duration;
342        if (velocityX != 0) {
343            final float deltaX = animateTo - itemView.getSwipeTranslationX();
344            duration = calculateTranslationDuration(deltaX, velocityX);
345        } else {
346            duration = mDefaultDismissAnimationDuration;
347        }
348
349        final ObjectAnimator animator = getSwipeTranslationXAnimator(
350                itemView, animateTo, duration, UiUtils.DEFAULT_INTERPOLATOR);
351        animator.addListener(new AnimatorListenerAdapter() {
352            @Override
353            public void onAnimationEnd(final Animator animation) {
354                onSwipeAnimationEnd(itemView);
355            }
356        });
357        animator.start();
358    }
359
360    /**
361     * Animate the bounce back of the given item.
362     */
363    private void animateRestore(final ConversationListItemView itemView,
364            final float velocityX) {
365        onSwipeAnimationStart(itemView);
366
367        final float translationX = itemView.getSwipeTranslationX();
368        final long duration;
369        if (velocityX != 0 // Has velocity.
370                && velocityX > 0 != translationX > 0) { // Right direction.
371            duration = calculateTranslationDuration(translationX, velocityX);
372        } else {
373            duration = mDefaultRestoreAnimationDuration;
374        }
375        final ObjectAnimator animator = getSwipeTranslationXAnimator(
376                itemView, 0f, duration, UiUtils.DEFAULT_INTERPOLATOR);
377        animator.addListener(new AnimatorListenerAdapter() {
378            @Override
379            public void onAnimationEnd(final Animator animation) {
380                       onSwipeAnimationEnd(itemView);
381            }
382        });
383        animator.start();
384    }
385
386    /**
387     * Create and start an animator that animates the given view's translationX
388     * from its current value to the value given by animateTo.
389     */
390    private ObjectAnimator getSwipeTranslationXAnimator(final ConversationListItemView itemView,
391            final float animateTo, final long duration, final TimeInterpolator interpolator) {
392        final ObjectAnimator animator =
393                ObjectAnimator.ofFloat(itemView, "swipeTranslationX", animateTo);
394        animator.setDuration(duration);
395        animator.setInterpolator(interpolator);
396        return animator;
397    }
398
399    /**
400     * Determine if the swipe has enough velocity to be dismissed.
401     */
402    private boolean isTargetSwipedFastEnough() {
403        final float velocityX = getLastComputedXVelocity();
404        final float velocityY = mVelocityTracker.getYVelocity();
405        final float minVelocity = mMinimumFlingVelocity;
406        final float translationX = mListItemView.getSwipeTranslationX();
407        final float width = mListItemView.getWidth();
408        return (Math.abs(velocityX) > minVelocity) // Fast enough.
409                && (Math.abs(velocityX) > Math.abs(velocityY)) // Not unintentional.
410                && (velocityX > 0) == (translationX > 0) // Right direction.
411                && Math.abs(translationX) >
412                    FLING_PERCENTAGE_OF_WIDTH_TO_DISMISS * width; // Enough movement.
413  }
414
415    /**
416     * Only used during a swipe gesture. Determine if the swipe has enough distance to be
417     * dismissed.
418     */
419    private boolean isTargetSwipedFarEnough() {
420        final float velocityX = getLastComputedXVelocity();
421
422        final float translationX = mListItemView.getSwipeTranslationX();
423        final float width = mListItemView.getWidth();
424
425        return (velocityX >= 0) == (translationX > 0) // Right direction.
426                && Math.abs(translationX) >
427                    PERCENTAGE_OF_WIDTH_TO_DISMISS * width; // Enough movement.
428  }
429
430    private long calculateTranslationDuration(final float deltaPosition, final float velocity) {
431        Assert.isTrue(velocity != 0);
432        final float durationInSeconds = Math.abs(deltaPosition / velocity);
433        return Math.min((int) (durationInSeconds * UNIT_SECONDS), mMaxTranslationAnimationDuration);
434    }
435
436    private boolean hasGestureSwipeTarget() {
437        return mListItemView != null;
438    }
439
440    private boolean hasValidGestureSwipeTarget() {
441        return hasGestureSwipeTarget() && mListItemView.getParent() == mRecyclerView;
442    }
443
444    /**
445     * Enable a hardware layer for the it view and build that layer.
446     */
447    private void setHardwareAnimatingLayerType(final ConversationListItemView itemView,
448            final boolean animating) {
449        if (animating) {
450            itemView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
451            if (itemView.getWindowToken() != null) {
452                itemView.buildLayer();
453            }
454        } else {
455            itemView.setLayerType(View.LAYER_TYPE_NONE, null);
456        }
457    }
458
459    private float getLastComputedXVelocity() {
460        return mVelocityTracker.getXVelocity();
461    }
462}