自定义View——神之ViewDragHelper实现ListView滑动删除

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/JadynAi/article/details/51031348

之前有段时间在自学研究自定义View,刚好那时候需要做一个项目,有用到ListView滑动删除的功能。趁着这段时间有空,就把这个Demo分享出来。

  • 先看看实际的效果吧

Gif动图

因为Gif录制软件的关系,鼠标漏掉了,大家将就看哈。要是有什么好的录制软件,也可以推荐给我。

先说说思路:

思路这个东西其实很重要,有的时候只记代码是没有用的。在做某些功能之前,你首先要很清楚的一点是,就是要使用什么样的技术来实现这种功能。


1、 这种滑动删除的功能,大家迎头碰上,大概一下子就会想到自定义View这方面。毕竟效果在那摆着,滑动的同时还要拉出一个删除组件。
2.、首先明确思路,这个ListView的条目Item是一个自定义组件来的。除此之外,还要能够向左滑动,那么肯定还要实现OnTouch方法。

那么,首先我们大致确定了思路方向,就着手码代码吧。
首先,我们要自定一个一个View,就命名为SweepView吧,而且SweepView要继承的是ViewGroup,而不是View。

很明显,这个条目内是包含了两个组件。故此要继承ViewGroup
先奉上这个LsitView的Item所需的xml文件

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    >
    <com.jadyn.list.SweepView
        android:id="@+id/sweep_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <include layout="@layout/view_left"/>
        <include layout="@layout/view_right"/>
    </com.jadyn.list.SweepView>
</FrameLayout>
  • 在这个xml文件里,我们使用了自定义SweepView,并且内部还包含了它的两个子组件。在SweepView的外层我包裹了一层FrameLayout,节省资源。

这里再简述一下View的绘制流程,一个View要显示在屏幕之上,需经过三个方法。onMeasure:测量宽高、onLayout:布局组件、onDraw:绘制组件。
而继承了ViewGroup的自定义组件在测量自己前,首先要测量自己内部的子组件。若是子组件有自己特定的宽或者高的话,那么还要通过getlayoutParams的方法获得布局参数,并makeMeasureSpec来制造MeasureSpec类型的值。
在测量完子组件以及自己后,还要在OnLayout方法内,布局子组件。这时必经的两步路,而继承了ViewGroup的自定义View一般不需要实现OnDraw方法。

  • 在这里需要特殊说明一点,一个View只有在经过measure方法之后,才能使用getMeasuredXXX()方法。
  • 一个View只有在经过layout()方法过后,才能使用自己的getWidth()或者getHeight()方法。因为getWidth()方法是通过视图右边的距离减去左边的距离来赋值的。但View还没布局,又怎么能得到值。所以在layout方法之前使用getWidth和getHeight会得到0。

第二步,在完成了将SweepView显示在屏幕上之后,接下来我们要完成的功能就是滑动了。但这次我们不使用OnTouch事件内部判断。而是使用Android为我们提供的一个类——称之为神器的ViewDragHelper。

ViewDragHelper,是用来分析手势处理的类,主要用来处理拖动。其实大家经常使用的SlidingPaneLayout和DrawerLayout的内部,都使用了ViewDragHelper来处理拖动。

  • ViewDragHelper的使用很“简单”,使用静态方法创建一个实例就行了。mDragHelper = ViewDragHelper.create(this, new DragCallBack());请千万千万注意,这里的第一个参数this,指的就是自定义SweepView的对象,但这个this只能继承自ViewGroup哦!
  • 然后在onTouchEvent事件中,将TouchEvent事件传递到ViewDragHelper来处理就行了。请注意,这里的onTouchEvent一定要返回true。否则自定义View只会响应Down事件,而不会响应MOVE和UP事件。
  • 天真的小伙伴们肯定现在已经高兴坏了,难道就真的这么简单。呵呵,肯定不是,ViewDragHelper是简单,因为复杂的代码都实现在了new DragCallBack()内部了。这个类继承ViewDragHelper.Callback。然后接下来,我们将详细介绍此类。

ViewDragHelper.Callback主要有四个重要的方法:
一、boolean tryCaptureView,捕捉子组件。返回true,就代表着接下来的这个方法能处理滑动事件。否则就不会处理。
二、clampViewPositionHorizontal[Vertical]:处理水平或者数值方向的滑动事件,一般在这个方法内部做子组件的边界处理,就是确保子组件不会滑过界。
三、onViewPositionChanged:当前拖动的子组件位置变化时,调用的方法。一般用来处理子组件间的联动,就是效果图中拖拽左侧组件,右侧组件跟着出来的效果。
四、onViewReleased:当手指释放的时候会调用的方法。在这个方法里实现松开时的平滑效果。
这里写图片描述
在这里面会使用Veiw的invalidate方法,而这个方法会刷新View并会回调computeScroll方法。在此方法内,必须添加这样一段逻辑,否则平滑不会实现的。因为平滑是一个持续的过程,不是一个瞬时的过程。当开始smooth平滑时,我们调用invalidate刷新一次。但还需要在平滑的这段时间内,保持一个实时的刷新。这里的ViewCompat.postInvalidateOnAnimation(this);就相当于invalidate()方法,不过起到了一个兼容各大厂商的作用

if (mDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this);
        }

接下来,就把整个SweepView的全部逻辑代码贴上来,内含有详细的注释绝对easy:

public class SweepView extends ViewGroup {

    private View mChildContent;//内容组件
    private View mChildDel;//删除组件
    private ViewDragHelper mDragHelper;

    private boolean isOpen;//是否打开着

    public SweepView(Context context, AttributeSet attrs) {
        super(context, attrs);

        //第一个参数必须为ViewGroup的对象
        mDragHelper = ViewDragHelper.create(this, new DragCallBack());
    }

    /**
     * @return 对外提供的判断此组件是否打开
     */
    public boolean isOpen() {
        return isOpen;
    }

    class DragCallBack extends ViewDragHelper.Callback {

        private int mCriticalX;//临界x坐标

        /*以删除组件为准的打开和关闭的x坐标*/
        private int mOpenX;
        private int mCloseX;

        /*捕捉ViewGroup内的子组件,参数child为子组件对象。
                        * 返回true就会回调clampViewPositionXXX()方法。
                        * 
                        * 也可针对某个child,返回child==xxx【子组件】就行了*/
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return true;
        }

        /*处理拖动事件,第二个参数left指的就是当前拖动的child的x坐标,dx就是改变的值。
        * 
        * 在这个方法里可对拖动进行边界限制
        * */
        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            int minLeft = 0;
            int maxLeft = 0;

            //限制内容组件的边界,无非就是限制此组件的x坐标,以屏幕左上角为原点
            if (child == mChildContent) {
                maxLeft = 0;//内容组件不能再向右滑动了,最大x坐标只能为0
                minLeft = -mChildDel.getWidth();
            } else if (child == mChildDel) {
                //对删除组件的边界进行限制
                maxLeft = mChildContent.getWidth();
                minLeft = mChildContent.getWidth() - mChildDel.getWidth();
            }

            if (left < minLeft) {
                left = minLeft;
            } else if (left > maxLeft) {
                left = maxLeft;
            }

            return left;
        }

        /*当当前拖动的子组件位置发生改变时
        * 
        * 在这个方法里处理两个子组件的联动
        * 
        * 就是内容组件动了,带着删除组件也动
        * 
        * 此方法第二个参数就是当前拖动的子组件的最新的x坐标
        * */
        @Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
            //当拖动的是内容组件时,就对删除组件进行layout布局
            //
            if (changedView == mChildContent) {
                //对删除组件进行重新布局,左坐标和右坐标加上内容组件的最新x坐标就行了
                mChildDel.layout(mChildContent.getWidth() + left, 0, mChildContent.getWidth() +
                        mChildDel.getWidth() + left, mChildDel.getHeight());
            } else if (changedView == mChildDel) {
                //当移动的是删除组件的时候,对内容组件进行重新布局。
                mChildContent.layout(left - mChildContent.getWidth(), 0, left,
                        mChildContent.getHeight());
            }
        }

        /*当手指松开的时候
        * 
        * 根据当前位置将组件平稳的滑动
        * 
        * 后面两个参数指的是组件离开的速度
        * */
        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            //临界x坐标,判断当前x坐标是否越过它
            mCriticalX = mChildContent.getWidth() - mChildDel.getWidth() / 2;

            /*以删除组件为准的,打开坐标和关闭坐标*/
            mOpenX = mChildContent.getWidth() - mChildDel.getWidth();
            mCloseX = mChildContent.getWidth();

            if (releasedChild == mChildContent) {
                //不应该使用getX方法,应该返回内容组件的右边的实时坐标
                float visualX = mChildContent.getRight();

                smoothChild(visualX);

            } else if (releasedChild == mChildDel) {
                //移动删除组件,以左侧x坐标为准
                float visualX = mChildDel.getLeft();

                smoothChild(visualX);
            }

            invalidate();//触发重新绘制,那么就一定会触发那个compute回调方法
        }

        /**
         * 根据组件判断临界值,来实现打开和关闭的平滑移动
         * @param visualX 
         */
        private void smoothChild(float visualX) {
            if (visualX <= mCriticalX) {
                //打开
                mDragHelper.smoothSlideViewTo(mChildDel, mOpenX, 0);
                isOpen = true;
            } else {
                //关闭
                mDragHelper.smoothSlideViewTo(mChildDel, mCloseX, 0);
                isOpen = false;
            }
        }
    }


    @Override
    public void computeScroll() {
        super.computeScroll();
        if (mDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    /*A测量,下一步就是B布局*/
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        mChildContent = getChildAt(0);
        mChildDel = getChildAt(1);

        LayoutParams contPar = mChildContent.getLayoutParams();
        //父容器给内容组件指定大小
        int heighContentSpec = MeasureSpec.makeMeasureSpec(contPar.height, MeasureSpec.EXACTLY);
        //测量子组件,内容组件宽随父布局,高度自己定
        mChildContent.measure(widthMeasureSpec, heighContentSpec);

        LayoutParams delPar = mChildDel.getLayoutParams();
        //删除组件的宽和高都是自己定的
        int widDelSpec = MeasureSpec.makeMeasureSpec(delPar.width, MeasureSpec.EXACTLY);
        int heightDelSpec = MeasureSpec.makeMeasureSpec(delPar.height, MeasureSpec.EXACTLY);

        mChildDel.measure(widDelSpec, heightDelSpec);

        //父布局设置最终的宽和高,宽沿用自己测量自己的widthMeasureSpec,高度使用内容布局的高度
        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), contPar.height);
    }

    /*B布局子组件,上一步是A测量*/
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        mChildContent.layout(0, 0, mChildContent.getMeasuredWidth(),
                mChildContent.getMeasuredHeight());
        //内容组件在layout方法过后,就可以使用getWidth和getHeight了
        mChildDel.layout(mChildContent.getWidth(), 0, mChildContent.getMeasuredWidth() +
                mChildDel.getMeasuredWidth(), mChildDel.getMeasuredHeight());
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //将触摸事件传递到dragHelper来处理
        mDragHelper.processTouchEvent(event);
        return true;
    }
}

