com.android.messaging.ui.conversationlist.ConversationListSwipeHelper.java Source code

Java tutorial

Introduction

Here is the source code for com.android.messaging.ui.conversationlist.ConversationListSwipeHelper.java

Source

/*
 * Copyright (C) 2015 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.android.messaging.ui.conversationlist;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.animation.TimeInterpolator;
import android.content.Context;
import android.content.res.Resources;
import android.support.v4.view.ViewCompat;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.OnItemTouchListener;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewConfiguration;

import com.android.messaging.R;
import com.android.messaging.util.Assert;
import com.android.messaging.util.UiUtils;

/**
 * Animation and touch helper class for Conversation List swipe.
 */
public class ConversationListSwipeHelper implements OnItemTouchListener {
    private static final int UNIT_SECONDS = 1000;
    private static final boolean ANIMATING = true;

    private static final float ERROR_FACTOR_MULTIPLIER = 1.2f;
    private static final float PERCENTAGE_OF_WIDTH_TO_DISMISS = 0.4f;
    private static final float FLING_PERCENTAGE_OF_WIDTH_TO_DISMISS = 0.05f;

    private static final int SWIPE_DIRECTION_NONE = 0;
    private static final int SWIPE_DIRECTION_LEFT = 1;
    private static final int SWIPE_DIRECTION_RIGHT = 2;

    private final RecyclerView mRecyclerView;
    private final long mDefaultRestoreAnimationDuration;
    private final long mDefaultDismissAnimationDuration;
    private final long mMaxTranslationAnimationDuration;
    private final int mTouchSlop;
    private final int mMinimumFlingVelocity;
    private final int mMaximumFlingVelocity;

    /* Valid throughout a single gesture. */
    private VelocityTracker mVelocityTracker;
    private float mInitialX;
    private float mInitialY;
    private boolean mIsSwiping;
    private ConversationListItemView mListItemView;

    public ConversationListSwipeHelper(final RecyclerView recyclerView) {
        mRecyclerView = recyclerView;

        final Context context = mRecyclerView.getContext();
        final Resources res = context.getResources();
        mDefaultRestoreAnimationDuration = res.getInteger(R.integer.swipe_duration_ms);
        mDefaultDismissAnimationDuration = res.getInteger(R.integer.swipe_duration_ms);
        mMaxTranslationAnimationDuration = res.getInteger(R.integer.swipe_duration_ms);

        final ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
        mTouchSlop = viewConfiguration.getScaledPagingTouchSlop();
        mMaximumFlingVelocity = Math.min(viewConfiguration.getScaledMaximumFlingVelocity(),
                res.getInteger(R.integer.swipe_max_fling_velocity_px_per_s));
        mMinimumFlingVelocity = viewConfiguration.getScaledMinimumFlingVelocity();
    }

    @Override
    public boolean onInterceptTouchEvent(final RecyclerView recyclerView, final MotionEvent event) {
        if (event.getPointerCount() > 1) {
            // Ignore subsequent pointers.
            return false;
        }

        // We are not yet tracking a swipe gesture. Begin detection by spying on
        // touch events bubbling down to our children.
        final int action = event.getActionMasked();
        switch (action) {
        case MotionEvent.ACTION_DOWN:
            if (!hasGestureSwipeTarget()) {
                onGestureStart();

                mVelocityTracker.addMovement(event);
                mInitialX = event.getX();
                mInitialY = event.getY();

                final View viewAtPoint = mRecyclerView.findChildViewUnder(mInitialX, mInitialY);
                final ConversationListItemView child = (ConversationListItemView) viewAtPoint;
                if (viewAtPoint instanceof ConversationListItemView && child != null && child.isSwipeAnimatable()) {
                    // Begin detecting swipe on the target for the rest of the gesture.
                    mListItemView = child;
                    if (mListItemView.isAnimating()) {
                        mListItemView = null;
                    }
                } else {
                    mListItemView = null;
                }
            }
            break;
        case MotionEvent.ACTION_MOVE:
            if (hasValidGestureSwipeTarget()) {
                mVelocityTracker.addMovement(event);

                final int historicalCount = event.getHistorySize();
                // First consume the historical events, then consume the current ones.
                for (int i = 0; i < historicalCount + 1; i++) {
                    float currX;
                    float currY;
                    if (i < historicalCount) {
                        currX = event.getHistoricalX(i);
                        currY = event.getHistoricalY(i);
                    } else {
                        currX = event.getX();
                        currY = event.getY();
                    }
                    final float deltaX = currX - mInitialX;
                    final float deltaY = currY - mInitialY;
                    final float absDeltaX = Math.abs(deltaX);
                    final float absDeltaY = Math.abs(deltaY);

                    if (!mIsSwiping && absDeltaY > mTouchSlop
                            && absDeltaY > (ERROR_FACTOR_MULTIPLIER * absDeltaX)) {
                        // Stop detecting swipe for the remainder of this gesture.
                        onGestureEnd();
                        return false;
                    }

                    if (absDeltaX > mTouchSlop) {
                        // Swipe detected. Return true so we can handle the gesture in
                        // onTouchEvent.
                        mIsSwiping = true;

                        // We don't want to suddenly jump the slop distance.
                        mInitialX = event.getX();
                        mInitialY = event.getY();

                        onSwipeGestureStart(mListItemView);
                        return true;
                    }
                }
            }
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            if (hasGestureSwipeTarget()) {
                onGestureEnd();
            }
            break;
        }

        // Start intercepting touch events from children if we detect a swipe.
        return mIsSwiping;
    }

