您的位置:首页 > 其它

ListView的拖动和侧滑实现

2018-03-01 19:49 113 查看

前言

ListView在Android App中占有重要的地位,很多界面的展示都要借助于这个控件,虽然RecyclerView已经逐步取代它的地位,掌握它的一些基本使用技巧还是很有必要的,现在就来探究一下如何实现拖动ListView的条目和侧滑删除ListView中的数据。

Android DND实现拖动

Android从3.0引入的Drag&Drop框架,实现在界面中的拖拽效果,用户为需要拖拽的子视图设置传递的数据、拖拽产生的阴影对象和本地数据等参数,调用View.startDrag方法就可以拖动子视图。同时还要对需要接收拖动事件的子视图设置拖动回调函数,拖动回调方法里处理多种拖动事件,根据拖动事件做出反应。

拖动事件意义
DragEvent.ACTION_DRAG_STARTED目标对象接收到拖动子视图开始,这时如果返回true代表对拖动的子视图感兴趣,系统会将后续的事件返送过来,否则就是不感兴趣,系统就忽略当前拖拽监视者,只会回调一次
DragEvent.ACTION_DRAG_ENTEREDDRAG_STARTED返回true之后,拖动子视图进入当前监控子视图范围,只会回调一次
DragEvent.ACTION_DRAG_LOCATION拖动子视图进入后开始准备向监控子视图drop,目前还在不断移动定位中,可以回调多次
DragEvent.ACTION_DRAG_EXITED拖动子视图离开了当前监控子视图的范围,只会回调一次
DragEvent.ACTION_DROP用户将拖动的子视图drop到当前监控子视图,只会回调一次
DragEvent.ACTION_DRAG_ENDED用户松手后所有的其他事件都已发送处理完成,最后发送DRAG_ENDED事件
在ListView的拖动事件中,被拖动的子视图就是用户点击的子视图,监听的子视图是当前屏幕中所有的ListView展示的子视图,当然被拖动的子视图自己除外。通常的拖动操作都是经过LongClick触发,只需要在ListView的adapter中getView生成的View设置LongClick时间监听。

final View dragView = convertView;
convertView.setOnLongClickListener(new View.OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
// DND框架要求传递的数据
ClipData.Item item = new ClipData.Item(String.valueOf(position));
ClipData clipData = new ClipData("", new String[] {
ClipDescription.MIMETYPE_TEXT_PLAIN
}, item);

// 开始当前View的拖动操作,将当前拖动对象的position当作localState传递到拖动事件中
dragView.startDrag(clipData, new View.DragShadowBuilder(dragView), position, 0);
return true;
}
});


View.DragShadowBuilder对象会在当前的界面上绘制子视图的外形称作DragShadow,也就是拖动阴影对象,这个对象会被透明化,这个应该是底层操作的,无法在应用层修改。设置了拖动监听之后开始设置其他的可能和它交换的Drag监听事件。

convertView.setOnDragListener(new View.OnDragListener() {
@Override
public boolean onDrag(View v, DragEvent event) {
switch (event.getAction()) {
case DragEvent.ACTION_DRAG_STARTED:
Log.d(TAG, "target: action = ACTION_DRAG_STARTED, clipData = " + event.getClipData());
// 如果传递的数据类型正确,而且监听对象不是当前拖动的对象
// event.getLocalState()可以获取前面拖动对方放进去的localState的position
if (event.getClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) &&
Integer.parseInt(String.valueOf(event.getLocalState())) != position) {
return true;
}
break;
case DragEvent.ACTION_DRAG_ENTERED:
Log.d(TAG, "target: action = ACTION_DRAG_ENTERED, clipData = " + event.getClipData());
// 当进入当前监听对象,设置当前监听对象背景变色 dragView.setBackgroundColor(context.getResources().getColor(R.color.colorAccent));
return true;
case DragEvent.ACTION_DRAG_LOCATION:
// 正在当前监听对象内拖动,不必关心
Log.d(TAG, "target: action = ACTION_DRAG_LOCATION, clipData = " + event.getClipData());
return true;
case DragEvent.ACTION_DRAG_EXITED:
// 如果离开了当前监听对象那么恢复当前监听对象的背景色
Log.d(TAG, "target: action = ACTION_DRAG_EXITED, clipData = " + event.getClipData());
dragView.setBackgroundColor(Color.WHITE);
return true;
case DragEvent.ACTION_DROP:
Log.d(TAG, "target: action = ACTION_DROP, clipData = " + event.getClipData());
final int srcPosition = Integer.parseInt((String) event.getClipData().getItemAt(0).getText());
// 如果用户将拖动对象drop到了当前监听对象,
// 交换拖动对象和当前监听对象数据位置并且刷新
if (srcPosition == position) {
return true;
}
Collections.swap(users, srcPosition, position);
notifyDataSetChanged();
return true;
case DragEvent.ACTION_DRAG_ENDED:
Log.d(TAG, "target: action = ACTION_DRAG_ENDED, clipData = " + event.getClipData());
// 拖动完成,恢复当前监听对象的背景色 dragView.setBackgroundColor(Color.WHITE);
return true;
}
return false;
}
});


