1. 程式人生 > >自定義控制元件——可拖拽排序的ListView

自定義控制元件——可拖拽排序的ListView

前言

最經研究了一下拖拽排序的ListView,跟酷狗裡的播放列表排序一樣,但因為要新增自己特有的功能,所以研究了好長時間。一開始接觸的是GitHub的開源專案——DragSortListView,實現的效果和流暢度都很棒。想根據他的程式碼自己寫一個,但程式碼太多了,實現的好複雜,看別人的程式碼你懂的了,就去嘗試尋找其他辦法。最後還是找到了更簡單的實現方法,雖然跟開源專案比要差一點,但對我來說可以了,最重要的是完全可以自定義。

實現的效果如下:
這裡寫圖片描述

主要問題

  1. 如何根據觸控的位置確定是哪個條目?
    ListView有一個方法,可以根據ListView控制元件內的座標位置確定條目索引:

    int position = pointToPosition(int x, int y)
  2. 如何把此條目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());
  3. 如何懸浮在視窗上,並跟著手移動?
    有了View的圖片,可通過ImageView顯示出來,但如何懸浮在視窗上?這裡需要使用WindowManager來顯示,並設定其引數WindowManager.LayoutParams。跟平常用程式碼在ViewGroup中新增View一樣。

    ImageView mDragPhotoView= new ImageView(getContext());
    mDragPhotoView.setImageBitmap(mDragPhotoBitmap); // 獲取當前視窗管理器 WindowManager mWindowManager= (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE); WindowManager.LayoutParams mWindowLayoutParams= new WindowManager.LayoutParams(); wm.addView(mDragPhotoView, mWindowLayoutParams);

    至於跟著手移動,手觸控的座標知道了,通過mWindowLayoutParams.y來設定y的座標,並更新到介面上:

    mWindowLayoutParams.y = newY;
    mWindowManager.updateViewLayout(mDragPhotoView, mWindowLayoutParams);
  4. 條目超過ListView,如何滾動?
    ListView有很多方法可以實現滾動:
    smoothScrollBy(int distance, int duration)實現緩慢移動;
    setSelectionFromTop(int position, int y)來設定指定條目距離頂部位置。
    為了美觀,我這裡選擇了第一種方法

    以上4點就是最重要的,下面的主要是為了增加功能和提升使用者體驗

  5. 如何讓移動到的位置,不顯示條目,並與之前的位置進行交換?
    不顯示條目,也就是不顯示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();
    }
  6. 如何讓快照只能在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;
    }
  7. 如果條目很多,在拖拽時,有時需要快速滾動,有時需要慢速滾動,如何實現?
    原理就是根據快照的位置距離上下邊緣的位置,如果距離小於一個條目的高度,開始滾動,越靠近邊緣滾動的越快。可通過設定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);
  8. 使用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();
    }
}

參考