Created
May 13, 2014 15:49
-
-
Save VladSumtsov/390e0bccf53e5fdbfc81 to your computer and use it in GitHub Desktop.
Roman Nurik's SwipeDismissListViewTouchListener with like in gmail functionality
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// THIS IS A BETA! I DON'T RECOMMEND USING IT IN PRODUCTION CODE JUST YET | |
/* | |
* Copyright 2012 Roman Nurik | |
* | |
* 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.iamon.utils; | |
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.AbsListView; | |
import android.widget.ListView; | |
import com.nineoldandroids.animation.Animator; | |
import com.nineoldandroids.animation.AnimatorListenerAdapter; | |
import com.nineoldandroids.animation.ObjectAnimator; | |
import com.nineoldandroids.animation.ValueAnimator; | |
import com.nineoldandroids.view.ViewHelper; | |
import java.util.ArrayList; | |
import java.util.Collections; | |
import java.util.List; | |
import static com.nineoldandroids.view.ViewHelper.setAlpha; | |
import static com.nineoldandroids.view.ViewHelper.setTranslationX; | |
import static com.nineoldandroids.view.ViewPropertyAnimator.animate; | |
/** | |
* A {@link android.view.View.OnTouchListener} that makes the list items in a {@link ListView} | |
* dismissable. {@link 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. | |
* <p/> | |
* <p>After creating the listener, the caller should also call | |
* {@link ListView#setOnScrollListener(android.widget.AbsListView.OnScrollListener)}, passing | |
* in the scroll listener returned by {@link #makeScrollListener()}. If a scroll listener is | |
* already assigned, the caller should still pass scroll changes through to this listener. This will | |
* ensure that this {@link SwipeDismissListViewTouchListener} is paused during list view | |
* scrolling.</p> | |
* <p/> | |
* <p>Example usage:</p> | |
* <p/> | |
* <pre> | |
* SwipeDismissListViewTouchListener touchListener = | |
* new SwipeDismissListViewTouchListener( | |
* listView, | |
* new SwipeDismissListViewTouchListener.OnDismissCallback() { | |
* public void onDismiss(ListView listView, int[] reverseSortedPositions) { | |
* for (int position : reverseSortedPositions) { | |
* adapter.remove(adapter.getItem(position)); | |
* } | |
* adapter.notifyDataSetChanged(); | |
* } | |
* }); | |
* listView.setOnTouchListener(touchListener); | |
* listView.setOnScrollListener(touchListener.makeScrollListener()); | |
* listView.onPause() in activity on pause to reset pending undos | |
*/ | |
public class SwipeDismissListViewTouchListener implements View.OnTouchListener { | |
private static final long SHOW_UNDO_DURATION = 100; | |
private final int contentLayoutId; | |
// Cached ViewConfiguration and system-wide constant values | |
private int slop; | |
private int minFlingVelocity; | |
private int maxFlingVelocity; | |
private long animationTime; | |
// Fixed properties | |
private ListView listView; | |
private OnDismissCallback onDismissCallback; | |
private int viewWidth = 1; // 1 and not 0 to prevent dividing by zero | |
// Transient properties | |
private List<PendingDismissData> pendingDismisses = new ArrayList<PendingDismissData>(); | |
private List<PendingDismissData> pendingUndoDismisses = new ArrayList<PendingDismissData>(); | |
private int dismissAnimationRefCount = 0; | |
private float downX; | |
private boolean swiping; | |
private VelocityTracker velocityTracker; | |
private int downPosition; | |
private View downView; | |
private boolean paused; | |
private int undoLayoutId = -1; | |
private int undoButtonId = -1; | |
private View undoView; | |
private View contentView; | |
/** | |
* The callback interface used by {@link SwipeDismissListViewTouchListener} to inform its client | |
* about a successful dismissal of one or more list item positions. | |
*/ | |
public interface OnDismissCallback { | |
/** | |
* Called when the user has indicated they she would like to dismiss one or more list item | |
* positions. | |
* | |
* @param listView The originating {@link android.widget.ListView}. | |
* @param reverseSortedPositions An array of positions to dismiss, sorted in descending | |
*/ | |
void onDismiss(ListView listView, List<Integer> reverseSortedPositions); | |
} | |
/** | |
* Constructs a new swipe-to-dismiss touch listener for the given list view. | |
* | |
* @param listView The list view whose items should be dismissable. | |
* @param onDismissCallback The callback to trigger when the user has indicated that she would like to | |
*/ | |
public SwipeDismissListViewTouchListener(ListView listView, OnDismissCallback onDismissCallback) { | |
this(listView, onDismissCallback, -1, -1, -1); | |
} | |
public void onPause() { | |
dismissPendingUndos(); | |
} | |
/** | |
* Constructs a new swipe-to-dismiss touch listener for the given list view. | |
* | |
* @param listView The list view whose items should be dismissable. | |
* @param onDismissCallback The callback to trigger when the user has indicated that she would like to | |
* @param undoLayoutId the id of undo layout already created in list view row. Undo layout must be setVisibility(View.GONE); | |
* @param contentLayoutId the id of content layout already created in list view row | |
* @param undoButtonId The id of the undo button already created in list view row | |
*/ | |
public SwipeDismissListViewTouchListener(ListView listView, OnDismissCallback onDismissCallback, int undoLayoutId, int contentLayoutId, int undoButtonId) { | |
ViewConfiguration vc = ViewConfiguration.get(listView.getContext()); | |
slop = vc.getScaledTouchSlop(); | |
minFlingVelocity = vc.getScaledMinimumFlingVelocity(); | |
maxFlingVelocity = vc.getScaledMaximumFlingVelocity(); | |
animationTime = listView.getContext().getResources().getInteger( | |
android.R.integer.config_shortAnimTime); | |
this.listView = listView; | |
this.onDismissCallback = onDismissCallback; | |
this.undoLayoutId = undoLayoutId; | |
this.undoButtonId = undoButtonId; | |
this.contentLayoutId = contentLayoutId; | |
} | |
/** | |
* Enables or disables (pauses or resumes) watching for swipe-to-dismiss gestures. | |
* | |
* @param enabled Whether or not to watch for gestures. | |
*/ | |
public void setEnabled(boolean enabled) { | |
paused = !enabled; | |
} | |
/** | |
* Returns an {@link android.widget.AbsListView.OnScrollListener} to be added to the | |
* {@link ListView} using | |
* {@link ListView#setOnScrollListener(android.widget.AbsListView.OnScrollListener)}. | |
* If a scroll listener is already assigned, the caller should still pass scroll changes | |
* through to this listener. This will ensure that this | |
* {@link SwipeDismissListViewTouchListener} is paused during list view scrolling.</p> | |
* | |
* @see {@link SwipeDismissListViewTouchListener} | |
*/ | |
public AbsListView.OnScrollListener makeScrollListener() { | |
return new AbsListView.OnScrollListener() { | |
@Override | |
public void onScrollStateChanged(AbsListView absListView, int scrollState) { | |
setEnabled(scrollState != AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL); | |
dismissPendingUndos(); | |
} | |
@Override | |
public void onScroll(AbsListView absListView, int i, int i1, int i2) { | |
} | |
}; | |
} | |
private void dismissPendingUndos() { | |
if (!pendingUndoDismisses.isEmpty()) { | |
for (PendingDismissData pd : pendingUndoDismisses) { | |
performDismiss(pd.view, pd.position, null); | |
} | |
pendingUndoDismisses.clear(); | |
} | |
} | |
@Override | |
public boolean onTouch(View view, MotionEvent motionEvent) { | |
if (viewWidth < 2) { | |
viewWidth = listView.getWidth(); | |
} | |
switch (motionEvent.getActionMasked()) { | |
case MotionEvent.ACTION_DOWN: { | |
if (paused) { | |
return false; | |
} | |
// TODO: ensure this is a finger, and set a flag | |
// Find the child view that was touched (perform a hit test) | |
Rect rect = new Rect(); | |
int childCount = listView.getChildCount(); | |
int[] listViewCoords = new int[2]; | |
listView.getLocationOnScreen(listViewCoords); | |
int x = (int) motionEvent.getRawX() - listViewCoords[0]; | |
int y = (int) motionEvent.getRawY() - listViewCoords[1]; | |
View child; | |
for (int i = 0; i < childCount; i++) { | |
child = listView.getChildAt(i); | |
child.getHitRect(rect); | |
if (rect.contains(x, y)) { | |
downView = child; | |
break; | |
} | |
} | |
if (downView != null) { | |
downX = motionEvent.getRawX(); | |
downPosition = listView.getPositionForView(downView); | |
velocityTracker = VelocityTracker.obtain(); | |
velocityTracker.addMovement(motionEvent); | |
if (undoLayoutId > 0) { | |
undoView = downView.findViewById(undoLayoutId); | |
undoView.setClickable(true); | |
contentView = downView.findViewById(contentLayoutId); | |
} else { | |
contentView = downView; | |
} | |
} | |
view.onTouchEvent(motionEvent); | |
return true; | |
} | |
case MotionEvent.ACTION_UP: { | |
if (velocityTracker == null) { | |
break; | |
} | |
float deltaX = motionEvent.getRawX() - downX; | |
velocityTracker.addMovement(motionEvent); | |
velocityTracker.computeCurrentVelocity(1000); | |
float velocityX = Math.abs(velocityTracker.getXVelocity()); | |
float velocityY = Math.abs(velocityTracker.getYVelocity()); | |
boolean dismiss = false; | |
boolean dismissRight = false; | |
if (Math.abs(deltaX) > viewWidth / 2) { | |
dismiss = true; | |
dismissRight = deltaX > 0; | |
} else if (minFlingVelocity <= velocityX && velocityX <= maxFlingVelocity | |
&& velocityY < velocityX) { | |
dismiss = true; | |
dismissRight = velocityTracker.getXVelocity() > 0; | |
} | |
if (dismiss) { | |
// dismiss | |
final View contentView = this.contentView; // downView gets null'd before animation ends | |
final View downView = this.downView; | |
final View undoView = this.undoView; // downView gets null'd before animation ends | |
final int downPosition = this.downPosition; | |
++dismissAnimationRefCount; | |
animate(contentView) | |
.translationX(dismissRight ? viewWidth : -viewWidth) | |
.alpha(0) | |
.setDuration(animationTime) | |
.setListener(new AnimatorListenerAdapter() { | |
@Override | |
public void onAnimationEnd(Animator animation) { | |
if (undoLayoutId == -1) { | |
performDismiss(downView, downPosition, null); | |
} else { | |
final PendingDismissData pendingDismiss = new PendingDismissData(downPosition, downView); | |
initUndoButton(pendingDismiss, undoView, contentView); | |
pendingUndoDismisses.add(pendingDismiss); | |
dismissMorePendingUndos(pendingDismiss, downPosition); | |
} | |
} | |
}); | |
} else { | |
returnToDefault(contentView, 0); | |
} | |
velocityTracker = null; | |
downX = 0; | |
contentView = null; | |
downView = null; | |
downPosition = ListView.INVALID_POSITION; | |
swiping = false; | |
break; | |
} | |
case MotionEvent.ACTION_MOVE: { | |
if (velocityTracker == null || paused) { | |
break; | |
} | |
velocityTracker.addMovement(motionEvent); | |
float deltaX = motionEvent.getRawX() - downX; | |
if (Math.abs(deltaX) > slop) { | |
swiping = true; | |
listView.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)); | |
listView.onTouchEvent(cancelEvent); | |
} | |
if (swiping) { | |
setTranslationX(contentView, deltaX); | |
setAlpha(contentView, Math.max(0f, Math.min(1f, | |
1f - 2f * Math.abs(deltaX) / viewWidth))); | |
return true; | |
} | |
break; | |
} | |
} | |
return false; | |
} | |
private void dismissMorePendingUndos(final PendingDismissData pendingDismiss, final int downPosition) { | |
if (pendingUndoDismisses.size() > 1) { | |
final PendingDismissData pd = pendingUndoDismisses.get(0); | |
pendingUndoDismisses.remove(pd); | |
performDismiss(pd.view, pd.position, new OnDismissCallback() { | |
@Override | |
public void onDismiss(ListView listView, List<Integer> reverseSortedPositions) { | |
if (downPosition > pd.position) { | |
moveUndoUp(pendingDismiss); | |
} | |
} | |
}); | |
} | |
} | |
private void moveUndoUp(final PendingDismissData pd) { | |
int upPosition = pd.position - 1; | |
final View upView = listView.getChildAt(upPosition - listView.getFirstVisiblePosition()); | |
final View view = pd.view; | |
float contentX = ViewHelper.getTranslationX(view.findViewById(contentLayoutId)); | |
resetContentPresentation(pd); | |
pd.view = upView; | |
pd.position = upPosition; | |
showUndoPresentation(pd, contentX); | |
} | |
private void showUndoPresentation(PendingDismissData pendingDismiss, float contentX) { | |
View undoView = pendingDismiss.view.findViewById(undoLayoutId); | |
undoView.setVisibility(View.VISIBLE); | |
setAlpha(undoView, 1); | |
View contentView = pendingDismiss.view.findViewById(contentLayoutId); | |
setTranslationX(contentView, contentX); | |
} | |
private void initUndoButton(final PendingDismissData pendingDismiss, final View undoView, final View contentView) { | |
ObjectAnimator.ofFloat(undoView, "alpha", 0, 1).setDuration(SHOW_UNDO_DURATION).start(); | |
undoView.setVisibility(View.VISIBLE); | |
undoView.findViewById(undoButtonId).setOnClickListener(new View.OnClickListener() { | |
@Override | |
public void onClick(View v) { | |
returnToDefault(contentView, SHOW_UNDO_DURATION); | |
ObjectAnimator alpha = ObjectAnimator.ofFloat(undoView, "alpha", 1, 0); | |
alpha.setDuration(SHOW_UNDO_DURATION); | |
pendingUndoDismisses.remove(pendingDismiss); | |
alpha.addListener(new SimpleAnimatorListener() { | |
@Override | |
public void onAnimationEnd(Animator animation) { | |
undoView.setVisibility(View.GONE); | |
} | |
}); | |
alpha.start(); | |
} | |
}); | |
} | |
private void returnToDefault(View view, long startDelay) { | |
animate(view) | |
.translationX(0) | |
.alpha(1) | |
.setStartDelay(startDelay) | |
.setDuration(animationTime) | |
.setListener(null); | |
} | |
class PendingDismissData implements Comparable<PendingDismissData> { | |
public int position; | |
public View view; | |
public PendingDismissData(int position, View view) { | |
this.position = position; | |
this.view = view; | |
} | |
@Override | |
public int compareTo(PendingDismissData other) { | |
// Sort by descending position | |
return other.position - position; | |
} | |
@Override | |
public boolean equals(Object o) { | |
if (this == o) return true; | |
if (o == null || getClass() != o.getClass()) return false; | |
PendingDismissData that = (PendingDismissData) o; | |
if (view != null ? !view.equals(that.view) : that.view != null) return false; | |
return true; | |
} | |
} | |
private void performDismiss(final View dismissView, final int dismissPosition, final OnDismissCallback callback) { | |
// Animate the dismissed list item to zero-height and fire the dismiss callback when | |
// all dismissed list item animations have completed. This triggers layout on each animation | |
// frame; in the future we may want to do something smarter and more performant. | |
final ViewGroup.LayoutParams lp = dismissView.getLayoutParams(); | |
final int originalHeight = dismissView.getHeight(); | |
ValueAnimator animator = ValueAnimator.ofInt(originalHeight, 1).setDuration(animationTime); | |
animator.addListener(new AnimatorListenerAdapter() { | |
@Override | |
public void onAnimationEnd(Animator animation) { | |
--dismissAnimationRefCount; | |
if (dismissAnimationRefCount == 0 || contentLayoutId > 0) { | |
// No active animations, process all pending dismisses. | |
// Sort by descending position | |
Collections.sort(pendingDismisses); | |
List<Integer> dismissPositions = new ArrayList<Integer>(pendingDismisses.size()); | |
for (int i = pendingDismisses.size() - 1; i >= 0; i--) { | |
int pos = pendingDismisses.get(i).position; | |
if (pos < listView.getAdapter().getCount()) { | |
dismissPositions.add(pos); | |
} | |
} | |
onDismissCallback.onDismiss(listView, dismissPositions); | |
ViewGroup.LayoutParams lp; | |
for (PendingDismissData pendingDismiss : pendingDismisses) { | |
// Reset view presentation | |
resetContentPresentation(pendingDismiss); | |
lp = pendingDismiss.view.getLayoutParams(); | |
lp.height = originalHeight; | |
pendingDismiss.view.setLayoutParams(lp); | |
} | |
pendingDismisses.clear(); | |
if (callback != null) { | |
callback.onDismiss(listView, dismissPositions); | |
} | |
} | |
} | |
}); | |
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { | |
@Override | |
public void onAnimationUpdate(ValueAnimator valueAnimator) { | |
lp.height = (Integer) valueAnimator.getAnimatedValue(); | |
dismissView.setLayoutParams(lp); | |
} | |
}); | |
pendingDismisses.add(new PendingDismissData(dismissPosition, dismissView)); | |
animator.start(); | |
} | |
private void resetContentPresentation(PendingDismissData pendingDismiss) { | |
View contentView = pendingDismiss.view; | |
if (contentLayoutId > 0) { | |
contentView.findViewById(undoLayoutId).setVisibility(View.GONE); | |
contentView = contentView.findViewById(contentLayoutId); | |
} | |
setAlpha(contentView, 1f); | |
setTranslationX(contentView, 0); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment