ViewDragHelper 实现 ListView 左滑删除

来源:互联网 发布:巴西经济危机 知乎 编辑:程序博客网 时间:2024/04/30 07:22

前言

因为项目上需要实现收藏夹 ListView 左滑删除的需求,之前用的是第三方框架(SwipeListView)。最近抽空自己动手写个。实现左滑删除其实有很多种方法,ScrollTo+Scroller,HorizentalScrollView,动画等等。 本文就用 V4 包提供强大 View 助手类:ViewDragHelper 实现左滑删除。

示例代码,请参见:https://github.com/heshiweij/SweepDeleteListView

UI图(非最终效果图)

这里写图片描述

布局搭建

首先,照例 定义一个类继承 ViewGroup public class SweepView extends ViewGroup,实现构造方法,完成 onFinishInflate、onSizeChanged、onLayout、onMeasure、onDetachedFromWindow 等方法的空实现。

然后,定义如下布局:

activity_main.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"    android:layout_width="match_parent"    android:layout_height="match_parent" >    <com.example.customeview.sweepdelete.SweepView        android:id="@+id/sv_view"        android:layout_width="match_parent"        android:layout_height="80dip" >        <!-- 内容区域 -->        <include layout="@layout/layout_content" />        <!-- 删除区域 -->        <include layout="@layout/layout_delete" />    </com.example.customeview.sweepdelete.SweepView></RelativeLayout>

layout_content.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"    android:layout_width="match_parent"    android:layout_height="match_parent"    android:orientation="vertical" >    <TextView         android:id="@+id/tv_content"        android:layout_width="match_parent"        android:text="内容"        android:gravity="center"        android:background="#33000000"        android:textColor="@android:color/white"        android:textSize="25sp"        android:layout_height="80dip"/></LinearLayout>

layout_delete.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"    android:layout_width="120dip"    android:layout_height="match_parent"    android:orientation="vertical" >    <TextView        android:id="@+id/tv_delete"        android:layout_width="match_parent"        android:layout_height="80dip"        android:background="#ff0000"        android:gravity="center"        android:text="删除"        android:textColor="@android:color/white"        android:textSize="25sp" /></LinearLayout>

可以看到,我们暂时将滑动控件放在 activity_main,并且写死整体高度为 80dip,内容区域的宽度为 MATCH_PARENT,删除区域的宽度写死为 120dip。

初始化成员

/* 内容View */private View mContentView;/* 删除View */private View mDeleteView;/* 删除宽度 */private int mWidthDelete;/* 内容宽度 */private int mWidthContent;/* 统一高度 */private int mHeight;/* View助手对象 */private ViewDragHelper mHelper;/*当前状态*/private boolean isOpened;

这里强烈建议:在自定义 ViewGroup ,最好在 onSizeChanged 时,确定所有元素的宽高(不能确定的除外),对变量名进行统一,(onSizeChanged 必定在 onMeasure 方法之后执行)。如果不统一变量,后面计算时加加减减一多,容易搞混。所以在这里,mWidthDelete、mWidthContent,mHeight 分别表示删除区域宽、内容区域宽,统一的高度(内容和删除区域的高度一样,所以用一个统一高度表示)。

测量