拖动实现效果如下图所示:



DND对于普通的拖动事件能够较好的支持,在ListView中使用这种方式有很多问题,比如无法控制拖动阴影的拖动位置,用户能够把它拖到屏幕的任何位置,这显然是不应该的,还有就是拖动阴影的透明化问题,有时候不希望有这个透明处理,除此之外用户在拖动阴影出现的时候后明显感觉到抖动了一下,这时因为阴影默认手指正好放在拖动阴影的正中间位置,用户要实现手指按下真实子视图位置和放在拖动阴影所在位置一致实现较难。

WindowManager实现拖动

前面的DND实现拖动有多种问题,这里就采用WindowManager来实现拖动阴影,这种拖动阴影完全在开发者的控制之下,这样就可以限定拖动位置、定制阴影效果,防止抖动问题的产生了。实现的原理很简单,就是直接为用户长按位置的子视图生成一副Bitmap图像,然后用ImageView展示这幅图像,WindowManager将这幅图像添加到屏幕当中去,并且为ListView拖动事件添加自定义逻辑实现拖动阴影随着用户手指移动。

// ListView初始化
private void init() {
windowManager = (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE);
// 使用GestureDetector监控用户的长按动作
detector = new GestureDetector(getContext(), new GestureAdapter() {
@Override
public void onLongPress(MotionEvent e) {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
isLongPressed = true;
View child = getChildAt(dragPos);

// 如果拖动阴影还没有定义
if (shadow == null) {
// 创建拖动阴影图片
shadow = Bitmap.createBitmap(child.getWidth(), child.getHeight(), Bitmap.Config.ARGB_8888);
// 将图片绑定到canvas对象
canvas = new Canvas(shadow);
// 创建加入到屏幕的ImageView对象
shadowView = new ImageView(getContext());
}

// 将长按处的view画到阴影图片上
child.draw(canvas);
shadowView.setImageBitmap(shadow);
int[] locations = new int[2];
child.getLocationInWindow(locations);

// 得到拖动view在屏幕上的位置,并且将拖动阴影添加到屏幕中
addShadowView(shadow, locations[0], locations[1]);
}
});
params = new WindowManager.LayoutParams();

// 监控ListView是否在滚动状态,当拖动阴影到达ListView最底部或者最高处需要滚动ListView
setOnScrollListener(new OnScrollListener() {
@Override
public void onScrollStateChanged(AbsListView view, int state) {
Log.d(TAG, "state " + state);
scrollState = state;
}

@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {

}
});
}

// 将用户的触摸操作发送到detector对象,用于判断是否在长按
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
detector.onTouchEvent(ev);
return super.dispatchTouchEvent(ev);
}


初始化完成之后查看移动阴影操作的方法实现。

// 向屏幕中添加拖动阴影
private void addShadowView(Bitmap shadow, int x, int y) {
params.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
params.width = shadow.getWidth();
params.height = shadow.getHeight();
params.format = PixelFormat.RGBA_8888;
params.gravity = Gravity.TOP | Gravity.LEFT;
params.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL |
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;
params.x = x;
params.y = y + CommonUtils.dp2px(10);
// WindowManager添加ImageView到屏幕上
windowManager.addView(shadowView, params);
}

private void updateShadowView(int dx, int dy) {
// 更新拖动阴影在屏幕上的位置,改变的位置来源于用户的拖动操作
params.x += dx;
params.y = Math.max(bound.top, params.y + dy);
windowManager.updateViewLayout(shadowView, params);
}

