都知道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文章。
微信公衆號圖片: