自定义控件——可拖拽排序的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();
    }
}

参考

开源项目DragSortListView:https://github.com/bauerca/drag-sort-listview
http://www.bkjia.com/Androidjc/995839.html
http://www.cnblogs.com/qianxudetianxia/archive/2011/06/12/2068761.html

猜你喜欢

转载自blog.csdn.net/a10615/article/details/51366459