    @Override
    public void onTouchEvent(final RecyclerView recyclerView, final MotionEvent event) {
        // We should only be here if we intercepted the touch due to swipe.
        Assert.isTrue(mIsSwiping);

        // We are now tracking a swipe gesture.
        mVelocityTracker.addMovement(event);

        final int action = event.getActionMasked();
        switch (action) {
        case MotionEvent.ACTION_OUTSIDE:
        case MotionEvent.ACTION_MOVE:
            if (hasValidGestureSwipeTarget()) {
                mListItemView.setSwipeTranslationX(event.getX() - mInitialX);
            }
            break;
        case MotionEvent.ACTION_UP:
            if (hasValidGestureSwipeTarget()) {
                final float maxVelocity = mMaximumFlingVelocity;
                mVelocityTracker.computeCurrentVelocity(UNIT_SECONDS, maxVelocity);
                final float velocityX = getLastComputedXVelocity();

                final float translationX = mListItemView.getSwipeTranslationX();

                int swipeDirection = SWIPE_DIRECTION_NONE;
                if (translationX != 0) {
                    swipeDirection = translationX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT;
                } else if (velocityX != 0) {
                    swipeDirection = velocityX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT;
                }

                final boolean fastEnough = isTargetSwipedFastEnough();
                final boolean farEnough = isTargetSwipedFarEnough();

                final boolean shouldDismiss = (fastEnough || farEnough);

                if (shouldDismiss) {
                    if (fastEnough) {
                        animateDismiss(mListItemView, velocityX);
                    } else {
                        animateDismiss(mListItemView, swipeDirection);
                    }
                } else {
                    animateRestore(mListItemView, velocityX);
                }

                onSwipeGestureEnd(mListItemView, shouldDismiss ? swipeDirection : SWIPE_DIRECTION_NONE);
            } else {
                onGestureEnd();
            }
            break;
        case MotionEvent.ACTION_CANCEL:
            if (hasValidGestureSwipeTarget()) {
                animateRestore(mListItemView, 0f);
                onSwipeGestureEnd(mListItemView, SWIPE_DIRECTION_NONE);
            } else {
                onGestureEnd();
            }
            break;
        }
    }

    @Override
    public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
    }

    /**
     * We have started to intercept a series of touch events.
     */
    private void onGestureStart() {
        mIsSwiping = false;
        // Work around bug in RecyclerView that sends two identical ACTION_DOWN
        // events to #onInterceptTouchEvent.
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.clear();
    }

    /**
     * The series of touch events has been detected as a swipe.
     *
     * Now that the gesture is a swipe, we will begin translating the view of the
     * given viewHolder.
     */
    private void onSwipeGestureStart(final ConversationListItemView itemView) {
        mRecyclerView.getParent().requestDisallowInterceptTouchEvent(true);
        setHardwareAnimatingLayerType(itemView, ANIMATING);
        itemView.setAnimating(true);
    }

    /**
     * The current swipe gesture is complete.
     */
    private void onSwipeGestureEnd(final ConversationListItemView itemView, final int swipeDirection) {
        if (swipeDirection == SWIPE_DIRECTION_RIGHT || swipeDirection == SWIPE_DIRECTION_LEFT) {
            itemView.onSwipeComplete();
        }

        // Balances out onSwipeGestureStart.
        itemView.setAnimating(false);

        onGestureEnd();
    }

    /**
     * The series of touch events has ended in an {@link MotionEvent#ACTION_UP}
     * or {@link MotionEvent#ACTION_CANCEL}.
     */
    private void onGestureEnd() {
        mVelocityTracker.recycle();
        mVelocityTracker = null;
        mIsSwiping = false;
        mListItemView = null;
    }

    /**
     * A swipe animation has started.
     */
    private void onSwipeAnimationStart(final ConversationListItemView itemView) {
        // Disallow interactions.
        itemView.setAnimating(true);
        ViewCompat.setHasTransientState(itemView, true);
        setHardwareAnimatingLayerType(itemView, ANIMATING);
    }

    /**
     * The swipe animation has ended.
     */
    private void onSwipeAnimationEnd(final ConversationListItemView itemView) {
        // Restore interactions.
        itemView.setAnimating(false);
        ViewCompat.setHasTransientState(itemView, false);
        setHardwareAnimatingLayerType(itemView, !ANIMATING);
    }

    /**
     * Animate the dismissal of the given item. The given velocityX is taken into consideration for
     * the animation duration. Whether the item is dismissed to the left or right is dependent on
     * the given velocityX.
     */
    private void animateDismiss(final ConversationListItemView itemView, final float velocityX) {
        Assert.isTrue(velocityX != 0);
        final int direction = velocityX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT;
        animateDismiss(itemView, direction, velocityX);
    }

    /**
     * Animate the dismissal of the given item. The velocityX is assumed to be 0.
     */
    private void animateDismiss(final ConversationListItemView itemView, final int swipeDirection) {
        animateDismiss(itemView, swipeDirection, 0f);
    }

    /**
     * Animate the dismissal of the given item.
     */
    private void animateDismiss(final ConversationListItemView itemView, final int swipeDirection,
            final float velocityX) {
        Assert.isTrue(swipeDirection != SWIPE_DIRECTION_NONE);

        onSwipeAnimationStart(itemView);

        final float animateTo = (swipeDirection == SWIPE_DIRECTION_RIGHT) ? mRecyclerView.getWidth()
                : -mRecyclerView.getWidth();
        final long duration;
        if (velocityX != 0) {
            final float deltaX = animateTo - itemView.getSwipeTranslationX();
            duration = calculateTranslationDuration(deltaX, velocityX);
        } else {
            duration = mDefaultDismissAnimationDuration;
        }

        final ObjectAnimator animator = getSwipeTranslationXAnimator(itemView, animateTo, duration,
                UiUtils.DEFAULT_INTERPOLATOR);
        animator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(final Animator animation) {
                onSwipeAnimationEnd(itemView);
            }
        });
        animator.start();
    }

    /**
     * Animate the bounce back of the given item.
     */
    private void animateRestore(final ConversationListItemView itemView, final float velocityX) {
        onSwipeAnimationStart(itemView);

        final float translationX = itemView.getSwipeTranslationX();
        final long duration;
        if (velocityX != 0 // Has velocity.
                && velocityX > 0 != translationX > 0) { // Right direction.
            duration = calculateTranslationDuration(translationX, velocityX);
        } else {
            duration = mDefaultRestoreAnimationDuration;
        }
        final ObjectAnimator animator = getSwipeTranslationXAnimator(itemView, 0f, duration,
                UiUtils.DEFAULT_INTERPOLATOR);
        animator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(final Animator animation) {
                onSwipeAnimationEnd(itemView);
            }
        });
        animator.start();
    }

    /**
     * Create and start an animator that animates the given view's translationX
     * from its current value to the value given by animateTo.
     */
    private ObjectAnimator getSwipeTranslationXAnimator(final ConversationListItemView itemView,
            final float animateTo, final long duration, final TimeInterpolator interpolator) {
        final ObjectAnimator animator = ObjectAnimator.ofFloat(itemView, "swipeTranslationX", animateTo);
        animator.setDuration(duration);
        animator.setInterpolator(interpolator);
        return animator;
    }

    /**
     * Determine if the swipe has enough velocity to be dismissed.
     */
    private boolean isTargetSwipedFastEnough() {
        final float velocityX = getLastComputedXVelocity();
        final float velocityY = mVelocityTracker.getYVelocity();
        final float minVelocity = mMinimumFlingVelocity;
        final float translationX = mListItemView.getSwipeTranslationX();
        final float width = mListItemView.getWidth();
        return (Math.abs(velocityX) > minVelocity) // Fast enough.
                && (Math.abs(velocityX) > Math.abs(velocityY)) // Not unintentional.
                && (velocityX > 0) == (translationX > 0) // Right direction.
                && Math.abs(translationX) > FLING_PERCENTAGE_OF_WIDTH_TO_DISMISS * width; // Enough movement.
    }

    /**
     * Only used during a swipe gesture. Determine if the swipe has enough distance to be
     * dismissed.
     */
    private boolean isTargetSwipedFarEnough() {
        final float velocityX = getLastComputedXVelocity();

        final float translationX = mListItemView.getSwipeTranslationX();
        final float width = mListItemView.getWidth();

        return (velocityX >= 0) == (translationX > 0) // Right direction.
                && Math.abs(translationX) > PERCENTAGE_OF_WIDTH_TO_DISMISS * width; // Enough movement.
    }

    private long calculateTranslationDuration(final float deltaPosition, final float velocity) {
        Assert.isTrue(velocity != 0);
        final float durationInSeconds = Math.abs(deltaPosition / velocity);
        return Math.min((int) (durationInSeconds * UNIT_SECONDS), mMaxTranslationAnimationDuration);
    }

    private boolean hasGestureSwipeTarget() {
        return mListItemView != null;
    }

    private boolean hasValidGestureSwipeTarget() {
        return hasGestureSwipeTarget() && mListItemView.getParent() == mRecyclerView;
    }

    /**
     * Enable a hardware layer for the it view and build that layer.
     */
    private void setHardwareAnimatingLayerType(final ConversationListItemView itemView, final boolean animating) {
        if (animating) {
            itemView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
            if (itemView.getWindowToken() != null) {
                itemView.buildLayer();
            }
        } else {
            itemView.setLayerType(View.LAYER_TYPE_NONE, null);
        }
    }

    private float getLastComputedXVelocity() {
        return mVelocityTracker.getXVelocity();
    }
}