/* * Copyright 2014 Niek Haarman * * 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. */ /* Originally based on Roman Nurik's SwipeDismissListViewTouchListener (https://gist.github.com/romannurik/2980593). */ package com.nhaarman.listviewanimations.itemmanipulation.swipedismiss; import android.graphics.Rect; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.widget.AdapterView; import com.nhaarman.listviewanimations.itemmanipulation.TouchEventHandler; import com.nhaarman.listviewanimations.util.AdapterViewUtil; import com.nhaarman.listviewanimations.util.ListViewWrapper; import com.nineoldandroids.animation.Animator; import com.nineoldandroids.animation.AnimatorListenerAdapter; import com.nineoldandroids.animation.AnimatorSet; import com.nineoldandroids.animation.ObjectAnimator; import com.nineoldandroids.view.ViewHelper; import com.pan.simplepicture.annotations.NonNull; import com.pan.simplepicture.annotations.Nullable; /** * An {@link android.view.View.OnTouchListener} that makes the list items in a * {@link android.widget.AbsListView} swipeable. Implementations of this class * should implement {@link #afterViewFling(android.view.View, int)} to specify * what to do after an item has been swiped. */ public abstract class SwipeTouchListener implements View.OnTouchListener, TouchEventHandler { /** * TranslationX View property. */ private static final String TRANSLATION_X = "translationX"; /** * Alpha View property. */ private static final String ALPHA = "alpha"; private static final int MIN_FLING_VELOCITY_FACTOR = 16; /** * The minimum distance in pixels that should be moved before starting * horizontal item movement. */ private final int mSlop; /** * The minimum velocity to initiate a fling, as measured in pixels per * second. */ private final int mMinFlingVelocity; /** * The maximum velocity to initiate a fling, as measured in pixels per * second. */ private final int mMaxFlingVelocity; /** * The duration of the fling animation. */ private final long mAnimationTime; @NonNull private final ListViewWrapper mListViewWrapper; /** * The minimum alpha value of swiped Views. */ private float mMinimumAlpha; /** * The width of the {@link android.widget.AbsListView} in pixels. */ private int mViewWidth = 1; /** * The raw X coordinate of the down event. */ private float mDownX; /** * The raw Y coordinate of the down event. */ private float mDownY; /** * Indicates whether the user is swiping an item. */ private boolean mSwiping; /** * Indicates whether the user can dismiss the current item. */ private boolean mCanDismissCurrent; /** * The {@code VelocityTracker} used in the swipe movement. */ @Nullable private VelocityTracker mVelocityTracker; /** * The parent {@link android.view.View} being swiped. */ @Nullable private View mCurrentView; /** * The {@link android.view.View} that is actually being swiped. */ @Nullable private View mSwipingView; /** * The current position being swiped. */ private int mCurrentPosition = AdapterView.INVALID_POSITION; /** * The number of items in the {@code AbsListView}, minus the pending * dismissed items. */ private int mVirtualListCount = -1; /** * Indicates whether the {@link android.widget.AbsListView} is in a * horizontal scroll container. If so, this class will prevent the * horizontal scroller from receiving any touch events. */ private boolean mParentIsHorizontalScrollContainer; /** * The resource id of the {@link android.view.View} that may steal touch * events from their parents. Useful for example when the * {@link android.widget.AbsListView} is in a horizontal scroll container, * but not the whole {@code AbsListView} should steal the touch events. */ private int mTouchChildResId; /** * The * {@link com.nhaarman.listviewanimations.itemmanipulation.swipedismiss.DismissableManager} * which decides whether or not a list item can be swiped. */ @Nullable private DismissableManager mDismissableManager; /** * The number of active swipe animations. */ private int mActiveSwipeCount; /** * Indicates whether swipe is enabled. */ private boolean mSwipeEnabled = true; /** * Constructs a new {@code SwipeTouchListener} for the given * {@link android.widget.AbsListView}. */ @SuppressWarnings("UnnecessaryFullyQualifiedName") protected SwipeTouchListener(@NonNull final ListViewWrapper listViewWrapper) { ViewConfiguration vc = ViewConfiguration.get(listViewWrapper .getListView().getContext()); mSlop = vc.getScaledTouchSlop(); mMinFlingVelocity = vc.getScaledMinimumFlingVelocity() * MIN_FLING_VELOCITY_FACTOR; mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity(); mAnimationTime = listViewWrapper.getListView().getContext() .getResources() .getInteger(android.R.integer.config_shortAnimTime); mListViewWrapper = listViewWrapper; } /** * Sets the * {@link com.nhaarman.listviewanimations.itemmanipulation.swipedismiss.DismissableManager} * to specify which views can or cannot be swiped. * * @param dismissableManager * {@code null} for no restrictions. */ public void setDismissableManager( @Nullable final DismissableManager dismissableManager) { mDismissableManager = dismissableManager; } /** * Set the minimum value of the alpha property swiping Views should have. * * @param minimumAlpha * the alpha value between 0.0f and 1.0f. */ public void setMinimumAlpha(final float minimumAlpha) { mMinimumAlpha = minimumAlpha; } /** * If the {@link android.widget.AbsListView} is hosted inside a * parent(/grand-parent/etc) that can scroll horizontally, horizontal swipes * won't work, because the parent will prevent touch-events from reaching * the {@code AbsListView}. * <p/> * Call this method to fix this behavior. Note that this will prevent the * parent from scrolling horizontally when the user touches anywhere in a * list item. */ public void setParentIsHorizontalScrollContainer() { mParentIsHorizontalScrollContainer = true; mTouchChildResId = 0; } /** * Sets the resource id of a child view that should be touched to engage * swipe. When the user touches a region outside of that view, no swiping * will occur. * * @param childResId * The resource id of the list items' child that the user should * touch to be able to swipe the list items. */ public void setTouchChild(final int childResId) { mTouchChildResId = childResId; mParentIsHorizontalScrollContainer = false; } /** * Notifies this {@code SwipeTouchListener} that the adapter contents have * changed. */ public void notifyDataSetChanged() { if (mListViewWrapper.getAdapter() != null) { mVirtualListCount = mListViewWrapper.getCount() - mListViewWrapper.getHeaderViewsCount(); } } /** * Returns whether the user is currently swiping an item. * * @return {@code true} if the user is swiping an item. */ public boolean isSwiping() { return mSwiping; } @NonNull public ListViewWrapper getListViewWrapper() { return mListViewWrapper; } /** * Enables the swipe behavior. */ public void enableSwipe() { mSwipeEnabled = true; } /** * Disables the swipe behavior. */ public void disableSwipe() { mSwipeEnabled = false; } /** * Flings the {@link android.view.View} corresponding to given position out * of sight. Calling this method has the same effect as manually swiping an * item off the screen. * * @param position * the position of the item in the * {@link android.widget.ListAdapter}. Must be visible. */ public void fling(final int position) { int firstVisiblePosition = mListViewWrapper.getFirstVisiblePosition(); int lastVisiblePosition = mListViewWrapper.getLastVisiblePosition(); if (position < firstVisiblePosition || position > lastVisiblePosition) { throw new IllegalArgumentException("View for position " + position + " not visible!"); } View downView = AdapterViewUtil.getViewForPosition(mListViewWrapper, position); if (downView == null) { throw new IllegalStateException("No view found for position " + position); } flingView(downView, position, true); mActiveSwipeCount++; mVirtualListCount--; } @Override public boolean isInteracting() { return mSwiping; } @Override public boolean onTouchEvent(@NonNull final MotionEvent event) { return onTouch(null, event); } @Override public boolean onTouch(@Nullable final View view, @NonNull final MotionEvent event) { if (mListViewWrapper.getAdapter() == null) { return false; } if (mVirtualListCount == -1 || mActiveSwipeCount == 0) { mVirtualListCount = mListViewWrapper.getCount() - mListViewWrapper.getHeaderViewsCount(); } if (mViewWidth < 2) { mViewWidth = mListViewWrapper.getListView().getWidth(); } boolean result; switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: result = handleDownEvent(view, event); break; case MotionEvent.ACTION_MOVE: result = handleMoveEvent(view, event); break; case MotionEvent.ACTION_CANCEL: result = handleCancelEvent(); break; case MotionEvent.ACTION_UP: result = handleUpEvent(event); break; default: result = false; } return result; } private boolean handleDownEvent(@Nullable final View view, @NonNull final MotionEvent motionEvent) { if (!mSwipeEnabled) { return false; } View downView = findDownView(motionEvent); if (downView == null) { return false; } int downPosition = AdapterViewUtil.getPositionForView(mListViewWrapper, downView); mCanDismissCurrent = isDismissable(downPosition); /* Check if we are processing the item at this position */ if (mCurrentPosition == downPosition || downPosition >= mVirtualListCount) { return false; } if (view != null) { view.onTouchEvent(motionEvent); } disableHorizontalScrollContainerIfNecessary(motionEvent, downView); mDownX = motionEvent.getX(); mDownY = motionEvent.getY(); mCurrentView = downView; mSwipingView = getSwipeView(downView); mCurrentPosition = downPosition; mVelocityTracker = VelocityTracker.obtain(); mVelocityTracker.addMovement(motionEvent); return true; } /** * Returns the child {@link android.view.View} that was touched, by * performing a hit test. * * @param motionEvent * the {@link android.view.MotionEvent} to find the {@code View} * for. * * @return the touched {@code View}, or {@code null} if none found. */ @Nullable private View findDownView(@NonNull final MotionEvent motionEvent) { Rect rect = new Rect(); int childCount = mListViewWrapper.getChildCount(); int x = (int) motionEvent.getX(); int y = (int) motionEvent.getY(); View downView = null; for (int i = 0; i < childCount && downView == null; i++) { View child = mListViewWrapper.getChildAt(i); if (child != null) { child.getHitRect(rect); if (rect.contains(x, y)) { downView = child; } } } return downView; } /** * Finds out whether the item represented by given position is dismissable. * * @param position * the position of the item. * * @return {@code true} if the item is dismissable, false otherwise. */ private boolean isDismissable(final int position) { if (mListViewWrapper.getAdapter() == null) { return false; } if (mDismissableManager != null) { long downId = mListViewWrapper.getAdapter().getItemId(position); return mDismissableManager.isDismissable(downId, position); } return true; } private void disableHorizontalScrollContainerIfNecessary( @NonNull final MotionEvent motionEvent, @NonNull final View view) { if (mParentIsHorizontalScrollContainer) { mListViewWrapper.getListView().requestDisallowInterceptTouchEvent( true); } else if (mTouchChildResId != 0) { mParentIsHorizontalScrollContainer = false; final View childView = view.findViewById(mTouchChildResId); if (childView != null) { final Rect childRect = getChildViewRect( mListViewWrapper.getListView(), childView); if (childRect.contains((int) motionEvent.getX(), (int) motionEvent.getY())) { mListViewWrapper.getListView() .requestDisallowInterceptTouchEvent(true); } } } } private boolean handleMoveEvent(@Nullable final View view, @NonNull final MotionEvent motionEvent) { if (mVelocityTracker == null || mCurrentView == null) { return false; } mVelocityTracker.addMovement(motionEvent); float deltaX = motionEvent.getX() - mDownX; float deltaY = motionEvent.getY() - mDownY; if (Math.abs(deltaX) > mSlop && Math.abs(deltaX) > Math.abs(deltaY)) { if (!mSwiping) { mActiveSwipeCount++; onStartSwipe(mCurrentView, mCurrentPosition); } mSwiping = true; mListViewWrapper.getListView().requestDisallowInterceptTouchEvent( true); /* Cancel ListView's touch (un-highlighting the item) */ if (view != null) { MotionEvent cancelEvent = MotionEvent.obtain(motionEvent); cancelEvent .setAction(MotionEvent.ACTION_CANCEL | motionEvent.getActionIndex() << MotionEvent.ACTION_POINTER_INDEX_SHIFT); view.onTouchEvent(cancelEvent); cancelEvent.recycle(); } } if (mSwiping) { if (mCanDismissCurrent) { ViewHelper.setTranslationX(mSwipingView, deltaX); ViewHelper.setAlpha( mSwipingView, Math.max( mMinimumAlpha, Math.min(1, 1 - 2 * Math.abs(deltaX) / mViewWidth))); } else { ViewHelper.setTranslationX(mSwipingView, deltaX * 0.1f); } return true; } return false; } private boolean handleCancelEvent() { if (mVelocityTracker == null || mCurrentView == null) { return false; } if (mCurrentPosition != AdapterView.INVALID_POSITION && mSwiping) { onCancelSwipe(mCurrentView, mCurrentPosition); restoreCurrentViewTranslation(); } reset(); return false; } private boolean handleUpEvent(@NonNull final MotionEvent motionEvent) { if (mVelocityTracker == null || mCurrentView == null) { return false; } if (mSwiping) { boolean shouldDismiss = false; boolean dismissToRight = false; if (mCanDismissCurrent) { float deltaX = motionEvent.getX() - mDownX; mVelocityTracker.addMovement(motionEvent); mVelocityTracker.computeCurrentVelocity(1000); float velocityX = Math.abs(mVelocityTracker.getXVelocity()); float velocityY = Math.abs(mVelocityTracker.getYVelocity()); if (Math.abs(deltaX) > mViewWidth / 2) { shouldDismiss = true; dismissToRight = deltaX > 0; } else if (mMinFlingVelocity <= velocityX && velocityX <= mMaxFlingVelocity && velocityY < velocityX) { shouldDismiss = true; dismissToRight = mVelocityTracker.getXVelocity() > 0; } } if (shouldDismiss) { beforeViewFling(mCurrentView, mCurrentPosition); if (willLeaveDataSetOnFling(mCurrentView, mCurrentPosition)) { mVirtualListCount--; } flingCurrentView(dismissToRight); } else { onCancelSwipe(mCurrentView, mCurrentPosition); restoreCurrentViewTranslation(); } } reset(); return false; } /** * Flings the pending {@link android.view.View} out of sight. * * @param flingToRight * {@code true} if the {@code View} should be flinged to the * right, {@code false} if it should be flinged to the left. */ private void flingCurrentView(final boolean flingToRight) { if (mCurrentView != null) { flingView(mCurrentView, mCurrentPosition, flingToRight); } } /** * Flings given {@link android.view.View} out of sight. * * @param view * the parent {@link android.view.View}. * @param position * the position of the item in the * {@link android.widget.ListAdapter} corresponding to the * {@code View}. * @param flingToRight * {@code true} if the {@code View} should be flinged to the * right, {@code false} if it should be flinged to the left. */ private void flingView(@NonNull final View view, final int position, final boolean flingToRight) { if (mViewWidth < 2) { mViewWidth = mListViewWrapper.getListView().getWidth(); } View swipeView = getSwipeView(view); ObjectAnimator xAnimator = ObjectAnimator.ofFloat(swipeView, TRANSLATION_X, flingToRight ? mViewWidth : -mViewWidth); ObjectAnimator alphaAnimator = ObjectAnimator.ofFloat(swipeView, ALPHA, 0); AnimatorSet animatorSet = new AnimatorSet(); animatorSet.playTogether(xAnimator, alphaAnimator); animatorSet.setDuration(mAnimationTime); animatorSet.addListener(new FlingAnimatorListener(view, position)); animatorSet.start(); } /** * Animates the pending {@link android.view.View} back to its original * position. */ private void restoreCurrentViewTranslation() { if (mCurrentView == null) { return; } ObjectAnimator xAnimator = ObjectAnimator.ofFloat(mSwipingView, TRANSLATION_X, 0); ObjectAnimator alphaAnimator = ObjectAnimator.ofFloat(mSwipingView, ALPHA, 1); AnimatorSet animatorSet = new AnimatorSet(); animatorSet.playTogether(xAnimator, alphaAnimator); animatorSet.setDuration(mAnimationTime); animatorSet.addListener(new RestoreAnimatorListener(mCurrentView, mCurrentPosition)); animatorSet.start(); } /** * Resets the fields to the initial values, ready to start over. */ private void reset() { if (mVelocityTracker != null) { mVelocityTracker.recycle(); } mVelocityTracker = null; mDownX = 0; mDownY = 0; mCurrentView = null; mSwipingView = null; mCurrentPosition = AdapterView.INVALID_POSITION; mSwiping = false; mCanDismissCurrent = false; } /** * Called when the user starts swiping a {@link android.view.View}. * * @param view * the {@code View} that is being swiped. * @param position * the position of the item in the * {@link android.widget.ListAdapter} corresponding to the * {@code View}. */ protected void onStartSwipe(@NonNull final View view, final int position) { } /** * Called when the swipe movement is canceled. A restore animation starts at * this point. * * @param view * the {@code View} that was swiped. * @param position * the position of the item in the * {@link android.widget.ListAdapter} corresponding to the * {@code View}. */ protected void onCancelSwipe(@NonNull final View view, final int position) { } /** * Called after the restore animation of a canceled swipe movement ends. * * @param view * the {@code View} that is being swiped. * @param position * the position of the item in the * {@link android.widget.ListAdapter} corresponding to the * {@code View}. */ protected void afterCancelSwipe(@NonNull final View view, final int position) { } /** * Called when the user lifted their finger off the screen, and the * {@link android.view.View} should be swiped away. A fling animation starts * at this point. * * @param view * the {@code View} that is being flinged. * @param position * the position of the item in the * {@link android.widget.ListAdapter} corresponding to the * {@code View}. */ protected void beforeViewFling(@NonNull final View view, final int position) { } /** * Returns whether flinging the item at given position in the current state * would cause it to be removed from the data set. * * @param view * the {@code View} that would be flinged. * @param position * the position of the item in the * {@link android.widget.ListAdapter} corresponding to the * {@code View}. * * @return {@code true} if the item would leave the data set, {@code false} * otherwise. */ protected abstract boolean willLeaveDataSetOnFling(@NonNull View view, int position); /** * Called after the fling animation of a succesful swipe ends. Users of this * class should implement any finalizing behavior at this point, such as * notifying the adapter. * * @param view * the {@code View} that is being swiped. * @param position * the position of the item in the * {@link android.widget.ListAdapter} corresponding to the * {@code View}. */ protected abstract void afterViewFling(@NonNull View view, int position); /** * Restores the {@link android.view.View}'s {@code alpha} and * {@code translationX} values. Users of this class should call this method * when recycling {@code View}s. * * @param view * the {@code View} whose presentation should be restored. */ protected void restoreViewPresentation(@NonNull final View view) { View swipedView = getSwipeView(view); ViewHelper.setAlpha(swipedView, 1); ViewHelper.setTranslationX(swipedView, 0); } /** * Returns the number of active swipe animations. */ protected int getActiveSwipeCount() { return mActiveSwipeCount; } /** * Returns the {@link android.view.View} that should be swiped away. Must be * a child of given {@code View}, or the {@code View} itself. * * @param view * the parent {@link android.view.View}. */ @NonNull protected View getSwipeView(@NonNull final View view) { return view; } private static Rect getChildViewRect(final View parentView, final View childView) { Rect childRect = new Rect(childView.getLeft(), childView.getTop(), childView.getRight(), childView.getBottom()); if (!parentView.equals(childView)) { View workingChildView = childView; ViewGroup parent; while (!(parent = (ViewGroup) workingChildView.getParent()) .equals(parentView)) { childRect.offset(parent.getLeft(), parent.getTop()); workingChildView = parent; } } return childRect; } /** * An {@link com.nineoldandroids.animation.Animator.AnimatorListener} that * notifies when the fling animation has ended. */ private class FlingAnimatorListener extends AnimatorListenerAdapter { @NonNull private final View mView; private final int mPosition; private FlingAnimatorListener(@NonNull final View view, final int position) { mView = view; mPosition = position; } @Override public void onAnimationEnd(@NonNull final Animator animation) { mActiveSwipeCount--; afterViewFling(mView, mPosition); } } /** * An {@link com.nineoldandroids.animation.Animator.AnimatorListener} that * performs the dismissal animation when the current animation has ended. */ private class RestoreAnimatorListener extends AnimatorListenerAdapter { @NonNull private final View mView; private final int mPosition; private RestoreAnimatorListener(@NonNull final View view, final int position) { mView = view; mPosition = position; } @Override public void onAnimationEnd(@NonNull final Animator animation) { mActiveSwipeCount--; afterCancelSwipe(mView, mPosition); } } }