自定義控制元件——可拖拽排序的ListView
前言
最經研究了一下拖拽排序的ListView,跟酷狗裡的播放列表排序一樣,但因為要新增自己特有的功能,所以研究了好長時間。一開始接觸的是GitHub的開源專案——DragSortListView,實現的效果和流暢度都很棒。想根據他的程式碼自己寫一個,但程式碼太多了,實現的好複雜,看別人的程式碼你懂的了,就去嘗試尋找其他辦法。最後還是找到了更簡單的實現方法,雖然跟開源專案比要差一點,但對我來說可以了,最重要的是完全可以自定義。
實現的效果如下:
主要問題
如何根據觸控的位置確定是哪個條目?
ListView有一個方法,可以根據ListView控制元件內的座標位置確定條目索引:int position = pointToPosition(int x, int y)
如何把此條目View的提取出來(我稱之為快照)?
ListView可通過getChildAt(int index)
來獲取子控制元件。但因為ListView內的條目View都要複用,所以此index不等於pointToPosition(x, y)
獲取的位置,要減去第一個可見條目的位置。即:View itemView = getChildAt(position - getFirstVisiblePosition());
獲取到View後,要把它變成一張照片(快照),View中有自帶的方法,可以把View的當前顯示的介面儲存為Bitmap圖片:
// 進行繪圖快取 itemView.setDrawingCacheEnabled(true); // 提取快取中的圖片 Bitmap bitmap = Bitmap.createBitmap(itemView.getDrawingCache());
如何懸浮在視窗上,並跟著手移動?
有了View的圖片,可通過ImageView顯示出來,但如何懸浮在視窗上?這裡需要使用WindowManager來顯示,並設定其引數WindowManager.LayoutParams。跟平常用程式碼在ViewGroup中新增View一樣。ImageView mDragPhotoView= new ImageView(getContext());
至於跟著手移動,手觸控的座標知道了,通過mWindowLayoutParams.y來設定y的座標,並更新到介面上:
mWindowLayoutParams.y = newY; mWindowManager.updateViewLayout(mDragPhotoView, mWindowLayoutParams);
條目超過ListView,如何滾動?
ListView有很多方法可以實現滾動:
smoothScrollBy(int distance, int duration)
實現緩慢移動;
setSelectionFromTop(int position, int y)
來設定指定條目距離頂部位置。
為了美觀,我這裡選擇了第一種方法以上4點就是最重要的,下面的主要是為了增加功能和提升使用者體驗
如何讓移動到的位置,不顯示條目,並與之前的位置進行交換?
不顯示條目,也就是不顯示View,但位置還得存在,這裡可以使用View的setVisibility()來實現:
setVisibility(View.INVISIBLE)
交換位置就是介面卡中的資料進行交換,我這裡自定義了一個BaseAdapter子抽象類,並在內部實現了調換位置的方法。當然也可以使用List的先刪除remove(int position)
,後新增add(int location, Object object)
的方法。public void swapData(int from, int to){ // mDragDatas是List例項物件 Collections.swap(mDragDatas, from, to); notifyDataSetChanged(); }
如何讓快照只能在ListView中的可視條目範圍內移動?
從此開始的問題,參考的資料中幾乎沒有,自己另外新增的功能,覺得能提升使用者體驗。快照必須跟條目一樣,在ListView控制元件範圍內,但快照的座標是針對螢幕的。在onTouchEvent()裡ev.getY()獲取的是觸控點在控制元件內的Y軸座標,ev.getRawY()獲取的是在螢幕內的Y軸座標點,so
mRawOffsetY = (int) (ev.getRawY() - mDownY);
就是ListView的左上角Y座標,也就是快照的Y軸的最小值。
ListView的
getHeight()
就能獲取底部高度,條目的總高度itemHeight是知道的(程式碼中,分割線的高度忘了加了,如果很小的話,不會有什麼影響)。Y軸的最大值就是:mRawOffsetY + getHeight() - mDragItemHeight;
但有一點,如果條目很少,都沒填充完ListView,怎麼辦?我們可以使用條目總高度*條目數量來確定所有條目的高度,與ListView的高度進行對比。這裡,我用條目高度+分割線高度的辦法來確定條目總高度。當然也可以使用一個條目的top到下一個條目的top距離來確定每個條目佔的總高度。
/** * 判斷ListView是否全部顯示,即ListView無法上下滾動了 */ private boolean isShowAll() { if (getChildCount() == 0) { return true; } View firstChild = getChildAt(0); int itemAllHeight = firstChild.getBottom() - firstChild.getTop() + getDividerHeight(); return itemAllHeight * getAdapter().getCount() < getHeight(); } ... // 根據是否顯示完全,設定快照在Y軸上可拖到的最大值 if (isShowAll()) { mMaxDragY = mRawOffsetY + getChildAt(getAdapter().getCount() - 1).getTop(); } else { mMaxDragY = mRawOffsetY + getHeight() - mDragItemHeight; }
如果條目很多,在拖拽時,有時需要快速滾動,有時需要慢速滾動,如何實現?
原理就是根據快照的位置距離上下邊緣的位置,如果距離小於一個條目的高度,開始滾動,越靠近邊緣滾動的越快。可通過設定smoothScrollBy(distance, duration)中的distance來達到調速的效果。設定一個在邊緣時滾動的最大值,剩下的就是按比例來計算了。百分比計算參考下面的”主要程式碼”(不會用標籤跳過去,知道的大俠麻煩告訴一聲,謝謝)// 如果當前位置已經不到一個條目,則進行上或下的滾動。並根據距離邊界的距離,設定滾動速度 int dragY = mMoveY - mItemOffsetY; if (dragY < mDragItemHeight) { int value = Math.max(0, dragY); // 防越界 float percent = estimatePercent(mDragItemHeight, 0, value); int distance = estimateInt(0, -mMaxDistance, percent); smoothScrollBy(distance, SMOOTH_SCROLL_DURATION); } else if (dragY > getHeight() - 2 * mDragItemHeight) { int value = Math.max(0, getHeight() - dragY - mDragItemHeight); // 防越界 float percent = estimatePercent(mDragItemHeight, 0, value); int distance = estimateInt(0, mMaxDistance, percent); smoothScrollBy(distance, SMOOTH_SCROLL_DURATION);
使用setVisibility(),把當前座標的條目隱藏時,會出現閃爍,如何解決?
在觸控下去的時候,被觸控的條目設定了隱藏,快照顯示出來前會有一段空白,導致閃爍的情況。個人覺得可能是快照還沒完全顯示出來。試了很多方法都不如意,最後決定還是用動畫的來去閃爍。// 隱藏。為了防止隱藏時出現畫面閃爍,使用動畫去除閃爍效果 Animation aAnim = new AlphaAnimation(1f, DRAG_PHOTO_VIEW_ALPHA); aAnim.setDuration(50); aAnim.setAnimationListener(new Animation.AnimationListener() { @Override public void onAnimationStart(Animation animation) { } @Override public void onAnimationEnd(Animation animation) { // Move中有隱藏的功能,如果按下後快速移動,會出現該顯示的又被隱藏了。所以要作判斷 if (mIsDraging && mToPosition == mDragPosition) { itemView.setVisibility(View.INVISIBLE); } } @Override public void onAnimationRepeat(Animation animation) { } }); itemView.startAnimation(aAnim);
主要程式碼
開源專案中發現老外的程式碼註釋很多,覺得還是很有必要的。上次自己寫了一個自定義控制元件,涉及到一些數學公式,幾個星期後要改進,結果自己都無法看懂了,最後使用了另外的方法去解決。
DragListView.java:
package com.zjun.draglistview;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.PixelFormat;
import android.support.annotation.FloatRange;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
import android.widget.AdapterView;
import android.widget.ImageView;
import android.widget.ListAdapter;
import android.widget.ListView;
/**
* 可拖拽排序ListView
* Created by Ralap on 2016/5/8.
*/
public class DragListView extends ListView {
private static final String LOG_TAG = "DragListView";
/**
* 拖拽快照的透明度(0.0f ~ 1.0f)。
*/
private static final float DRAG_PHOTO_VIEW_ALPHA = .8f;
/**
* 上下滾動時的時間
*/
private static final int SMOOTH_SCROLL_DURATION = 100;
/**
* 上下滾動時的最大距離,可進行設定
* @see #setMaxDistance(int)
* @see #getMaxDistance()
*/
private int mMaxDistance = 30;
/**
* 是否處於拖拽中
*/
private boolean mIsDraging;
/**
* 按下時的座標位置
*/
private int mDownX;
private int mDownY;
/**
* 移動時的座標
*/
private int mMoveX;
private int mMoveY;
/**
* 原生偏移量。也就是ListView的左上角相對於螢幕的位置
*/
private int mRawOffsetX;
private int mRawOffsetY;
/**
* 在條目中的位置
*/
private int mItemOffsetX;
private int mItemOffsetY;
/**
* 拖拽快照的垂直位置範圍。根據條目數量和ListView的高度來確定
*/
private int mMinDragY;
private int mMaxDragY;
/**
* 拖拽條目的高度
*/
private int mDragItemHeight;
/**
* 被拖拽的條目位置
*/
private int mDragPosition;
/**
* 移動前的條目位置
*/
private int mFromPosition;
/**
* 移動後的條目位置
*/
private int mToPosition;
/**
* 視窗管理器,用於顯示條目的快照
*/
private WindowManager mWindowManager;
/**
* 視窗管理的佈局引數
*/
private WindowManager.LayoutParams mWindowLayoutParams;
/**
* 拖拽條目的快照圖片
*/
private Bitmap mDragPhotoBitmap;
/**
* 正在拖拽的條目快照view
*/
private ImageView mDragPhotoView;
public DragListView(Context context) {
super(context);
}
public DragListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public DragListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
// 獲取第一個手指點的Action
int action = ev.getAction() & MotionEvent.ACTION_MASK;
switch (action) {
case MotionEvent.ACTION_DOWN:
mDownX = (int) ev.getX();
mDownY = (int) ev.getY();
// 獲取當前觸控位置對應的條目索引
mDragPosition = pointToPosition(mDownX, mDownY);
// 如果觸控的座標不在條目上,在分割線、或外部區域,則為無效值-1; 寬度3/4 以右的區域可拖拽
if (mDragPosition == AdapterView.INVALID_POSITION || mDownX < getWidth() * 3 / 4) {
return super.onTouchEvent(ev);
}
mIsDraging = true;
mToPosition = mFromPosition = mDragPosition;
mRawOffsetX = (int) (ev.getRawX() - mDownX);
mRawOffsetY = (int) (ev.getRawY() - mDownY);
// 開始拖拽的前期工作:展示item快照
startDrag();
break;
case MotionEvent.ACTION_MOVE:
mMoveX = (int) ev.getX();
mMoveY = (int) ev.getY();
if (mIsDraging) {
// 更新快照位置
updateDragView();
// 更新當前被替換的位置
updateItemView();
} else {
return super.onTouchEvent(ev);
}
break;
case MotionEvent.ACTION_UP:
if (mIsDraging) {
// 停止拖拽
stopDrag();
} else {
return super.onTouchEvent(ev);
}
break;
default:
break;
}
return true;
}
/**
* 開始拖拽
*/
private boolean startDrag() {
// 實際在ListView中的位置,因為涉及到條目的複用
final View itemView = getItemView(mDragPosition);
if (itemView == null) {
return false;
}
// 進行繪圖快取
itemView.setDrawingCacheEnabled(true);
// 提取快取中的圖片
mDragPhotoBitmap = Bitmap.createBitmap(itemView.getDrawingCache());
// 清除繪圖快取,否則複用的時候,會出現前一次的圖片。或使用銷燬destroyDrawingCache()
itemView.setDrawingCacheEnabled(false);
// 隱藏。為了防止隱藏時出現畫面閃爍,使用動畫去除閃爍效果
Animation aAnim = new AlphaAnimation(1f, DRAG_PHOTO_VIEW_ALPHA);
aAnim.setDuration(50);
aAnim.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
// Move中有隱藏的功能,如果按下後快速移動,會出現該顯示的又被隱藏了。所以要作判斷
if (mIsDraging && mToPosition == mDragPosition) {
itemView.setVisibility(View.INVISIBLE);
}
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
itemView.startAnimation(aAnim);
mItemOffsetX = mDownX - itemView.getLeft();
mItemOffsetY = mDownY - itemView.getTop();
mDragItemHeight = itemView.getHeight();
mMinDragY = mRawOffsetY;
// 根據是否顯示完全,設定快照在Y軸上可拖到的最大值
if (isShowAll()) {
mMaxDragY = mRawOffsetY + getChildAt(getAdapter().getCount() - 1).getTop();
} else {
mMaxDragY = mRawOffsetY + getHeight() - mDragItemHeight;
}
createDragPhotoView();
return true;
}
/**
* 判斷ListView是否全部顯示,即ListView無法上下滾動了
*/
private boolean isShowAll() {
if (getChildCount() == 0) {
return true;
}
View firstChild = getChildAt(0);
int itemAllHeight = firstChild.getBottom() - firstChild.getTop() + getDividerHeight();
return itemAllHeight * getAdapter().getCount() < getHeight();
}
/**
* 建立拖拽快照
*/
private void createDragPhotoView() {
// 獲取當前視窗管理器
mWindowManager = (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE);
// 建立佈局引數
mWindowLayoutParams = new WindowManager.LayoutParams();
mWindowLayoutParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
mWindowLayoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
mWindowLayoutParams.gravity = Gravity.TOP | Gravity.START;
mWindowLayoutParams.format = PixelFormat.TRANSLUCENT; // 期望的圖片為半透明效果,但設定其他值並沒有看到不一樣的效果
// 下面這些引數能夠幫助準確定位到選中項點選位置
mWindowLayoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
| WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;
mWindowLayoutParams.windowAnimations = 0; // 無動畫
mWindowLayoutParams.alpha = DRAG_PHOTO_VIEW_ALPHA; // 微透明
mWindowLayoutParams.x = mDownX + mRawOffsetX - mItemOffsetX;
mWindowLayoutParams.y = adjustDragY(mDownY + mRawOffsetY - mItemOffsetY);
mDragPhotoView = new ImageView(getContext());
mDragPhotoView.setImageBitmap(mDragPhotoBitmap);
mWindowManager.addView(mDragPhotoView, mWindowLayoutParams);
}
/**
* 校正Drag的值,不讓其越界
*/
private int adjustDragY(int y) {
if (y < mMinDragY) {
return mMinDragY;
} else if (y > mMaxDragY) {
return mMaxDragY;
}
return y;
}
/**
* 根據Adapter中的位置獲取對應ListView的條目
*/
private View getItemView(int position) {
if (position < 0 || position >= getAdapter().getCount()) {
return null;
}
int index = position - getFirstVisiblePosition();
return getChildAt(index);
}
/**
* 更新快照的位置
*/
private void updateDragView() {
if (mDragPhotoView != null) {
mWindowLayoutParams.y = adjustDragY(mMoveY + mRawOffsetY - mItemOffsetY);
mWindowManager.updateViewLayout(mDragPhotoView, mWindowLayoutParams);
}
}
/**
* 更新條目位置、顯示等
*/
private void updateItemView() {
int position = pointToPosition(mMoveX, mMoveY);
if (position != AdapterView.INVALID_POSITION) {
mToPosition = position;
}
// 調換位置,並把顯示進行調換
if (mFromPosition != mToPosition) {
if (exchangePosition()) {
View view = getItemView(mFromPosition);
if (view != null) {
view.setVisibility(View.VISIBLE);
}
view = getItemView(mToPosition);
if (view != null) {
view.setVisibility(View.INVISIBLE);
}
mFromPosition = mToPosition;
}
}
// 如果當前位置已經不到一個條目,則進行上或下的滾動。並根據距離邊界的距離,設定滾動速度
int dragY = mMoveY - mItemOffsetY;
if (dragY < mDragItemHeight) {
int value = Math.max(0, dragY); // 防越界
float percent = estimatePercent(mDragItemHeight, 0, value);
int distance = estimateInt(0, -mMaxDistance, percent);
smoothScrollBy(distance, SMOOTH_SCROLL_DURATION);
} else if (dragY > getHeight() - 2 * mDragItemHeight) {
int value = Math.max(0, getHeight() - dragY - mDragItemHeight); // 防越界
float percent = estimatePercent(mDragItemHeight, 0, value);
int distance = estimateInt(0, mMaxDistance, percent);
smoothScrollBy(distance, SMOOTH_SCROLL_DURATION);
}
}
/**
* 停止拖拽
*/
private void stopDrag() {
// 顯示座標上的條目
View view = getItemView(mToPosition);
if (view != null) {
view.setVisibility(View.VISIBLE);
}
// 移除快照
if (mDragPhotoView != null) {
mWindowManager.removeView(mDragPhotoView);
mDragPhotoView.setImageDrawable(null);
mDragPhotoBitmap.recycle();
mDragPhotoBitmap = null;
mDragPhotoView = null;
}
mIsDraging = false;
}
/**
* 調換位置
*/
private boolean exchangePosition() {
int itemCount = getAdapter().getCount();
if (mFromPosition >= 0 && mFromPosition < itemCount
&& mToPosition >= 0 && mToPosition < itemCount) {
getAdapter().swapData(mFromPosition, mToPosition);
return true;
}
return false;
}
/**
* 根據百分比,估算在指定範圍內的值
*/
public static int estimateInt(int start ,int end, @FloatRange(from = 0.0f, to = 1.0f) float percent) {
return (int) (start + percent * (end - start));
}
/**
* 估算給定值在指定範圍內的百分比
* @param start 始值
* @param end 末值
* @param value 要估算的值
* @return 0.0f ~ 1.0f。如果沒有指定範圍,或給定值不在範圍內則返回-1
*/
public static float estimatePercent(float start, float end, float value) {
if (start == end
|| (value < start && value < end)
|| (value > start && value > end)){
return -1;
}
return (value - start) / (end - start);
}
@Override
public void setAdapter(ListAdapter adapter) {
if (!(adapter instanceof DragListViewAdapter)){
throw new RuntimeException("請使用自帶的Adapter");
}
super.setAdapter(adapter);
}
@Override
public DragListViewAdapter getAdapter(){
return (DragListViewAdapter) super.getAdapter();
}
}
MainActivity.java
private void initView() {
dvl_drag_list = (DragListView) findViewById(R.id.dvl_drag_list);
tv_msg_drag_list = (TextView) findViewById(R.id.tv_msg_drag_list);
tv_msg_drag_list.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
int size = mDataList.size();
String dataMsg;
if (size == 0) {
dataMsg = "沒有資料了";
} else {
dataMsg = "資料大小:" + mDataList.size() + ", 最後一個:" + mDataList.get(mDataList.size() - 1);
}
tv_msg_drag_list.setText(dataMsg);
}
});
mListAdapter = new MyAdapter(this, mDataList);
dvl_drag_list.setAdapter(mListAdapter);
}
public class MyAdapter extends DragListViewAdapter<String> {
public MyAdapter(Context context, List<String> dataList) {
super(context, dataList);
}
@Override
public View getItemView(int position, View convertView, ViewGroup parent) {
ViewHolder viewHolder;
if (convertView == null) {
convertView = LayoutInflater.from(getApplicationContext()).inflate(R.layout.item_drag_list, parent, false);
viewHolder = new ViewHolder();
viewHolder.name = (TextView) convertView.findViewById(R.id.tv_name_drag_list);
viewHolder.desc = (TextView) convertView.findViewById(R.id.tv_desc_drag_list);
convertView.setTag(viewHolder);
}else{
viewHolder = (ViewHolder) convertView.getTag();
}
viewHolder.name.setText(mDragDatas.get(position));
String s = mDragDatas.get(position) + "的描述";
viewHolder.desc.setText(s);
return convertView;
}
class ViewHolder{
TextView name;
TextView desc;
}
}
DragListViewAdapter.java
public abstract class DragListViewAdapter<T> extends BaseAdapter{
public Context mContext;
public List<T> mDragDatas;
public DragListViewAdapter(Context context, List<T> dataList){
this.mContext = context;
this.mDragDatas = dataList;
}
@Override
public int getCount() {
return mDragDatas == null ? 0 : mDragDatas.size();
}
@Override
public T getItem(int position) {
return mDragDatas.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
return getItemView(position, convertView, parent);
}
public abstract View getItemView(int position, View convertView, ViewGroup parent);
public void swapData(int from, int to){
Collections.swap(mDragDatas, from, to);
notifyDataSetChanged();
}
public void deleteData(int position) {
mDragDatas.remove(position);
notifyDataSetChanged();
}
}