// THIS IS A BETA! I DON'T RECOMMEND USING IT IN PRODUCTION CODE JUST YET /* * Copyright 2012 Roman Nurik * Copyright 2013 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. */ package com.marshalchen.common.uimodule.listviewanimations.itemmanipulation.swipedismiss; import android.annotation.SuppressLint; import android.graphics.Rect; import android.view.*; import android.widget.AbsListView; import com.marshalchen.common.uimodule.listviewanimations.itemmanipulation.OnDismissCallback; import com.marshalchen.common.uimodule.listviewanimations.util.AdapterViewUtil; import com.marshalchen.common.uimodule.nineoldandroids.animation.Animator; import com.marshalchen.common.uimodule.nineoldandroids.animation.AnimatorListenerAdapter; import com.marshalchen.common.uimodule.nineoldandroids.animation.ValueAnimator; import com.marshalchen.common.uimodule.nineoldandroids.view.ViewHelper; import com.marshalchen.common.uimodule.nineoldandroids.view.ViewPropertyAnimator; import java.util.ArrayList; import java.util.Collections; import java.util.List; import static com.marshalchen.common.uimodule.nineoldandroids.view.ViewPropertyAnimator.animate; /** * A {@link android.view.View.OnTouchListener} that makes the list items in a * {@link android.widget.ListView} dismissable. {@link android.widget.ListView} is given special treatment * because by default it handles touches for its list items... i.e. it's in * charge of drawing the pressed state (the list selector), handling list item * clicks, etc. * * For performance reasons, do not use this class directly, but use the {@link com.marshalchen.common.uimodule.listviewanimations.itemmanipulation.swipedismiss.SwipeDismissAdapter}. */ @SuppressLint("Recycle") public class SwipeDismissListViewTouchListener implements SwipeOnTouchListener { private static final int MIN_FLING_VELOCITY_FACTOR = 16; // Cached ViewConfiguration and system-wide constant values private final int mSlop; private final int mMinFlingVelocity; private final int mMaxFlingVelocity; protected long mAnimationTime; // Fixed properties private final AbsListView mListView; private final OnDismissCallback mCallback; private int mViewWidth = 1; // 1 and not 0 to prevent dividing by zero // Transient properties protected List<PendingDismissData> mPendingDismisses = new ArrayList<PendingDismissData>(); private int mDismissAnimationRefCount = 0; private float mDownX; private float mDownY; private boolean mSwiping; private VelocityTracker mVelocityTracker; private PendingDismissData mCurrentDismissData; private int mVirtualListCount = -1; private boolean mDisallowSwipe; private boolean mIsParentHorizontalScrollContainer; private int mResIdOfTouchChild; private boolean mTouchChildTouched; private DismissableManager mDismissableManager; /** * Constructs a new swipe-to-dismiss touch listener for the given list view. * * @param listView * The list view whose items should be dismissable. * @param callback * The callback to trigger when the user has indicated that she * would like to dismiss one or more list items. */ public SwipeDismissListViewTouchListener(final AbsListView listView, final OnDismissCallback callback, final SwipeOnScrollListener onScroll) { ViewConfiguration vc = ViewConfiguration.get(listView.getContext()); mSlop = vc.getScaledTouchSlop(); mMinFlingVelocity = vc.getScaledMinimumFlingVelocity() * MIN_FLING_VELOCITY_FACTOR; mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity(); mAnimationTime = listView.getContext().getResources().getInteger(android.R.integer.config_shortAnimTime); mListView = listView; mCallback = callback; onScroll.setTouchListener(this); mListView.setOnScrollListener(onScroll); } public void disallowSwipe() { mDisallowSwipe = true; } @SuppressWarnings("UnusedDeclaration") public void allowSwipe() { mDisallowSwipe = false; } /** * Set the {@link com.marshalchen.common.uimodule.listviewanimations.itemmanipulation.swipedismiss.DismissableManager} to specify which views can or cannot be swiped. * @param dismissableManager null for no restrictions. */ @SuppressWarnings("UnusedDeclaration") public void setDismissableManager(final DismissableManager dismissableManager) { mDismissableManager = dismissableManager; } @Override public boolean onTouch(final View view, final MotionEvent motionEvent) { if (mVirtualListCount == -1) { mVirtualListCount = mListView.getAdapter().getCount(); } if (mViewWidth < 2) { mViewWidth = mListView.getWidth(); } switch (motionEvent.getActionMasked()) { case MotionEvent.ACTION_DOWN: mDisallowSwipe = false; view.onTouchEvent(motionEvent); return handleDownEvent(motionEvent); case MotionEvent.ACTION_MOVE: return handleMoveEvent(motionEvent); case MotionEvent.ACTION_CANCEL: return handleCancelEvent(motionEvent); case MotionEvent.ACTION_UP: mDisallowSwipe = false; mTouchChildTouched = false; return handleUpEvent(motionEvent); } return false; } @Override public boolean isSwiping() { return mSwiping; } /** * Factory to allow override of dismiss data */ protected PendingDismissData createPendingDismissData(final int position, final View view) { return new PendingDismissData(position, view); } private boolean handleDownEvent(final MotionEvent motionEvent) { // Find the child view that was touched (perform a hit test) Rect rect = new Rect(); int childCount = mListView.getChildCount(); int[] listViewCoords = new int[2]; mListView.getLocationOnScreen(listViewCoords); int x = (int) motionEvent.getRawX() - listViewCoords[0]; int y = (int) motionEvent.getRawY() - listViewCoords[1]; View downView = null; for (int i = 0; i < childCount && downView == null; i++) { View child = mListView.getChildAt(i); child.getHitRect(rect); if (rect.contains(x, y)) { downView = child; } } if (downView != null) { mDownX = motionEvent.getRawX(); mDownY = motionEvent.getRawY(); int downPosition = AdapterViewUtil.getPositionForView(mListView, downView); if (mDismissableManager != null) { long downId = mListView.getAdapter().getItemId(downPosition); if (!mDismissableManager.isDismissable(downId, downPosition)) { /* Cancel, not dismissable */ return false; } } mCurrentDismissData = createPendingDismissData(downPosition, downView); if (mPendingDismisses.contains(mCurrentDismissData) || downPosition >= mVirtualListCount) { // Cancel, we're already processing this position mCurrentDismissData = null; return false; } else { mTouchChildTouched = !mIsParentHorizontalScrollContainer && mResIdOfTouchChild == 0; if (mResIdOfTouchChild != 0) { mIsParentHorizontalScrollContainer = false; final View childView = downView.findViewById(mResIdOfTouchChild); if (childView != null) { final Rect childRect = getChildViewRect(mListView, childView); if (childRect.contains((int) motionEvent.getX(), (int) motionEvent.getY())) { mTouchChildTouched = true; mListView.requestDisallowInterceptTouchEvent(true); } } } if (mIsParentHorizontalScrollContainer) { // Do it now and don't wait until the user moves more than // the slop factor. mTouchChildTouched = true; mListView.requestDisallowInterceptTouchEvent(true); } mVelocityTracker = VelocityTracker.obtain(); mVelocityTracker.addMovement(motionEvent); } } return true; } private Rect getChildViewRect(final View parentView, View childView) { final Rect childRect = new Rect(childView.getLeft(), childView.getTop(), childView.getRight(), childView.getBottom()); if (parentView == childView) { return childRect; } ViewGroup parent; while ((parent = (ViewGroup) childView.getParent()) != parentView) { childRect.offset(parent.getLeft(), parent.getTop()); childView = parent; } return childRect; } private boolean handleMoveEvent(final MotionEvent motionEvent) { if (mVelocityTracker == null) { return false; } mVelocityTracker.addMovement(motionEvent); float deltaX = motionEvent.getRawX() - mDownX; float deltaY = motionEvent.getRawY() - mDownY; if (mTouchChildTouched && !mDisallowSwipe && Math.abs(deltaX) > mSlop && Math.abs(deltaX) > Math.abs(deltaY)) { mSwiping = true; mListView.requestDisallowInterceptTouchEvent(true); // Cancel ListView's touch (un-highlighting the item) MotionEvent cancelEvent = MotionEvent.obtain(motionEvent); cancelEvent.setAction(MotionEvent.ACTION_CANCEL | motionEvent.getActionIndex() << MotionEvent.ACTION_POINTER_INDEX_SHIFT); mListView.onTouchEvent(cancelEvent); } if (mSwiping) { ViewHelper.setTranslationX(mCurrentDismissData.view, deltaX); //noinspection MagicNumber ViewHelper.setAlpha(mCurrentDismissData.view, Math.max(0f, Math.min(1f, 1f - 2f * Math.abs(deltaX) / mViewWidth))); return true; } return false; } @SuppressWarnings("UnusedParameters") private boolean handleCancelEvent(final MotionEvent motionEvent) { if (mVelocityTracker == null) { return false; } if (mCurrentDismissData != null && mSwiping) { ViewPropertyAnimator.animate(mCurrentDismissData.view) .translationX(0) .alpha(1) .setDuration(mAnimationTime) .setListener(null); } mVelocityTracker.recycle(); mVelocityTracker = null; mDownX = 0; mDownY = 0; mCurrentDismissData = null; mSwiping = false; return false; } private boolean handleUpEvent(final MotionEvent motionEvent) { if (mVelocityTracker == null) { return false; } float deltaX = motionEvent.getRawX() - mDownX; mVelocityTracker.addMovement(motionEvent); mVelocityTracker.computeCurrentVelocity(1000); float velocityX = Math.abs(mVelocityTracker.getXVelocity()); float velocityY = Math.abs(mVelocityTracker.getYVelocity()); boolean dismiss = false; boolean dismissRight = false; if (Math.abs(deltaX) > mViewWidth / 2) { dismiss = true; dismissRight = deltaX > 0; } else if (mMinFlingVelocity <= velocityX && velocityX <= mMaxFlingVelocity && velocityY < velocityX) { dismiss = true; dismissRight = mVelocityTracker.getXVelocity() > 0; } if (mSwiping) { if (dismiss) { // mDownView gets null'd before animation ends final PendingDismissData pendingDismissData = mCurrentDismissData; ++mDismissAnimationRefCount; animate(mCurrentDismissData.view).translationX(dismissRight ? mViewWidth : -mViewWidth).alpha(0).setDuration(mAnimationTime).setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(final Animator animation) { onDismiss(pendingDismissData); } }); mVirtualListCount--; mPendingDismisses.add(mCurrentDismissData); } else { // cancel animate(mCurrentDismissData.view).translationX(0).alpha(1).setDuration(mAnimationTime).setListener(null); } } mVelocityTracker.recycle(); mVelocityTracker = null; mDownX = 0; mDownY = 0; mCurrentDismissData = null; mSwiping = false; return false; } protected class PendingDismissData implements Comparable<PendingDismissData> { public final int position; public final View view; public PendingDismissData(final int position, final View view) { this.position = position; this.view = view; } @Override public int compareTo(final PendingDismissData other) { // Sort by descending position return other.position - position; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + position; return result; } @Override public boolean equals(final Object obj) { if (this == obj) return true; if (obj == null) return false; if (((Object) this).getClass() != obj.getClass()) return false; PendingDismissData other = (PendingDismissData) obj; return position == other.position; } } protected void onDismiss(final PendingDismissData data) { // default behaviour performDismiss(data); } protected void performDismiss(final PendingDismissData data) { // Animate the dismissed list item to zero-height and fire the // dismiss callback when all dismissed list item animations have // completed. final ViewGroup.LayoutParams lp = data.view.getLayoutParams(); final int originalHeight = data.view.getHeight(); ValueAnimator animator = ValueAnimator.ofInt(originalHeight, 1).setDuration(mAnimationTime); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(final ValueAnimator valueAnimator) { lp.height = (Integer) valueAnimator.getAnimatedValue(); data.view.setLayoutParams(lp); } }); animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(final Animator animation) { finalizeDismiss(); } }); animator.start(); } /** * Here you can manage dismissed View. */ protected void recycleDismissedViewsItems(final List<PendingDismissData> pendingDismisses) { ViewGroup.LayoutParams lp; for (final PendingDismissData pendingDismiss : pendingDismisses) { // Reset view presentation ViewHelper.setAlpha(pendingDismiss.view, 1f); ViewHelper.setTranslationX(pendingDismiss.view, 0); lp = pendingDismiss.view.getLayoutParams(); lp.height = 0; pendingDismiss.view.setLayoutParams(lp); } } protected void finalizeDismiss() { --mDismissAnimationRefCount; if (mDismissAnimationRefCount == 0) { // No active animations, process all pending dismisses. // Sort by descending position Collections.sort(mPendingDismisses); int[] dismissPositions = new int[mPendingDismisses.size()]; for (int i = mPendingDismisses.size() - 1; i >= 0; i--) { dismissPositions[i] = mPendingDismisses.get(i).position; } mCallback.onDismiss(mListView, dismissPositions); recycleDismissedViewsItems(mPendingDismisses); mPendingDismisses.clear(); } } void setIsParentHorizontalScrollContainer(final boolean isParentHorizontalScrollContainer) { mIsParentHorizontalScrollContainer = mResIdOfTouchChild == 0 && isParentHorizontalScrollContainer; } void setTouchChild(final int childResId) { mResIdOfTouchChild = childResId; if (childResId != 0) { setIsParentHorizontalScrollContainer(false); } } public void notifyDataSetChanged() { mVirtualListCount = mListView.getAdapter().getCount(); } }