都知道QQ有一个比较牛逼的效果就是测拉删除效果,目前这个功能,网上自定义控件也有很多实现方式了,本篇也自己实现一个测拉删除效果的自定义控件。虽然功能一样,实现方式不同罢了,也希望提供一些思路,对自己和读者有些帮助~
由于QQ测拉功能强大,手写文字耗费时间,就做个低配置版的测拉效果。废话不多讲,还是乖乖搞事情吧~
1、实现测拉删除的真整体布局:
对于自定义View的布局:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/activity_main" android:layout_width="match_parent" android:layout_height="match_parent" tools:context="com.itydl.a07sweepview.MainActivity"> <com.itydl.a07sweepview.SweepView android:id="@+id/sv" android:layout_width="match_parent" android:layout_height="65dp" > <!--左侧内容区域--> <include layout="@layout/content"/> <!--右侧删除区域--> <include layout="@layout/delete"/> </com.itydl.a07sweepview.SweepView> </RelativeLayout>通过include的方式引入布局。这两个布局分别表示内容区域,和左侧删除区域。代码如下:
content.xml:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="80dp"> <TextView android:gravity="center" android:textColor="#ffffff" android:background="#88000000" android:textSize="25sp" android:layout_width="match_parent" android:layout_height="match_parent" android:text="测试数据"/> </LinearLayout>delete.xml:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="150dp" android:layout_height="65dp"> <TextView android:gravity="center" android:textColor="#ffffff" android:background="#ff0000" android:textSize="25sp" android:layout_width="match_parent" android:layout_height="match_parent" android:text="删除"/> </LinearLayout>
自定义View的代码:
public class SweepView extends ViewGroup { private View mContentView; private View mDeleteView; private int mDeleteWidth; public SweepView(Context context) { this(context, null); } public SweepView(Context context, AttributeSet attrs) { super(context, attrs); } //xml文件加载完成 @Override protected void onFinishInflate() { //一般用于拿到孩子对象 mContentView = getChildAt(0); mDeleteView = getChildAt(1); //拿到DeleteView的params对象 LayoutParams params = mDeleteView.getLayoutParams(); mDeleteWidth = params.width; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //测量孩子 mContentView.measure(widthMeasureSpec, heightMeasureSpec); int measureSpecWidth = MeasureSpec.makeMeasureSpec(mDeleteWidth, MeasureSpec.EXACTLY); mDeleteView.measure(measureSpecWidth, heightMeasureSpec); int widthMeasureSpecSelf = MeasureSpec.getSize(widthMeasureSpec); int heightMeasureSpecSelf = MeasureSpec.getSize(heightMeasureSpec); //设置自定义View的大小 setMeasuredDimension(widthMeasureSpecSelf, heightMeasureSpecSelf); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { //给孩子布局 int contentWidth = mContentView.getMeasuredWidth(); int contentHeight = mContentView.getMeasuredHeight(); mContentView.layout(0, 0, contentWidth, contentHeight); int deleteWidth = mDeleteView.getMeasuredWidth(); int deleteHeight = mDeleteView.getMeasuredWidth(); mDeleteView.layout(contentWidth, 0, contentWidth + deleteWidth, deleteHeight); } }上面进行layout和measue相信已经简单到跟写button代码一样easy了,没啥好说的。
运行程序:
知识简单的布局,点击并没办法滑动。接下来实现滑动效果:
这里滑动采用v4包里面的工具类:ViewDragHelper
2、ViewDragHelper在本项目中的使用:
1)、创建实例
public SweepView(Context context, AttributeSet attrs) { super(context, attrs); mDragHelper = ViewDragHelper.create(this,new MyCallBack()); }2、touch事件委托给ViewDragHelper离开监听处理,在它内部把触摸事件封装的很好了。
@Override public boolean onTouchEvent(MotionEvent event) { mDragHelper.processTouchEvent(event); return true; }3、实现自己的callback:
我们在实例化ViewDragHelper的时候, 这里参数1就代表自定义的View,传入this即可。着重说一下callBack,我们通过创建内部类方式,创建MyCallBack类,并重写里面的回调方法:
class MyCallBack extends ViewDragHelper.Callback{ //是否分析(返回true才会有效)view的touch;参数1:触摸的view;2:touch的id。 @Override public boolean tryCaptureView(View child, int pointerId) { // 去分析child。表示我分析ContentView和DeleteView的topuch事件 System.out.println(child == mContentView); return child == mContentView || child == mDeleteView; } @Override public int clampViewPositionHorizontal(View child, int left, int dx) { System.out.println(left); return left; } @Override public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) { } @Override public void onViewReleased(View releasedChild, float xvel, float yvel) { } }这里一共重写了四个回调方法。我们一一解释都代表什么意思,以及每个方法功能和参数意义。
1)、tryCaptureView(View child, int pointerId)在发生touch事件的down事件的时候回调
代表我是否分析(返回true才会有效)view的touch事件;参数1:当前触摸的view;2:touch的id。如果这个方法返回值为false,表示已不对触摸的view进行分析。则表示我ViewDragHelper不支持滑动效果了。后期的方法也都无效。
在里面打印了一行log,我们手滑内容区域,发现此时已经能够滑动了:
首先,可以实现滑动效果,再此时看log日志:
02-05 08:36:02.445 4596-4596/com.itydl.a07sweepview I/System.out: true
发现返回值为true、因此后续的操作才得以实现。
2)clampViewPositionHorizontal(View child, int left, int dx):水平移动的回调,发生touch时间move时回调。
当touch移动后的回调 参数1:分析的是哪个孩子组件移动了;参数2:左上角的坐标,child的左侧的边距,控件移动到左边什么位置,值会根据移动变化;参数3:增量的x(记录相对上一次的变化量dx>0右滑,dx<0左滑)。这里的值是预期的值,可以在这里做边距的监测,做越界处理。该方法的返回值表示:// 确定要移动多少,移动到什么位置去 return left;。【这个方法里面经常换主角,touch到哪个view这里的child就是哪个view】
这么多理论知识,估计一时明白也够呛,别着急,相信往下继续学习会非常清楚的。
在这里我也做了一行打印:
02-05 08:46:59.090 4596-4596/com.itydl.a07sweepview I/System.out: 34-----34
02-05 08:46:59.107 4596-4596/com.itydl.a07sweepview I/System.out: 66-----32
02-05 08:46:59.125 4596-4596/com.itydl.a07sweepview I/System.out: 99-----33
02-05 08:46:59.142 4596-4596/com.itydl.a07sweepview I/System.out: 146-----47
02-05 08:46:59.159 4596-4596/com.itydl.a07sweepview I/System.out: 177-----31
02-05 08:46:59.175 4596-4596/com.itydl.a07sweepview I/System.out: 203-----26
02-05 08:46:59.200 4596-4596/com.itydl.a07sweepview I/System.out: 227-----24
通过log可以更加清晰的了解参数的具体意义,那个dx值是一个变化量。必须我最初left=0,下一次=10,第三次=60.那么dx分别为:10,50
除了clampViewPositionHorizontal当然还有clampViewPositionHorizontal,会一种相信另一种也是信手拈来。
3)、onViewPositionChanged(View changedView, int left, int top, int dx, int dy)当【控件位置】移动时的回调
参数意义:// @changedView: 哪个view移动了 // @left,top:view移动后的左上角的坐标 // @dx,dy: 移动的增量
这个方法跟上边clampViewPositionHorizontal差不多,都是移动回调,如果说clampViewPositionHorizontal用于处理越界以及确定位置的话,那么onViewPositionChanged一般用于移动view的布局重置。后续代码可以看到两者的各自责任,以及实现什么功能。
4)、onViewReleased(View releasedChild, float xvel, float yvel)松开收时候的回调up事件的回调。
@releasedChild:松开了哪个view; @xvel,yvel:速率。该方法一般用于“松手回弹”效果的操作,即松开手,自定义控件往哪个位置回弹。上一篇自定义ViewPage可以看到回弹效果,那里是使用Scrollor实现的,而在ViewDragHelper有它的手段,稍后会用到。
3、滑动删除滑动的实现。
了解了上述几个方法,我们就要在这几个方法里面做一些操作了,比如先实现滑动效果。不仅仅点击contentview区域可滑动,点击deleteview区域也可以实现控件的整体滑动效果。
代码如下:
@Override public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) { int contentWidth = mContentView.getMeasuredWidth(); int contentHeight = mContentView.getMeasuredHeight(); int deleteWidth = mDeleteView.getMeasuredWidth(); int deleteHeight = mDeleteView.getMeasuredWidth(); if (changedView == mContentView) { mDeleteView.layout(contentWidth + left, 0, contentWidth + deleteWidth + left, deleteHeight); } else if (changedView == mDeleteView) { mContentView.layout(left-contentWidth,0,left,contentHeight); } }
就像前面介绍所说的在onViewPositionChanged方法中根据move事件,对xml可以做重新布局操作。上面代码的值都是一些很简单的小算法,相信看起来还是蛮简单的。当我们滑动灰色内容区域,此时的changeView就是灰色内容区域,当滑动删除位置,此时的changeView就代表了红色区域。
4、滑动删除边界的处理,解决越界问题。
@Override public int clampViewPositionHorizontal(View child, int left, int dx) { Log.e("YDL", dx + ""); if (child == mContentView) { if (left < 0 && left < -mDeleteView.getMeasuredWidth()) {//左滑 return -mDeleteView.getMeasuredWidth(); } else if (left > 0) {//右滑 return 0; } } else if (child == mDeleteView) { if (left > mContentView.getMeasuredWidth()) { return mContentView.getMeasuredWidth(); }else if(left < mContentView.getMeasuredWidth() - mDeleteView.getMeasuredWidth()){ return mContentView.getMeasuredWidth() - mDeleteView.getMeasuredWidth(); } } return left; }就像前面介绍所说的在clampViewPositionHorizontal方法中根据move事件,根据滑动不同的子View,来确定边界值不越界。运行程序:
此时,实现了滑动效果,并解决了越界问题。
5、实现滑动“回弹”效果
此时核心的逻辑已经实现了,接下来就是处理一些细节
@Override public void onViewReleased(View releasedChild, float xvel, float yvel) { // up时的回调 // @releasedChild:松开了哪个view // @xvel,yvel:速率 int left = mContentView.getLeft(); int contentWidth = mContentView.getMeasuredWidth(); int contentHeight = mContentView.getMeasuredHeight(); int deleteWidth = mDeleteView.getMeasuredWidth(); int deleteHeight = mDeleteView.getMeasuredWidth(); if(-left < mDeleteView.getMeasuredWidth()/2){ mContentView.layout(0, 0, contentWidth, contentHeight); mDeleteView.layout(contentWidth, 0, contentWidth + deleteWidth, deleteHeight); }else{ mContentView.layout(-deleteWidth, 0, contentWidth - deleteWidth, contentHeight); mDeleteView.layout(contentWidth - deleteWidth, 0, contentWidth, deleteHeight); } }UP事件后,通过判断ContentView左侧坐标位置,重新确定了两个孩子组件的位置。运行科自行调试,恢复布局很生硬,因此加入缓慢恢复功能。
修改上述代码:
@Override public void onViewReleased(View releasedChild, float xvel, float yvel) { // up时的回调 // @releasedChild:松开了哪个view // @xvel,yvel:速率 int left = mContentView.getLeft(); int contentWidth = mContentView.getMeasuredWidth(); int deleteWidth = mDeleteView.getMeasuredWidth(); if(-left < mDeleteView.getMeasuredWidth()/2){ mDragHelper.smoothSlideViewTo(mContentView,0,0); mDragHelper.smoothSlideViewTo(mDeleteView,contentWidth,0); }else{ mDragHelper.smoothSlideViewTo(mContentView,-deleteWidth,0); mDragHelper.smoothSlideViewTo(mDeleteView,contentWidth - deleteWidth,0); } //效果等同于invalidate()---->会调用computeScroll ViewCompat.postInvalidateOnAnimation(SweepView.this); }其中smoothSlideViewTo已经把平滑恢复封装的很好了。只需要传入View、该最终左侧坐标、最终top坐标即可。
这里必须进行invalidate();刷新,使用了ViewCompat.postInvalidateOnAnimation(SweepView.this);代替,这个api可以兼容更低的版本。然而这里只是委托作用,真正的平滑移动效果在移动回调方法computeScroll()中。重写之:
@Override public void computeScroll() { if(mDragHelper.continueSettling(true)){ //直接刷新即可 ViewCompat.postInvalidateOnAnimation(SweepView.this); } }在这里面,只需要简单刷新界面调用ViewCompat.postInvalidateOnAnimation(SweepView.this);即可。当我们调用ViewCompat.postInvalidateOnAnimation(SweepView.this);后,内部会调用computeScroll方法,在这里面隔一段距离并借助scrollTo()完成平滑移动。运行:
6、滑动删除的实现:
在MainActivity中使用ListView,相信都会熟练使用。在定义Adapter适配器的时候,我们通过下面方式实现ListView加载数据,而且每个Item都加入是用自定义控件。
@Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView == null) { holder = new ViewHolder(); convertView = View.inflate(MainActivity.this, R.layout.item_list, null); holder.mTextView = (TextView) convertView.findViewById(R.id.tv_content); holder.mSweepView = (SweepView) convertView.findViewById(R.id.sv); convertView.setTag(holder); } else { holder = (ViewHolder) convertView.getTag(); } String itemStr = (String) getItem(position); holder.mTextView.setText(itemStr); return convertView; }getView的item的布局:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <com.itydl.a07sweepview.SweepView android:id="@+id/sv" android:layout_width="match_parent" android:layout_height="65dp" > <!--左侧内容区域--> <include layout="@layout/content"/> <!--右侧删除区域--> <include layout="@layout/delete"/> </com.itydl.a07sweepview.SweepView> </LinearLayout>运行程序:
7、实现真正的侧栏删除,以及一些细节的处理。
首先添加可删除事件,直接在getView方法里面设置item上的孩子组件的点击事件即可。
//给listview的Item上添加点击事件,点击删除该item条目 holder.mTextDelet.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { mList.remove(position); notifyDataSetChanged(); } });运行程序:
此时已经可以完成删除了,只不过我们发现删除之后接着看下一条item不对劲,我没有滑动下一条item,怎么这么显示呢?还有我们往下滑动ListView,由于复用的原因,也会出现类似情况。
那么紧跟着解决细节问题:
要解决问题其实也挺简单,只需要再点击item的时候,关闭掉所有打开的item即可。那么,如何才能控制打开与关闭呢?其实在前面的恢复布局就已经隐含了这个功能。只需要把原来位置抽取一个方法就好了。
@Override public void onViewReleased(View releasedChild, float xvel, float yvel) { // up时的回调 // @releasedChild:松开了哪个view // @xvel,yvel:速率 int left = mContentView.getLeft(); int contentWidth = mContentView.getMeasuredWidth(); int deleteWidth = mDeleteView.getMeasuredWidth(); if(-left < mDeleteView.getMeasuredWidth()/2){ mDragHelper.smoothSlideViewTo(mContentView,0,0); mDragHelper.smoothSlideViewTo(mDeleteView,contentWidth,0); }else{ mDragHelper.smoothSlideViewTo(mContentView,-deleteWidth,0); mDragHelper.smoothSlideViewTo(mDeleteView,contentWidth - deleteWidth,0); } //效果等同于invalidate()---->会调用computeScroll ViewCompat.postInvalidateOnAnimation(SweepView.this); }修改为:
@Override public void onViewReleased(View releasedChild, float xvel, float yvel) { // up时的回调 // @releasedChild:松开了哪个view // @xvel,yvel:速率 int left = mContentView.getLeft(); if(-left < mDeleteView.getMeasuredWidth()/2){ //还原item(关闭) close(); }else{ //(打开) open(); } }打开和关闭方法就要暴露方法出去:
/** * 关闭item */ public void close() { int contentWidth = mContentView.getMeasuredWidth(); if(mSweepChangeListener != null){ mSweepChangeListener.sweepChanged(SweepView.this,false); } mDragHelper.smoothSlideViewTo(mContentView,0,0); mDragHelper.smoothSlideViewTo(mDeleteView,contentWidth,0); //效果等同于invalidate()---->会调用computeScroll.这里必须有刷新才可以 ViewCompat.postInvalidateOnAnimation(SweepView.this); } /** * 打开item */ public void open() { int contentWidth = mContentView.getMeasuredWidth(); int deleteWidth = mDeleteView.getMeasuredWidth(); if(mSweepChangeListener != null){ mSweepChangeListener.sweepChanged(SweepView.this,true); } mDragHelper.smoothSlideViewTo(mContentView,-deleteWidth,0); mDragHelper.smoothSlideViewTo(mDeleteView,contentWidth - deleteWidth,0); //效果等同于invalidate()---->会调用computeScroll.这里必须有刷新才可以 ViewCompat.postInvalidateOnAnimation(SweepView.this); }为了判断item的view是打开还是关闭,因此在自定义View中暴露接口出去,并在每一次getView的时候通过接口会掉的方式告知当前的item是打开还是关闭的:
public void setOnSweepChangeListener(OnSweepChangeListener listener){ this.mSweepChangeListener = listener; } public interface OnSweepChangeListener{ /** * item是自定义SweepView对象。每个ListView的item都是SweepView对象,且不同 * @param sweepView * @param isOpend */ void sweepChanged(SweepView sweepView,boolean isOpend); }对于接口对象调用接口方法,上边的打开和关闭方法中已经很给出了调用。
最后是在getView方法中作如下修改:
holder.mSweepView.setOnSweepChangeListener(new SweepView.OnSweepChangeListener() {
@Override
public void sweepChanged(SweepView sweepView, boolean isOpend) {
if(isOpend){
//如果打开,将该item对应的SweepView对象则保存至集合中
if(!mSweepViews.contains(sweepView)){
mSweepViews.add(sweepView);
}
}else{
mSweepViews.remove(sweepView);
}
}
});
当加载每一个 item的时候,每条item都监听当前Item的SweepView子布局是打开还是关闭,如果打开,将该item对应的SweepView对象则保存至集合中。 而当点击了删除按钮的时候,我们需要如下操作://给listview的Item上添加点击事件,点击删除该item条目 holder.mTextDelet.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { mList.remove(itemStr); closeAll(); notifyDataSetChanged(); } });
private void closeAll() { //使用迭代器 /*ListIterator<SweepView> iterator = mSweepViews.listIterator(); while (iterator.hasNext()) { SweepView view = iterator.next(); view.close(); }*/ for (SweepView sweepView : mSweepViews) { sweepView.close(); } }此时实现了删除,运行程序:
可能你觉得已经完成了,其实,还存在一个严重的bug,当我们往下滑动的时候就可以看到了,以及我们滑动多个item都能打开,显然不符合QQ滑动删除效果。那么最后就解决这个bug:
解决思路:当我们按下的时候,记录按下滑动打开时候的SweepView的实例,这个实例用临时变量保存起来。然后通过接口回调的方式,传递用户下一次按下的item的SweepView实例。这次新添加的接口回调方法,在View的onTouchEvent的Action_Down的时候调用。代码如下:
1)添加接口方法
/** * 按下时候的回调。按下,如果按下时当前的item与打开的item不一致,按下关闭掉item * @param sweepView */ void sweepDown(SweepView sweepView);2)在View的onTouchEvent的Action_Down的时候调用。
@Override public boolean onTouchEvent(MotionEvent event) { if(event.getAction() == MotionEvent.ACTION_DOWN){ //按下的时候 mSweepChangeListener.sweepDown(this); } mDragHelper.processTouchEvent(event); return true; }3)、记录按下滑动打开时候的SweepView的实例,以及实现只允许一条item展示。
private SweepView mSweepView;
holder.mSweepView.setOnSweepChangeListener(new SweepView.OnSweepChangeListener() { @Override public void sweepChanged(SweepView sweepView, boolean isOpend) { if (isOpend) { mSweepView = sweepView; //如果打开,将该item对应的SweepView对象则保存至集合中 if (!mSweepViews.contains(sweepView)) { mSweepViews.add(sweepView); } } else { mSweepViews.remove(sweepView); } } @Override public void sweepDown(SweepView sweepView) { //如果不滑动,mSweepView为null,因此要过滤为null情况 if(mSweepView != null && mSweepView != sweepView){ mSweepView.close(); } } });
最后运行看看地方QQ侧拉删除的效果吧:
效果还不错,加个关注呗~
打开微信搜索公众号 Android程序员开发指南 或者手机扫描下方二维码 在公众号阅读更多Android文章。
微信公众号图片: