package com.chrome.codereview.utils; import android.content.Context; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewPropertyAnimator; import android.view.ViewTreeObserver; import android.widget.ListAdapter; import android.widget.ListView; import java.util.HashMap; /** * Created by sergeyv on 9/8/14. */ public class SwipeListView extends ListView { public interface BackgroundToggle { void showBackground(int top, int bottom, int swipeDirection); void changeDirection(int swipeDirection); void hideBackground(); } public interface OnSwipeListener { void onSwipe(Object item, int direction); } public static final int DIRECTION_LEFT = 1; public static final int DIRECTION_RIGHT = 2; private static final int SWIPE_DURATION = 250; private static final int MOVE_DURATION = 150; private static final int UNKNOWN = -1; private static final int SWIPE = -2; private static final int NOT_SWIPE = -3; private float downX; private int swipeSlop = -1; private int state = UNKNOWN; private int swipeDirection = 0; private BackgroundToggle backgroundToggle; private HashMap<Long, Integer> itemIdTopMap = new HashMap<Long, Integer>(); private View swipedView = null; private float downY; private OnSwipeListener swipeListener; public SwipeListView(Context context) { super(context); init(); } public SwipeListView(Context context, AttributeSet attrs) { super(context, attrs); init(); } public SwipeListView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(); } public void setSwipeListener(OnSwipeListener swipeListener) { this.swipeListener = swipeListener; } public void setBackgroundToggle(BackgroundToggle backgroundToggle) { this.backgroundToggle = backgroundToggle; } @Override public void setAdapter(ListAdapter adapter) { if (adapter instanceof SwipeListAdapter) { super.setAdapter(adapter); return; } throw new IllegalArgumentException("Adapter must be implementation of SwipeListAdapter"); } @Override public SwipeListAdapter getAdapter() { return (SwipeListAdapter) super.getAdapter(); } @Override public boolean onTouchEvent(MotionEvent event) { int action = event.getAction(); if (action == MotionEvent.ACTION_DOWN) { int position = pointToPosition((int) event.getX(), (int) event.getY()); swipedView = getChildAt(position - getFirstVisiblePosition()); state = position == INVALID_POSITION || !getAdapter().isItemSwipable(position) ? NOT_SWIPE : UNKNOWN; downX = event.getX(); downY = event.getY(); return super.onTouchEvent(event); } if (state == NOT_SWIPE) { return super.onTouchEvent(event); } if (state == UNKNOWN && (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL)) { swipedView = null; return super.onTouchEvent(event); } float deltaX = event.getX() - downX; int direction = deltaX >= 0 ? DIRECTION_RIGHT : DIRECTION_LEFT; float deltaXAbs = Math.abs(deltaX); float deltaYAbs = Math.abs(event.getY() - downY); if (state == UNKNOWN && action == MotionEvent.ACTION_MOVE) { if (deltaYAbs > swipeSlop) { state = NOT_SWIPE; swipedView = null; return super.onTouchEvent(event); } if (deltaXAbs > 2 * swipeSlop) { state = SWIPE; requestDisallowInterceptTouchEvent(true); swipeDirection = direction; backgroundToggle.showBackground(swipedView.getTop(), swipedView.getHeight(), direction); return true; } } if (state == UNKNOWN) { return super.onTouchEvent(event); } if (action == MotionEvent.ACTION_CANCEL) { setPressedViewState(0, 1); swipedView = null; return super.onTouchEvent(event); } int width = swipedView.getWidth(); if (action == MotionEvent.ACTION_MOVE) { setPressedViewState(deltaX, 1 - deltaXAbs / width); if (direction != swipeDirection) { swipeDirection = direction; backgroundToggle.changeDirection(direction); } return true; } if (action == MotionEvent.ACTION_UP) { float fractionCovered; float endX; float endAlpha; final boolean remove; if (deltaXAbs > width / 4) { // Greater than a quarter of the width - animate it out fractionCovered = deltaXAbs / width; endX = width * Math.signum(deltaX); endAlpha = 0; remove = true; } else { // Not far enough - animate it back fractionCovered = 1 - (deltaXAbs / width); endX = 0; endAlpha = 1; remove = false; } // Animate position and alpha of swiped item // NOTE: This is a simplified version of swipe behavior, for the // purposes of this demo about animation. A real version should use // velocity (via the VelocityTracker class) to send the item off or // back at an appropriate speed. long duration = (int) ((1 - fractionCovered) * SWIPE_DURATION); //Since I stole events from usual listView, it stucks in a bad state: //after all out actions listview has mTouchMode != TOUCH_MODE_REST //but this hack return list to normal state. //Maybe in a future we should implement our logic in the onInterceptTouch instead of //onTouch...But onInterceptTouch has very complicated contract and everything why //we need it - sending canceled events. MotionEvent fakeEvent = MotionEvent.obtain(event); fakeEvent.setAction(MotionEvent.ACTION_CANCEL); super.onTouchEvent(fakeEvent); setEnabled(false); Runnable onEnd = new Runnable() { @Override public void run() { // Restore animated values setPressedViewState(0, 1); if (remove) { animateRemoval(swipedView, swipeDirection); } else { backgroundToggle.hideBackground(); setEnabled(true); } swipedView = null; } }; if (duration > 0) { ViewPropertyAnimator animator = swipedView.animate().setDuration(duration). alpha(endAlpha).translationX(endX); ViewUtils.onAnimationEnd(animator, onEnd); } else { onEnd.run(); } return true; } return super.onTouchEvent(event); } /** * This method animates all other views in the ListView container (not including ignoreView) * into their final positions. It is called after ignoreView has been removed from the * adapter, but before layout has been run. The approach here is to figure out where * everything is now, then allow layout to run, then figure out where everything is after * layout, and then to run animations between all of those start/end positions. */ private void animateRemoval(View viewToRemove, final int swipeDirection) { final SwipeListAdapter adapter = getAdapter(); int firstVisiblePosition = getFirstVisiblePosition(); for (int i = 0; i < getChildCount(); ++i) { View child = getChildAt(i); if (child != viewToRemove) { int position = firstVisiblePosition + i; long itemId = adapter.getItemId(position); itemIdTopMap.put(itemId, child.getTop()); } } // Delete the item from the adapter int position = getPositionForView(viewToRemove); final Object removedItem = adapter.getItem(position); adapter.remove(position); final ViewTreeObserver observer = getViewTreeObserver(); observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { public boolean onPreDraw() { observer.removeOnPreDrawListener(this); boolean firstAnimation = true; int firstVisiblePosition = getFirstVisiblePosition(); Runnable lastAction = new Runnable() { public void run() { backgroundToggle.hideBackground(); setEnabled(true); if (swipeListener == null) { return; } swipeListener.onSwipe(removedItem, swipeDirection); } }; for (int i = 0; i < getChildCount(); ++i) { final View child = getChildAt(i); int position = firstVisiblePosition + i; long itemId = adapter.getItemId(position); Integer startTop = itemIdTopMap.get(itemId); int top = child.getTop(); if (startTop == null) { // Animate new views along with the others. The catch is that they did not // exist in the start state, so we must calculate their starting position // based on neighboring views. int childHeight = child.getHeight() + getDividerHeight(); startTop = top + (i > 0 ? childHeight : -childHeight); } if (startTop == top) { continue; } int delta = startTop - top; child.setTranslationY(delta); child.animate().setDuration(MOVE_DURATION).translationY(0); if (firstAnimation) { ViewUtils.onAnimationEnd(child.animate(), lastAction); firstAnimation = false; } } if (firstAnimation) { lastAction.run(); } itemIdTopMap.clear(); return true; } }); } private void setPressedViewState(float x, float alpha) { swipedView.setAlpha(alpha); swipedView.setTranslationX(x); } private void init() { swipeSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); } }