第三步、实现了LsitView的Item自定义View,接下来只需要实现删除的功能就行了。这个很简单,为了方便。这里的Activity我们就直接继承了ListActivity,就不用在布局中添加LsitView了。麻烦。

public class MainActivity extends ListActivity {

    private ListView mListView;
    private List<String> mDataList;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        mListView = getListView();//得到LsitView对象

        initData();//初始化数据源
    }

    private void initData() {
        mDataList = new ArrayList<>();

        for (int i = 0; i < 25; i++) {
            mDataList.add("哈哈哈" + i);
        }

        mListView.setAdapter(mBaseAdapter);
    }

    private BaseAdapter mBaseAdapter = new BaseAdapter() {

        @Override
        public View getView(int position, View convertView, ViewGroup parent) {
            ViewHolder vh = null;
            if (convertView == null) {
                convertView = LayoutInflater.from(MainActivity.this).inflate(R.layout.item_list, null);
                vh = new ViewHolder(convertView);
                convertView.setTag(vh);
            } else {
                vh = (ViewHolder) convertView.getTag();
            }

            /*如果此时是打开的状态才会触发重新布局
            * 
            * RequestLayout:
            当view确定自身已经不再适合现有的区域时,该view本身调用这个方法要求parent view
            重新调用他的onMeasure onLayout来对重新设置自己位置。

            特别的当view的layoutparameter发生改变,并且它的值还没能应用到view上,
            这时候适合调用这个方法。也就是当通过getLayoutParrms().width = XXX的时候,我们需要重新调用RequestLayout

            invalidate:View类调用迫使view重画。

            View.requestLayout() :请求重新布局
            View.invalidate() : 刷新视图,相当于调用View.onDraw()方法
            */
            if (vh.mSweep.isOpen()) {
                vh.mSweep.requestLayout();//触发控件重新布局,不加这一步,删除没反应
                Log.d("MainActivity", vh.mSweep.getLayoutParams().height+"");
            }

            final String data = mDataList.get(position);
            vh.mContent.setText(data);
            final ViewHolder testView = vh;
            vh.mDelete.setOnClickListener(new View.OnClickListener() {

                @Override
                public void onClick(View v) {
                    mDataList.remove(data);
                    Log.d("MainActivity", testView.mSweep.getLayoutParams().height+"");
                    notifyDataSetChanged();
                    Log.d("MainActivity", testView.mSweep.getLayoutParams().height+"");
                }
            });
            return convertView;
        }

        @Override
        public long getItemId(int position) {
            return position;
        }

        @Override
        public Object getItem(int position) {
            return mDataList.get(position);
        }

        @Override
        public int getCount() {
            return mDataList.size();
        }
    };

    private class ViewHolder {
        TextView mContent;
        TextView mDelete;
        SweepView mSweep;

        public ViewHolder(View root) {
            mContent = (TextView) root.findViewById(R.id.content);
            mDelete = (TextView) root.findViewById(R.id.delete);
            mSweep = (SweepView) root.findViewById(R.id.sweep_view);
        }
    }
}

在这里,在这里,在这里。重要的事情说三遍。最重要的一个逻辑,就是下面

 if (vh.mSweep.isOpen()) {
                vh.mSweep.requestLayout();//触发控件重新布局,不加这一步,删除没反应
            }

删除功能的话,就算使用了适配器的notifyDataSetChanged();方法也是没用的,因为这刷新的是LsitView,而不是LsitView的子组件。
所以这里要调用View的requestLayout方法,因为删除了这个条目,SweepView的LayoutParams肯定会变化,那么就需要重新绘制。并且我们在这里添加了一个判断,仅仅在SweepView显示打开的时候,才会调用requestLayout方法,去触发重新布局。

以上

猜你喜欢

转载自blog.csdn.net/JadynAi/article/details/51031348
今日推荐