标签(空格分隔): Android
新版的知乎安卓客户端有一个有趣的功能,就是在一个item里,向右滑动时整个item会越来越透明,滑动到一半时,整个item就不见了,放开手指就是删除,删除后还可以撤销,第一次看见这个功能觉得很有意思,用了几天业余时间,我仿造里一个,效果如下:
那下面就来想想看怎么实现的,大概可以先分解为三部分:
- 手指滑动删除item
- 删除item后的撤销功能
- 滑动时的效果处理
提醒一下如果你对scroller不熟悉,可以先看一下scroller实现原理
先来看最主要的类CustomSwipeListView源码:
import android.content.Context;import android.graphics.Color;import android.util.AttributeSet;import android.util.Log;import android.view.MotionEvent;import android.view.VelocityTracker;import android.view.View;import android.view.ViewConfiguration;import android.view.WindowManager;import android.widget.AdapterView;import android.widget.ListView;import android.widget.Scroller;import android.widget.TextView;/** * 2015-2-13 自定义ListView */public class CustomSwipeListView extends ListView { /** * 当前滑动的ListView position */ private int slidePosition; /** * 手指按下X的坐标 */ private int downY; /** * 手指按下Y的坐标 */ private int downX; /** * 屏幕宽度 */ private int screenWidth; /** * ListView的item */ private View itemView; /** * item里面的内容区域 */ private View contentView; /** * 滑动类 */ private Scroller scroller; /** * 滑动速度极限值 */ private final int SNAP_VELOCITY = CustomSwipeUtils.convertDptoPx(getContext(), 1000); /** * 速度追踪对象 */ private VelocityTracker velocityTracker; /** * 是否响应滑动,默认为不响应 */ private boolean isSlide = false; /** * 认为是用户滑动的最小距离 */ private int mTouchSlop; /** * 移除item后的回调接口 */ private RemoveListener mRemoveListener; /** * 用来指示item滑出屏幕的方向,向左或者向右,用一个枚举值来标记 */ private RemoveDirection removeDirection; private boolean isRemoveScroll = false; /** * 指定计算哪个点的速度 */ private int mPointerId; /** * 获得允许执行一个fling手势动作的最大速度值 */ private int mMaxVelocity; int velocityX = 0; // 滑动删除方向的枚举值 public enum RemoveDirection { RIGHT, LEFT; } public CustomSwipeListView(Context context) { this(context, null); } public CustomSwipeListView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public CustomSwipeListView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); screenWidth = ((WindowManager) context.getSystemService(Context.WINDOW_SERVICE)) .getDefaultDisplay().getWidth(); scroller = new Scroller(context); // 检测用户在move前划过的距离,移动距离大于这个距离才开始算滑动 mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); mMaxVelocity = ViewConfiguration.get(getContext()).getScaledMaximumFlingVelocity(); } /** * 设置滑动删除的回调接口 * * @param removeListener */ public void setRemoveListener(RemoveListener removeListener) { this.mRemoveListener = removeListener; } /** * 分发事件,主要做的是判断点击的是那个item, 以及通过postDelayed来设置响应左右滑动事件 */ @Override public boolean dispatchTouchEvent(MotionEvent event) { addVelocityTracker(event); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: mPointerId = event.getPointerId(0); // 假如scroller滚动还没有结束,我们直接返回 if (!scroller.isFinished()) { return super.dispatchTouchEvent(event); } downX = (int) event.getX(); downY = (int) event.getY(); slidePosition = pointToPosition(downX, downY); // 无效的position, 不做任何处理 if (slidePosition == AdapterView.INVALID_POSITION) { return super.dispatchTouchEvent(event); } // 获取我们点击的item view itemView = getChildAt(slidePosition - getFirstVisiblePosition()); contentView = itemView.findViewById(R.id.ll_cotentview); break; case MotionEvent.ACTION_MOVE: if (Math.abs(getScrollVelocity()) > SNAP_VELOCITY || (Math.abs(event.getX() - downX) > mTouchSlop && Math.abs(event.getY() - downY) < mTouchSlop)) { isSlide = true; } break; case MotionEvent.ACTION_UP: recycleVelocityTracker(); break; } return super.dispatchTouchEvent(event); } /** * 往右滑动,getScrollX()返回的是左边缘的距离,就是以View左边缘为原点到开始滑动的距离,所以向右边滑动为负值 */ private void scrollRight() { isRemoveScroll = true; removeDirection = RemoveDirection.RIGHT; final int delta = (screenWidth + itemView.getScrollX()); // 调用startScroll方法来设置一些滚动的参数,我们在computeScroll()方法中调用scrollTo来滚动item scroller.startScroll(itemView.getScrollX(), 0, -delta, 0, Math.abs(delta)); postInvalidate(); // 刷新itemView } /** * 向左滑动,根据上面我们知道向左滑动为正值 */ private void scrollLeft() { isRemoveScroll = true; removeDirection = RemoveDirection.LEFT; final int delta = (screenWidth - itemView.getScrollX()); // 调用startScroll方法来设置一些滚动的参数,我们在computeScroll()方法中调用scrollTo来滚动item scroller.startScroll(itemView.getScrollX(), 0, delta, 0, Math.abs(delta)); postInvalidate(); // 刷新itemView } /** * 根据手指滚动itemView的距离来判断是滚动到开始位置还是向左或者向右滚动 */ private void scrollByDistanceX() { // 如果向左滚动的距离大于屏幕的二分之一,就让其删除 if (itemView.getScrollX() >= screenWidth / 2) { scrollLeft(); } else if (itemView.getScrollX() <= -screenWidth / 2) { scrollRight(); } else { scrollToOrigin(); } } // 如果滑动速度不快且距离不到1/3,就原地滑动回原点 private void scrollToOrigin() { isRemoveScroll = false; int scrollX = itemView.getScrollX(); // 反方向滑动回去 scroller.startScroll(scrollX, 0, -scrollX, 0, 400); } /** * 处理我们拖动ListView item的逻辑 */ @Override public boolean onTouchEvent(MotionEvent ev) { if (isSlide && slidePosition != AdapterView.INVALID_POSITION) { addVelocityTracker(ev); final int action = ev.getAction(); int x = (int) ev.getX(); switch (action) { case MotionEvent.ACTION_MOVE: int deltaX = downX - x; downX = x; // 手指拖动itemView滚动, deltaX大于0向左滚动,小于0向右滚 itemView.scrollBy(deltaX, 0); setCotentViewAlpha(getAlphaRatio()); velocityX = getScrollVelocity(); return true; case MotionEvent.ACTION_UP: Log.i("scrollvelocity x ========== ", velocityX + " " + SNAP_VELOCITY); if (velocityX > SNAP_VELOCITY) { scrollRight(); } else if (velocityX < -SNAP_VELOCITY) { scrollLeft(); } else { scrollByDistanceX(); } recycleVelocityTracker(); // 手指离开的时候就不响应左右滚动 isSlide = false; break; } } // 否则直接交给ListView来处理onTouchEvent事件 return super.onTouchEvent(ev); } /** * 获取移动距离跟透明度的比率,总距离为1/2 屏幕宽,透明度从0~255 */ private int getAlphaRatio() { int scrollX = Math.abs(itemView.getScrollX()); int xRatio = (int) Math.round(((2 * scrollX) / (float) screenWidth) * 255); // 透明度最大值为255 xRatio = 255 - (xRatio > 255 ? 255 : xRatio); return xRatio; } /** * 设置内容区域的透明度 */ private void setCotentViewAlpha(int xRatio) { contentView.getBackground().setAlpha(xRatio); TextView tvTitle = (TextView) contentView.findViewById(R.id.test_title); TextView tvDate = (TextView) contentView.findViewById(R.id.test_date); setTextAlpha(xRatio, tvTitle); setTextAlpha(xRatio, tvDate); } /** * 设置文字的透明色 */ private void setTextAlpha(int ratio, TextView textView) { int color = textView.getCurrentTextColor(); textView.setTextColor(Color.argb(ratio, Color.red(color), Color.green(color), Color.blue(color))); } @Override public void computeScroll() { // 调用startScroll的时候scroller.computeScrollOffset()返回true, if (scroller.computeScrollOffset()) { // 让ListView item根据当前的滚动偏移量进行滚动 itemView.scrollTo(scroller.getCurrX(), scroller.getCurrY()); setCotentViewAlpha(getAlphaRatio()); postInvalidate(); // 滚动动画结束的时候调用回调接口 if (scroller.isFinished() && isRemoveScroll) { if (mRemoveListener == null) { throw new NullPointerException( "RemoveListener is null, we should called setRemoveListener()"); } mRemoveListener.removeItem(removeDirection, slidePosition); // 删除item后要把透明度和坐标恢复到初始值 itemView.scrollTo(0, 0); setCotentViewAlpha(255); } } } /** * 添加用户的速度跟踪器 * * @param event */ private void addVelocityTracker(MotionEvent event) { if (velocityTracker == null) { velocityTracker = VelocityTracker.obtain(); } velocityTracker.addMovement(event); } /** * 移除用户速度跟踪器 */ private void recycleVelocityTracker() { if (velocityTracker != null) { velocityTracker.clear(); velocityTracker.recycle(); velocityTracker = null; } } /** * 获取X方向的滑动速度,大于0向右滑动,反之向左 * * @return */ private int getScrollVelocity() { velocityTracker.computeCurrentVelocity(1000, mMaxVelocity); int velocity = (int) velocityTracker.getXVelocity(mPointerId); return velocity; } /** * 当ListView item滑出屏幕,回调这个接口 我们需要在回调方法removeItem()中移除该Item,然后刷新ListView */ public interface RemoveListener { public void removeItem(RemoveDirection direction, int position); }}
代码里面的解释还是很详细的,我在这就大体说一下上面三点思路:
滑动删除
手指滑动item抬起手时,计算item的偏移量,如果大于item的1/2宽,就判定为删除
滑动的速度velocityTracker > 1000 dp(dp会转成px) 时,也判定为删除
滑动的效果
手指滑动的效果是用scroller实现的
item的透明度要要依据item的滑动距离来计算,具体的公式为:
int xRatio = (int) Math.round(((2 * scrollX) / (float) screenWidth) * 255)需要透明的不止是item的背景,item里的字体让也要透明
item其实分为两个部分,整块item的背景色其实是灰色,item的内容区域是白色,这样一划动就露出灰色背景
接下来还有自定的adapter,它实现了删除撤销功能
import java.util.List;import android.content.Context;import android.os.Handler;import android.view.LayoutInflater;import android.view.View;import android.view.ViewGroup;import android.view.animation.AnimationUtils;import android.widget.BaseAdapter;import android.widget.TextView;import android.widget.Toast;import com.example.slidecutlistview.CustomSwipeListView.RemoveDirection;import com.example.slidecutlistview.CustomSwipeListView.RemoveListener;/** * 实现撤销动作的Adapter */public class CustomSwipeAdapter extends BaseAdapter implements CancelListener, RemoveListener { private static final int INVALID_POSITION = -1; protected Context mContext; private TestModel deleteModel; // 测试数据的实体类列表 private List<TestModel> testModels; // 记录删除的item的位置 private int deletedPosition; // 是否撤销删除的item private boolean cancelRemoveItem = false; // 滑动的方向 private RemoveDirection deleteDirection; // 记录是否上一次弹出框还没消失 private boolean isCountingTime; // 撤销弹出框的线程 private Runnable dismissRunnable; private Handler handler; private CustomSwipeCancelDialog cancelDialog; public CustomSwipeAdapter(Context context, List<TestModel> Objects) { mContext = context; testModels = Objects; handler = new Handler(); dismissRunnable = new DismissRunnable(); cancelDialog = new CustomSwipeCancelDialog(context); cancelDialog.setcancelActionListener(this); } @Override public TestModel getItem(int position) { return testModels.get(position); } @Override public int getCount() { return testModels.size(); } @Override public View getView(int position, View convertView, ViewGroup parent) { View view; ViewHolder holder; if (convertView == null) { view = LayoutInflater.from(mContext).inflate(R.layout.test_listview_item_view, parent, false); holder = new ViewHolder(); holder.tvDate = (TextView) view.findViewById(R.id.test_date); holder.tvTitle = (TextView) view.findViewById(R.id.test_title); view.setTag(holder); } else { view = convertView; holder = (ViewHolder) view.getTag(); } holder.tvTitle.setText(getItem(position).getTestTitle()); holder.tvDate.setText(getItem(position).getTestDate()); if (cancelRemoveItem) { cancelActionAnimation(view.findViewById(R.id.ll_cotentview), position); } return view; } class ViewHolder { TextView tvTitle; TextView tvDate; } /** * 执行撤销动画 */ private void cancelActionAnimation(View contentView, int undoPosition) { if (undoPosition == deletedPosition) { switch (deleteDirection) { case LEFT: contentView.startAnimation(AnimationUtils.loadAnimation(mContext, R.anim.canceldialog_push_left_in)); break; case RIGHT: contentView.startAnimation(AnimationUtils.loadAnimation(mContext, R.anim.canceldialog_push_right_in)); break; default: break; } clearDeletedObject(); } else { contentView.clearAnimation(); } } /** * 撤销dialog消失时调用 */ @Override public void normalAction() { if (!cancelRemoveItem) { clearDeletedObject(); } } public void clearDeletedObject() { deleteModel = null; cancelRemoveItem = false; deletedPosition = INVALID_POSITION; } /** * 删除后点击撤销的操作 */ @Override public void executeCancelAction() { if (deletedPosition <= testModels.size() && deletedPosition != INVALID_POSITION) { testModels.add(deletedPosition, deleteModel); cancelRemoveItem = true; notifyDataSetChanged(); } } @Override public long getItemId(int position) { return 0; } /** * 滑动删除之后的回调方法 */ @Override public void removeItem(RemoveDirection direction, int position) { // 上一个删除item在延迟的时间内,再删除另一个,要先终止上一个runnable if (isCountingTime) { handler.removeCallbacks(dismissRunnable); } TestModel model = removeItemByPosition(position, direction); cancelDialog.setMessage("Delete" + model.getTestTitle()).showCancelDialog(); dismissDialog(); switch (direction) { case RIGHT: Toast.makeText(mContext, "向右删除 " + position, Toast.LENGTH_SHORT).show(); break; case LEFT: Toast.makeText(mContext, "向左删除 " + position, Toast.LENGTH_SHORT).show(); break; default: break; } } /** * 删除操作并保存被删除对象信息 */ public TestModel removeItemByPosition(int position, RemoveDirection direction) { if (position < getCount() && position != INVALID_POSITION) { deleteModel = testModels.remove(position); deletedPosition = position; deleteDirection = direction; notifyDataSetChanged(); return deleteModel; } else { throw new IndexOutOfBoundsException("The position is invalid!"); } } /** * 弹出撤销对话框后一段时间内(5秒)还没任何操作的话,对话框自动消失 */ private void dismissDialog() { isCountingTime = true; handler.postDelayed(dismissRunnable, 5000); } class DismissRunnable implements Runnable { @Override public void run() { if (cancelDialog.isShowing()) { cancelDialog.closeCancelDialog(); clearDeletedObject(); isCountingTime = false; } } }}
这个adapter继承里两个接口,一个是CustomSwipeListView里的RemoveListener和撤销接口CancelListener
撤销操作
- ListView里检测到删除操作时,回调adapter里的removeItem方法
- adapter执行删除操作,并保存被删除的item数据,最后展示撤销的dialog
- 点击撤销,把被删除的数据从新增加在adapter里,并执行撤销动画
- 如果不做操作五秒后或者点击其他区域,撤销的dialog消失并删除保留的被删除数据
最后附上整个DEMO的github地址
另:此demo部分源码和思路来自这篇博客和这个开源项目