由于本例中,View 数量确定,所以就可以直接进行测量。关于如何测量,请参考我的文章《自定义控件:onMeasure 方法和测量原理的理解》

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {    // 测量内容区域    mContentView.measure(widthMeasureSpec, heightMeasureSpec);    // 测量删除区域    int deleteWidthMeasureSpec = MeasureSpec.makeMeasureSpec(mWidthDelete, MeasureSpec.EXACTLY);    mDeleteView.measure(deleteWidthMeasureSpec, heightMeasureSpec);    // 确定自己的尺寸    int width = MeasureSpec.getSize(widthMeasureSpec);    int height = MeasureSpec.getSize(heightMeasureSpec);    setMeasuredDimension(width, height);}

布局

同样,由于 View 数量少,布局也很简单:让内容区域填充整个屏幕,让删除区域以固定大小位于屏幕左侧。

这里写图片描述

关于 onLayout 和 mContentView.layout() 中几个参数:

onLayout 中的 int l, int t, int r, int b,是当前 ViewGroup 相对其父 ViewGroup 的位置

mContentView.layout 中的 int l, int t, int r, int b,是内容区域相对于其父 ViewGroup(SweepView) 的位置

protected void onLayout(boolean changed, int l, int t, int r, int b) {    // 布局内容区域    mContentView.layout(0, 0, mWidthContent, mHeight);    // 布局删除区域    mDeleteView.layout(mWidthContent, 0, mWidthContent + mWidthDelete, mHeight);}

ViewDragHelper 的用法

简介

ViewDragHelper 是 v4 包提供简化 View 拖拽操作的帮助类,他不能直接作用在 View 上,而是监听 View 所在的 ViewGroup,实现 View 的滑动。ViewDragHelper 类似手势识别器(GestureDetector),会监听 ViewGroup 的 onTouchEvent。所以,ViewDragHelper 必须在 onTouchEvent 中注册才能使用。onTouchEvent 中的 ACTION_DOWN, ACTION_MOVE, ACTION_UP 事件,被 ViewDragHelper 封装成了方法,对应关系如下图:

ViewDragHelper 方法 onTouchEvent 事件 tryCaptureView ACTION_DOWN clampViewPositionHorizontal ACTION_MOVE onViewPositionChanged ACTION_MOVE(额外的回调) onViewReleased ACTION_UP

.

作用机制

手指在 ViewGroup 上拖动,当 ViewDragHelper 捕获到事件,交给
tryCaptureView,根据 tryCaptureView 的返回值,决定要不要继续监听。如果返回 true,则将“被拖动的对象child”,“本次拖动期望移动到什么位置”,“即将产生的偏移量”传给作为参数 clampViewPositionXXX ,根据 clampViewPositionXXX 的返回值决定真正移动多少距离。当移动发生时,还会调用 onViewPositionChanged ,以便做一些附加操作(例如,连带移动别的 View),当手指释放时,执行 onViewReleased。

使用步骤

1 . 创建对象

mHelper = ViewDragHelper.create(this, new SweepCallback());

2 . 注册托管

    public boolean onTouchEvent(MotionEvent event) {        mHelper.processTouchEvent(event);        // 这里必须返回 true,ViewDragHelper 才能正常监听        return true;    }

3 . 实现 Callback

class SweepCallback extends ViewDragHelper.Callback{/** * 当 Down 的时候调用 * 是否分析该 View 的touch *  * 参数 child :按在了哪个 View 上 *  * 返回值 * return false: 不去分析,任何效果没有 * return true: 分析,开始监听 MOVE UP 事件 */@Overridepublic boolean tryCaptureView(View child, int pointerId) {    // 这里表示要分析 mContentView 和 mDeleteView     return mContentView == child || mDeleteView == child;}/** * 当move的时候回调 *  * 参数: * child: tryCaptureView中分析的view * left: 左侧边距(期望值) * dx: 本次期望移动距离 *  * 返回值:确定要移动多少,返回后正式开始移动 */@Overridepublic int clampViewPositionHorizontal(View child, int left, int dx) {    // 定义真正的偏移量    int offset = 0;    if (child == mContentView){        if (left < -mWidthDelete){            offset = -mWidthDelete;        } else if (left > 0){            offset = 0;        } else {            offset = left;        }    } else if (child == mDeleteView){        if (left < mWidthContent - mWidthDelete){            offset = mWidthContent - mWidthDelete;        } else if (left > mWidthContent){            offset = mWidthContent;        } else {            offset = left;        }    }    return offset;}/** * 当view的位置改变时调用,也属于 move 的监听,但不参与移动,只是 move 事件的额外的监听 * 作用:带动另一个 view 的移动,其实也可以在 clampViewPositionHorizontal 实现 */@Overridepublic void onViewPositionChanged(View changedView, int left, int top,        int dx, int dy) {    // 当view位置改变,刷新界面    invalidate();    // 如果 changedView 是mContentView,则带动mDeleteView    if (changedView == mContentView){        // 改变mDeleteView的位置        mDeleteView.layout(mWidthContent + left, 0, mWidthContent+left+mWidthDelete, mHeight);    } else if (changedView == mDeleteView){        mContentView.layout(left-mWidthContent, 0, left, mHeight);    }}/** * 松开手的回调 * 参数: * releasedChild,你松开了哪个view * xvel:松开时x方向的速率 * yvel:松开时y方向的速率 */@Overridepublic void onViewReleased(View releasedChild, float xvel, float yvel) {    ScrollAnimation animation = null;    int left = releasedChild.getLeft();    if (releasedChild == mContentView){        if (left >= -mWidthDelete / 2){            // 内部开启了 Scroller 的startScroll,进行数据模拟,不断调用的是 computeScroll            mHelper.smoothSlideViewTo(releasedChild, 0, 0);            // 关闭            isOpened = false;        } else {            mHelper.smoothSlideViewTo(releasedChild, -mWidthDelete, 0);            // 打开            isOpened = true;        }    } else if (releasedChild == mDeleteView){        if (left >= mWidthContent - mWidthDelete / 2){            // 关闭            mHelper.smoothSlideViewTo(releasedChild, mWidthContent, 0);            isOpened = false;        } else {            mHelper.smoothSlideViewTo(releasedChild, mWidthContent - mWidthDelete, 0);            // 打开            isOpened = true;        }    }    // 注意:一定要实时刷新界面    invalidate();}

这里需要注意的是,tryCaptureView(View child, int pointerId)、 clampViewPositionHorizontal(View child, int left, int dx)、onViewPositionChanged(View changedView)等,第一个参数始终是用户手指接触的 view,响应的 left,dx等变量,也是属于当前 view 的边距、偏移量。

至此,实现完 callback, 内容区域和删除区域的基本可以被正常拖动了。

平滑移动

实现平滑移动的方式很多,但是这里不能用 ScrollTo 的方式,因为 ScrollTo,ScrollBy 改变的是 View 的显示方式,并不是真正的位置。而前面代码中,我们使用的 layout 是真正重绘 view 的位置,两者不兼容。使用 layout 移动布局, 获得的 getScrollX() 始终为 0。所以,此处应该用真正改变 View 绘制位置的方法来实现移动。

我们之前的代码,采用 ViewDragHelper.smoothSlideViewTo() 实现平滑。其实,smoothSlideViewTo 内部用的是 Scroller 计算虚拟的数据,并在 continueSettling 方法,通过 canvas 的重绘制实现的真正的移动。所以,在我们自己的 ViewGroup 的 computeScroll 中,只需要直接调用 mHelper.continueSettling(true) 即可。

mHelper.smoothSlideViewTo(releasedChild, mWidthContent - mWidthDelete, 0);
public void computeScroll() {    // mHelper 已经在continueSettling() 中帮我们做了移动,重绘了view的位置(这种改变位置的方法layout方法是兼容的)    if (mHelper.continueSettling(true)){        invalidate();        System.out.println(mContentView.getLeft());    }}

至此,我们已经实现了 SweepView 的滑出,边界处理,滑动一般自动返回等(相关细节不再描述,直接看上面 Callback 的代码即可)。

放入 ListView 中

下面,将写好是 SweepView 组件做成 ListView 的 item

item_sweep_delete.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"    android:layout_width="match_parent"    android:layout_height="match_parent" >    <com.example.customeview.sweepdelete.SweepView        android:id="@+id/sv_view"        android:layout_width="match_parent"        android:layout_height="80dip" >        <!-- 内容区域 -->        <include layout="@layout/layout_content" />        <!-- 删除区域 -->        <include layout="@layout/layout_delete" />    </com.example.customeview.sweepdelete.SweepView></RelativeLayout>

然后,在 Activity 中直接创建 ListView,并 setAdapter 即可。

细节处理

1 . 点击删除后,收拢关闭

点击删除按钮,发现删除区域还是展开,这是因为 notifyDataSetChanged() 方法虽然可以重新加载数据,但是并不会将界面上的 View 销毁,删除前和删除后,界面上的 SweepView 还是那几个。 收拢关闭的方法很多,有集合方式、广播方式。这里我就偷个懒,用广播做。

定义广播

private class CloseExpandReceiver extends BroadcastReceiver{    @Override    public void onReceive(Context context, Intent intent) {        System.out.println("我接收到广播了");        closeInternel();    }} 
private void closeInternel(){    // 如果打开状态, 重新布局(就是 onlayout 的初始布局)    if (isOpened){        // 偷个懒,用生命周期的 onLayout 方法        onLayout(false, 0, 0, 0, 0);        isOpened = false;        System.out.println("我已经关闭");    }}

注册广播

protected void onSizeChanged(int w, int h, int oldw, int oldh) {    super.onSizeChanged(w, h, oldw, oldh);    // 注册广播    IntentFilter filter = new IntentFilter(CLOSE_EXPAND_ACTION);    mReceiver = new CloseExpandReceiver();    getContext().registerReceiver(mReceiver, filter);    ...}

定义静态全局的关闭方法

public static void closeAll(Context context){    Intent intent = new Intent();    intent.setAction(SweepView.CLOSE_EXPAND_ACTION);    context.sendBroadcast(intent);}

最后别忘了,在 onDetachedFromWindow 取消注册

protected void onDetachedFromWindow() {    super.onDetachedFromWindow();    if (mReceiver != null){        getContext().unregisterReceiver(mReceiver);        System.out.println("取消注册广播");    }}

OK,在删除时,调用 SweepView.closeAll(this); 发个广播就全关闭了,偷懒的办法,非常的简单,简直完美!

2 . 滚动 ListView,关闭收拢

为了防止 ListView 的复用带来交互问题,我们干脆在滑动 ListView 时候就全部关闭

mList.setOnScrollListener(new OnScrollListener() {    @Override    public void onScrollStateChanged(AbsListView view, int scrollState) {        if (scrollState == OnScrollListener.SCROLL_STATE_FLING ){            // 关闭            SweepView.closeAll(SweepViewActivity.this);        }    }    /// 。。。});

至此,功能已经全部实现。

附录

遇到的坑

1 . ListView 的直接子元素的 android:layout_width 和android:layout_height 不管设成什么,均为为默认的 match_parent 和 wrap_content,所以不建议将 SweepView 作为 item 的根节点

2 . invalidate() 方法在部分手机上无效,可使用兼容写法:ViewCompat.postInvalidateOnAnimation(SweepView.this)

最终效果

这里写图片描述

示例代码,请参见:https://github.com/heshiweij/SweepDeleteListView

3 0