private void removeShadowView() {
// 当用户手指离开屏幕需要移除拖动阴影
try {
windowManager.removeView(shadowView);
} catch (Exception e) {
e.printStackTrace();
}
}


拖动阴影的操作已经完成,现在需要覆盖ListView的onTouchEvent处理方法,在ACTION_DOWN记录下用户的按下的位置,使用pointToPosition获取用户按下的position位置。如果监控到用户做了长按操作,那么这时ACTION_DOWN记录下的数据就是用户要拖动的数据位置。

case MotionEvent.ACTION_DOWN:
lastX = downX = x;
lastY = downY = y;
dataPos = pointToPosition(x, y);
dragPos = pointToPosition(x, y) - getFirstVisiblePosition();
break;


接下来在ACTION_MOVE中响应用户的拖动操作,不停的更新拖动阴影在屏幕上的位置。

case MotionEvent.ACTION_MOVE:
// 如果监控到用户长按动作
if (isLongPressed) {
// 更新拖动阴影的位置
updateShadowView(x - lastX, y - lastY);

// 判断当前情况下是否需要滚动ListView
if (scrollState != OnScrollListener.SCROLL_STATE_IDLE && !shouldScrollDown() && !shouldScrollUp()) {
stop();
}
lastX = x;
lastY = y;

// 更新界面中的元素的选中背景颜色
if (pointToPosition(x, y) == INVALID_POSITION) {
for (int i = 0, count = getChildCount(); i < count; i++) {
getChildAt(i).setBackgroundColor(Color.WHITE);
}
return true;
}
int pos = pointToPosition(x, y) - getFirstVisiblePosition();
if (testScroll() == NONE) {
for (int i = 0, count = getChildCount(); i < count; i++) {
if (pos != dragPos && i == pos) {
getChildAt(i).setBackgroundColor(getResources().getColor(R.color.colorAccent));
} else {
getChildAt(i).setBackgroundColor(Color.WHITE);
}
}
} else {
for (int i = 0, count = getChildCount(); i < count; i++) {
getChildAt(i).setBackgroundColor(Color.WHITE);
}
}

// 如果需要滚动那么就滚动
scrollList();
return true;
}
break;


当用户拖动阴影到最上方或这最下方,这时候ListView就需要将未展示的数据展示出来,判断最上方和最下方的代码如下:

// 获取ListView在屏幕中的展示位置
// bound变量里放的就是ListView的位置信息
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (bound == null) {
int[] locations = new int[2];
getLocationInWindow(locations);
bound = new Rect(locations[0], locations[1],
locations[0] + getWidth(), locations[1] + getHeight());
}
}

// 如果拖动阴影到达了ListView屏幕最上方而且第一个展示的View不是position==0或
// 者第一个展示的Viewposition==0但是View还有一部分被卷在上方
private boolean shouldScrollUp() {
return params.y <= bound.top && (getFirstVisiblePosition() != 0 ||
getFirstVisiblePosition() == 0 && getChildAt(0).getTop() != getPaddingTop());
}

// 如果拖动阴影到达了ListView在屏幕的最下方,而且最后一个可见对象不是最后的数据,
// 或者最后一个可见对象是最后一个数据但还有一部分被卷在下方
private boolean shouldScrollDown() {
return params.y >= bound.bottom - params.height && (getLastVisiblePosition() != getAdapter().getCount() - 1 ||
getLastVisiblePosition() == getAdapter().getCount() - 1 &&
getChildAt(getChildCount() - 1).getBottom() != getHeight() - getPaddingBottom());
}

// 滚动使用smoothScroll实现
private void scrollList() {
if (scrollState == OnScrollListener.SCROLL_STATE_IDLE) {
if (shouldScrollDown()) {
smoothScrollBy(bound.height(), bound.height() / 100 * 1000);
} else if (shouldScrollUp()) {
smoothScrollBy(-bound.height(), bound.height() / 100 * 1000);
}
}
}


上面提到的两种情况就是需要滚动的情况,加入在滚动过程中用户突然将拖动阴影向中间方向移动这时就应该直接停止移动。

// 立即停止ListView的滚动
public void stop() {
manualCancel = true;
dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(), SystemClock.uptimeMillis(),
MotionEvent.ACTION_CANCEL, 0, 0, 0));
}

// 检查到是人工停止了ListView滚动,需要把这个事件传递给ListView,
// 其他的ACTION_CANCE事件和ACTION_UP处理一致。
case MotionEvent.ACTION_CANCEL:
if (manualCancel) {
manualCancel = false;
super.onTouchEvent(ev);
return true;
}


当用户手指从屏幕上离开会触发ACTION_UP事件,这时如果用户的位置在某一个具体的View上,就需要交换这个View和拖动View之间的位置。

case MotionEvent.ACTION_UP:
// 移除拖动阴影
removeShadowView();
if (isLongPressed) {
isLongPressed = false;
int targetPos = pointToPosition(x, y);
if (targetPos == INVALID_POSITION) {
for (int i = 0, count = getChildCount(); i < count; i++) {
getChildAt(i).setBackgroundColor(Color.WHITE);
}
return true;
}
int pos = pointToPosition(x, y) - getFirstVisiblePosition();
getChildAt(pos).setBackgroundColor(Color.WHITE);
// 交换拖动和drop的View数据
if (targetPos != dataPos) {
Collections.swap(UserListAdapter.users, targetPos, dataPos);
((BaseAdapter) getAdapter()).notifyDataSetChanged();
}
return true;
}
break;


最终的实现效果如下图所示:



ViewDragHelper实现侧滑

前面已经完成了拖动效果,其实侧滑删除的效果在很多应用里都很常用,这个效果其实可以使用ViewDragHelper对象来实现。考虑到侧滑其实是把上方的View拖动并且露出下方的View,那么可以使用扩展的FrameLayout的来实现侧滑布局。在布局中包含上放布局、下方布局,上方布局就是用户在界面中看到的展示数据的布局,下方布局就是展示删除操作的布局。

<com.example.scroll.widget.SwipeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">

// 底部展示删除按钮的布局
<LinearLayout
android:id="@id/below_view"
android:orientation="horizontal"
android:layout_gravity="end"
android:gravity="center_vertical"
android:layout_width="wrap_content"
android:layout_height="match_parent">

<TextView
android:id="@+id/delete"
android:text="@string/delete"
android:gravity="center"
android:background="@color/colorAccent"
android:padding="20dp"
android:layout_width="wrap_content"
android:layout_height="match_parent" />

</LinearLayout>

// 展示用户数据的布局
<include layout="@layout/user_item"
android:layout_height="wrap_content"
android:layout_width="match_parent" />

</com.example.scroll.widget.SwipeLayout>


然后用户在横向(x轴)拖动上方的布局,上方布局最多只能向左移动下方布局宽度的位置,无法向右移动,需要限制移动过程中的left数值,由于无法在竖向(y轴)拖动所以top始终都不应该改变。

public class SwipeLayout extends FrameLayout {
private ViewDragHelper helper;
private ViewGroup aboveView;
private ViewGroup belowView;

public SwipeLayout(@NonNull Context context) {
this(context, null);
}

public SwipeLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public SwipeLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}

// 初始画上方和下方布局
@Override
protected void onFinishInflate() {
super.onFinishInflate();
aboveView = (ViewGroup) findViewById(R.id.above_view);
belowView = (ViewGroup) findViewById(R.id.below_view);
}

private void init() {
helper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(View child, int pointerId) {
// 只允许上方布局拖动
return child == aboveView;
}

@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
// 如果上方布局拖动超出一半就全部露出下方布局,
// 否则还原到初始状态
if (Math.abs(aboveView.getLeft()) < belowView.getWidth() / 2) {
helper.smoothSlideViewTo(aboveView, 0, aboveView.getTop());
} else {
helper.smoothSlideViewTo(aboveView, -belowView.getWidth(), aboveView.getTop());
}
invalidate();
}

@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
// 限制横向拖动的位置坐标
if (left < -belowView.getWidth()) {
return -belowView.getWidth();
}

if (left > 0) {
return 0;
}

return left;
}

@Override
public int clampViewPositionVertical(View child, int top, int dy) {
// 限制竖向拖动的位置坐标
return aboveView.getTop();
}
});
}

@Override
public void computeScroll() {
super.computeScroll();
if (helper != null && helper.continueSettling(true)) {
invalidate();
}
}

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return helper.shouldInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
helper.processTouchEvent(event);
return true;
}
}


最终实现效果如下图所示:



查看所有实现的代码请点击查看源码
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息