仿QQ對話列表滑動刪除與置頂的原理及實現
接下來,我們將完成QQ聊天介面的ListView滑動效果,大家可能都用過ListView,知道ListView是上下滑動的,並不會產生左右滑動的效果,如果想讓ListView變成左右滑動的效果,必須對安卓原始碼有所瞭解,如果你想了解原始碼,請到http://blog.csdn.net/column/details/core-services.html 該專欄下了解詳情。
我的思路就是:
所有的螢幕操作事件由ListView作做攔截,同時把事件傳遞給SlideView做滑動,這種實現的確可以達到效果,而且程式碼很簡單,根本不需要處理什麼複雜的滑動衝突。
QQ效果圖與自己實現的效果圖對比:
1.思路流程
首先我們需要實現自己的ListView來處理截獲螢幕的事件,但不是由ListView處理,而是轉發給自定義item控制元件處理,也就是實現的SlideView控制元件。根據處理手勢設定item的狀態,也就是說當已經滑動了,這個時候如果不獲取item的狀態,下次在滑動這個item的時候是不知道這個控制元件已經滑動了,不然就會有二次滑動,所以必須儲存滑動狀態。
下面用箭頭詳細說明今天程式碼流程:
當手指滑動某個item的時候——>ListView截獲滑動事件——>在自定義ListView中實現onTouchEvent()方法,判斷當前是哪個item——>然後將事件派發給item自己的SlideView去處理滑動——>處理完成後設定當前item的狀態(防止二次滑動,也防止其他item滑動後,該item沒有恢復到原來的模樣)
2.定義Item容器
程式碼如下:
<merge xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout android:id="@+id/view_content" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal"> </LinearLayout> <LinearLayout android:id="@+id/holder" android:layout_width="120dp" android:layout_height="match_parent" android:background="#C5C1AA" android:orientation="horizontal"> <TextView android:id="@+id/top" android:layout_width="wrap_content" android:layout_height="50dp" android:gravity="center" android:background="#FFD700" android:text="@string/slide_holder_top" android:textSize="20sp" android:layout_weight="1"/> <TextView android:id="@+id/delete" android:layout_width="wrap_content" android:layout_height="50dp" android:gravity="center" android:text="@string/slide_holder_delete" android:textSize="20sp" android:layout_weight="1"/> </LinearLayout> </merge>
這裡需要說明的就是,merge節點在載入的時候會忽略掉檢視的層級,也就是說載入是時候會自動忽略掉merge節點,直接載入內部的內容,這裡就是兩個LinearLayout了。
這裡為什麼要用這個,因為等會的SlideView會繼承LinearLayout,如果用LinearLayout包裹這兩個LinearLayout,就會多出一個本身沒用LinearLayout,除了增加視力層級,沒有任何效果。
第一個LinearLayout寬高都設定為match_parent,目的就是將後一個LinearLayout擠出螢幕之外。好達到有滑動出控制元件的效果。
3.實現SlideView
我們SlideView會繼承自LinearLayout,當然你也可以繼承其他的佈局,只是LinearLayout考慮的相對簡單點。
㈠定義item狀態的介面
public interface OnSlideViewOnListener { //滑動的三個狀態 public static final int SLIDE_STATUS_OFF = 0; public static final int SLIDE_STATUS_START_SCROLL = 1; public static final int SLIDE_STATUS_ON = 2; /** * 更新滑動的狀態 * * @param view * @param status */ public void Slide(View view, int status); }
這個就不用多作解釋了,一目瞭然。
㈡初始化SlideView
public void initView() { //獲取上下文 this.mContext = getContext(); //初始化滑動物件 this.mScroller = new Scroller(this.mContext); //設定該LinearLayout為橫向座標 setOrientation(LinearLayout.HORIZONTAL); //載入佈局 View.inflate(this.mContext,R.layout.slide_holder,this); //獲取容器 this.mLinearLayout = (LinearLayout) findViewById(R.id.view_content); //將隱藏容器的寬度根據螢幕調節 this.mHolderWidth = Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this.mHolderWidth, getResources().getDisplayMetrics())); }
㈢處理ListView傳送過來的事件
public void onRequiredToEvent(MotionEvent event) { //獲取當前X,Y座標 int x = (int) event.getX(); int y = (int) event.getY(); Log.d(TAG, "x=" + x + " ,y=" + y); //getScrollX()獲取的值則發生了變化:指呼叫的控制元件的水平移動的距離,當未移動的時候,獲取的值為0. 當向右移動20,則獲取值為 -20,再向右移動10,則獲取-30 int scrollX = getScrollX(); //根據按鍵的方式處理相應的事件 switch (event.getAction()) { //按下時候處理 case MotionEvent.ACTION_DOWN: { if (!this.mScroller.isFinished()) { this.mScroller.abortAnimation(); } if (this.mOnSlideViewOnListener != null) { this.mOnSlideViewOnListener.Slide(this, OnSlideViewOnListener.SLIDE_STATUS_START_SCROLL); } break; } //移動時候處理 case MotionEvent.ACTION_MOVE: { //x,y各滑動了多少距離 int deltaX = x - this.mLastX; int deltaY = y - this.mLastY; Log.d(TAG, "deltaX=" + deltaX + " ,deltaY=" + deltaY); if (Math.abs(deltaX) < Math.abs(deltaY) * 2) { //角度大於60度,不符合滑動的條件 break; } int newScrollX = scrollX - deltaX; if (deltaX != 0) { if (newScrollX < 0) { newScrollX = 0; } else if (newScrollX > this.mHolderWidth) { newScrollX = this.mHolderWidth; } this.scrollTo(newScrollX, 0); } break; } //擡起時候處理 case MotionEvent.ACTION_UP: { int newScrollX = 0; if (scrollX - this.mHolderWidth * 0.75 > 0) { newScrollX = this.mHolderWidth; } this.smoothScrollTo(newScrollX, 0); if (this.mOnSlideViewOnListener != null) { this.mOnSlideViewOnListener.Slide(this, newScrollX == 0 ? OnSlideViewOnListener.SLIDE_STATUS_OFF : OnSlideViewOnListener.SLIDE_STATUS_ON); } break; } default: break; } //將歷史座標更新 this.mLastX = x; this.mLastY = y; }
在開始的時候定義了一個Scroller物件,該物件是彈性滑動物件,滑動效果由他實現。
Math.abs(deltaX) < Math.abs(deltaY) * 2的解釋:
deltaX為滑動的座標變數,X向左座標越小,X向右座標越大,Y向下座標越大,Y向上座標越小,所以有可能相對於上一個座標變數相減會是負值,所以用Math.abs()取他們的絕對值,因為我們只要保證橫向滑動的時候,角度在60度以內就算是橫向滑動,大於60度,那就算縱向滑動所以不處理滑動。而tan(63)約等於2,故對邊除鄰邊要<2,所以把變更Y移過去就得到這個值。
當然影象更容易理解,如下所示:
大的直線箭頭為X,Y軸,120度的箭頭為滑動方向,我們在該圖中為左,在矩形ListView的Item這個120度角度內都判斷為向左滑動,如果手指超過這個角度,就不是橫向滑動了。
getScrollX()指呼叫的控制元件的水平移動的距離,當未移動的時候,獲取的值為0. 當向右移動20,則獲取值為 -20,再向右移動10,則獲取-30
this.mScroller.isFinished():
返回scroller是否已完成滾動。
返回值:停止滾動返回true,否則返回false
this.mScroller.abortAnimation():停止動畫。Scroller滾動到最終x與y位置時中止動畫。
這兩句應該這樣理解,當我按下某個item的時候,如果我在滑動這個item,擡起手後,他的動畫還沒有結束,這個時候,我又按下了剛才的item,就必須讓他停止動畫。又因為正在滑動,所以必須設定這個item的狀態為滑動。
newScrollX = scrollX - deltaX;的目的是為了防止滑動越界,因為我可能手指一直向某個方向滑動,如果滑動超過了120dip,那麼如果不處理越界,就會有多的空白部分,會導致滑動無止境,有可能滑動的螢幕上沒有這個item了。所以當超過120dip的時候,就停止滑動if:newScrollX,else:newScrollX = this.mHolderWidth;當小於0後,也停止滑動:if:newScrollX < 0,else:newScrollX = 0;然後自執行滑動this.scrollTo(newScrollX, 0);
this.scrollTo(newScrollX, 0);是直接從某個座標在X軸滑動newScrollX的距離,Y軸滑動0,X右為負,左為正,Y下為負,上為正。與座標系相反。
最後解釋一下,當手指擡起後,滿足scrollX - this.mHolderWidth * 0.75 > 0,則滑動出隱藏控制元件。然後滑動。
滑動程式碼如下:
public void smoothScrollTo(int destX, int destY) { int scrollX = getScrollX(); int delta = destX - scrollX; this.mScroller.startScroll(scrollX, 0, delta, 0, Math.abs(delta) * 3); invalidate();//重繪 }
因為為橫向滑動destY是沒有意義的,這裡這樣是可能很容易擴充套件程式碼,首先獲得手指滑動的距離,然後獲得最終item需要滑動的距離delta,然後執行滑動。
public void startScroll (int startX, int startY, int dx, int dy) :以提供的起始點和將要滑動的距離開始滾動。滾動會使用預設值250ms作為持續時間。
當呼叫startScroll方法後,scroller並不是直接包辦一切,它只是幫助你計算每次移動的偏移量,你需要重寫computeScroll方法,並在裡面處理移動。程式碼如下:
//呼叫startScroll()是不會有滾動效果的,只有在computeScroll()獲取滾動情況,做出滾動的響應 @Override public void computeScroll() { if(this.mScroller.computeScrollOffset()){ scrollTo(this.mScroller.getCurrX(),this.mScroller.getCurrY()); postInvalidate(); } super.computeScroll(); }
如果滑動出隱藏控制元件設定狀態為ON,否則為OFF。
玩QQ的也應該知道,所有的item只有一個,也只能有一個滑動出現,如果要滑動出另一個控制元件必須將上個控制元件關閉。所以還需要將控制元件關閉的方法,程式碼如下 :
/** * 將當前狀態置為關閉 */ public void shrink(){ if(getScrollX()!=0){ this.smoothScrollTo(0,0); } }
當滑動另一個控制元件的時候就呼叫上一個item控制元件的shrink()方法。
當然還需要將View載入進來,程式碼如下:
/** * 將View載入進來 * @param view */ public void setContentView(View view){ this.mLinearLayout.addView(view); }
當然還要有設定回撥函式的方法:
/** * 設定回撥函式 * @param onSlideViewOnListener */ public void setmOnSlideViewOnListener(OnSlideViewOnListener onSlideViewOnListener){ this.mOnSlideViewOnListener=onSlideViewOnListener; }
這樣SlideView就基本實現了。
4.實現自定義的ListView
如果用系統自己的ListView,將不會將滑動事件傳送到SlideView,故需要實現自己的ListView,然而我們並不 是直接繼承ListView,而是ListViewCompat,該類也繼承自ListView,不過有些擴充套件的方法可以使用,更加全能。
程式碼如下:
public class MyListView extends ListViewCompat { private SlideView chooseSlideView; public MyListView(Context context, AttributeSet attrs) { super(context, attrs); } @Override public boolean onTouchEvent(MotionEvent event) { switch(event.getAction()){ case MotionEvent.ACTION_DOWN:{ int x=(int)event.getX(); int y=(int)event.getY(); int position=pointToPosition(x,y); if(position!= ListView.INVALID_POSITION){ MessageItem item=(MessageItem)getItemAtPosition(position); this.chooseSlideView=item.mSlideView; } break; } default: break; } //將操作提交給SlideView處理 if(this.chooseSlideView!=null){ this.chooseSlideView.onRequiredToEvent(event); } return super.onTouchEvent(event); } }
當接收到觸控事件後,根據X,Y座標,獲得item的ID,提供給我們的方法為pointToPosition(x,y),一看就知道該方法的:point點到Position。每個ListView都有一個無效的位置,比如第一行的前一行,最後一行的後一行,需要作一個判斷:position!= ListView.INVALID_POSITION。如果是有效的位置,就獲得該item的資訊,並設定chooseSlideView。然後將觸控事件提交給item的包裝View處理(也就是SlideView)。
MessageItem程式碼如下:
public class MessageItem { public int iconRes; public String title; public String msg; public String time; public SlideView mSlideView; }
5.最後實現Activity程式碼
啟動的Activity需要實現兩個介面,一個是SlideView.OnSlideViewOnListener,一個是View.OnClickListener點選事件。
對於ListView需要一個介面卡adapter,程式碼如下:
private class SlideAdapter extends BaseAdapter { private LayoutInflater mInflater; SlideAdapter() { super(); this.mInflater = getLayoutInflater(); } @Override public int getCount() { return mMessageItem.size(); } @Override public Object getItem(int position) { return mMessageItem.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; SlideView slideView = (SlideView) convertView; if (slideView == null) { View itemView = this.mInflater.inflate(R.layout.list_item, null); slideView = new SlideView(MainActivity.this); slideView.setContentView(itemView); holder = new ViewHolder(slideView); slideView.setmOnSlideViewOnListener(MainActivity.this); slideView.setTag(holder); } else { holder = (ViewHolder) slideView.getTag(); } MessageItem item = mMessageItem.get(position); item.mSlideView = slideView; item.mSlideView.shrink(); holder.icon.setImageResource(item.iconRes); holder.title.setText(item.title); holder.msg.setText(item.msg); holder.time.setText(item.time); holder.delete.setOnClickListener(MainActivity.this); holder.top.setOnClickListener(MainActivity.this); return slideView; } }
定義LayoutInflater都是用來載入佈局檔案的,不管是Activity裡的setContentView()還是剛才的SlideView的Viiew.inflate其內部實現都是這樣做的。
其中list_item程式碼如下:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:id="@+id/icon" android:layout_width="50dp" android:layout_height="50dp"/> <LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignBaseline="@+id/icon" android:layout_toRightOf="@+id/icon" android:orientation="vertical"> <TextView android:id="@+id/title" android:layout_width="wrap_content" android:layout_height="wrap_content" /> <TextView android:id="@+id/msg" android:layout_width="wrap_content" android:layout_height="wrap_content" /> </LinearLayout> <TextView android:id="@+id/time" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentRight="true"/> </RelativeLayout>
這裡定義了一個ViewHolder,為的就是防止二次載入。其程式碼如下:
public class ViewHolder { public ImageView icon; public TextView title; public TextView msg; public TextView time; public TextView delete; public TextView top; ViewHolder(View view) { this.icon = (ImageView) view.findViewById(R.id.icon); this.title = (TextView) view.findViewById(R.id.title); this.msg = (TextView) view.findViewById(R.id.msg); this.time = (TextView) view.findViewById(R.id.time); this.delete = (TextView) view.findViewById(R.id.delete); this.top = (TextView) view.findViewById(R.id.top); } }
每次獲得MessageItem都呼叫了item.mSlideView.shrink();將隱藏控制元件隱藏。其他的程式碼不用多作解釋,ListView基本都是這麼用的。
下面我們來看一個介面SlideView.OnSlideViewOnListener實現,程式碼如下:
@Override public void Slide(View view, int status) { if (this.mLastSlideViewWithStatusOn != null && this.mLastSlideViewWithStatusOn != view) { this.mLastSlideViewWithStatusOn.shrink(); } if (status == SlideView.OnSlideViewOnListener.SLIDE_STATUS_ON) { this.mLastSlideViewWithStatusOn = (SlideView) view; } }
我們定義了一個記錄開啟隱藏控制元件的SlideView:
private SlideView mLastSlideViewWithStatusOn;
當滑動某個控制元件的時候,就將上一個開啟隱藏控制元件的item關閉。如果這個控制元件滑動開了,也就記錄這個控制元件status ==SlideView.OnSlideViewOnListener.SLIDE_STATUS_ON:
this.mLastSlideViewWithStatusOn = (SlideView) view;
在來看看實現點選事件的程式碼:
@Override public void onClick(View v) { switch (v.getId()) { case R.id.delete: { int position = this.mListView.getPositionForView(v); if (position != ListView.INVALID_POSITION) { this.mMessageItem.remove(position); this.adapter.notifyDataSetChanged(); } break; } case R.id.top: { int position = this.mListView.getPositionForView(v); if (position != ListView.INVALID_POSITION) { MessageItem item = this.mMessageItem.get(position); this.mMessageItem.remove(position); this.mMessageItem.add(0, item); this.adapter.notifyDataSetChanged(); } break; } default: break; } }
怎麼刪除item,因為這個程式碼沒有任何解釋,有必要說明一下。
為了獲取操作螢幕的時值,點選的是那個item的刪除按鈕,就必須獲取他的位置position,我們使用getPositionForView(),position for view,也就是位置根據View獲取。接著也是判斷獲取的position是不是有效的。然後將根據位置刪除這個item。mMessageItem:
private List<MessageItem> mMessageItem = new ArrayList<MessageItem>();
是資訊的集合。
然後更新ListView:
this.adapter.notifyDataSetChanged();
而置頂思考的思路如下:
獲取該item的位置,如上面程式碼所示,然後獲得具體的MessageItem,將集合該位置的MessageItem刪除,將具體的MessageItem插入到第一個位置。然後更新ListView如上所示。
置頂後的效果圖如下所示:
下面為初始化Activity:
public void initView() { this.mListView = (ListViewCompat) findViewById(R.id.myList); for (int i = 0; i < 20; i++) { MessageItem item = new MessageItem(); if (i < 10) { item.iconRes =imgRes[i]; item.title = name[i]; item.msg = author[i]; item.time = time[i]; } else { item.iconRes = imgRes[19-i]; item.title = name[19 - i]; item.msg = author[19 - i]; item.time = time[19 - i]; } this.mMessageItem.add(item); } this.adapter = new SlideAdapter(); this.mListView.setAdapter(this.adapter); this.mListView.setOnItemClickListener(this); }
成員變更如下:
private String[] name = {"吾國之教育病理", "穀物大腦", "中國曆代經濟變革得失", "了凡四訓", "耶路撒冷三千年", "如果這是宋史", "中國近代史", "羅馬人的故事", "光榮與夢想", "大秦帝國"}; private String[] author = {"鄭也夫", "戴維·珀爾馬特", "吳曉波", "袁了凡", "西蒙·蒙蒂菲奧裡", "高天流雲 ", "王奇生", "鹽野七生 ", "威廉·曼徹斯特 ", "孫皓暉"}; private String[] time = {"2013-10-1 ", " 2015-5-20", "2013-8-1 ", "2007", "2013年3月", "2008", "2013年7月22日", "2011 年12月", "2006年", "2012年5月"}; private int[] imgRes = {R.drawable.book1, R.drawable.book2, R.drawable.book3, R.drawable.book4, R.drawable.book5, R.drawable.book6, R.drawable.book7, R.drawable.book8, R.drawable.book9, R.drawable.book10}; private ListViewCompat mListView; private List<MessageItem> mMessageItem = new ArrayList<MessageItem>(); private SlideAdapter adapter; // 上次處於開啟狀態的SlideView private SlideView mLastSlideViewWithStatusOn;
activity_main.xml程式碼如下:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <com.example.liyuanjing.slidelistview.MyListView android:id="@+id/myList" android:layout_width="match_parent" android:layout_height="wrap_content"/> </LinearLayout>
本文章程式碼下載地址如下: