概述
想要实现类似朋友圈发布的图片拖动的功能,涉及到了复杂的移动判断逻辑。幸运的是Google已经帮我们提供了一个ItemTouchHelper
,可以帮助我们实现该复杂的功能。
本文将会以以下几步展开,其中会解析某些使用到的API的原理。
- ItemTouchHelper是什么
- 移动功能的实现
- 选中后放大 松手后缩小
- 删除功能的实现
- 修改移动事件触发的阈值
- 限制最后一个“+”不能移动
ItemTouchHelper是什么
官方的解释
This is a utility class to add swipe to dismiss and drag & drop support to RecyclerView.
It works with a RecyclerView and a Callback class, which configures what type of interactions are enabled and also receives events when user performs these actions.
简单的翻译一下就是
这是一个支持滑动删除和拖拽的工具类,配合RecyclerView使用,里面的Callback类来配置支持哪种交互类型,然后获取用户的交互事件进行处理。
他怎么做到的呢
我可以在源码中看到ItemTouchHelper
是继承于RecyclerView.ItemDecoration
的
然后看看它里面有一个这样的一个函数 ItemTouchHelper#setupCallbacks()
private void setupCallbacks() {
ViewConfiguration vc = ViewConfiguration.get(mRecyclerView.getContext());
mSlop = vc.getScaledTouchSlop();
//把自己作为一个ItemDecoration设置给RecyclerView
mRecyclerView.addItemDecoration(this);
//处理onTouchEvent和拦截TouchEvent
mRecyclerView.addOnItemTouchListener(mOnItemTouchListener);
mRecyclerView.addOnChildAttachStateChangeListener(this);
startGestureDetection();
}
然后看RecyclerView#onDraw(Canvas c)
@Override
public void onDraw(Canvas c) {
super.onDraw(c);
final int count = mItemDecorations.size();
//调用ItemDecoration的onDraw
for (int i = 0; i < count; i++) {
mItemDecorations.get(i).onDraw(c, this, mState);
}
}
然后看 OnItemTouchListener
怎么处理onTouchEvent
的 OnItemTouchListener#onTouchEvent()
````` 省略一些别的代码
switch (action) {
case MotionEvent.ACTION_MOVE: {
// Find the index of the active pointer and fetch its position
if (activePointerIndex >= 0) {
//设置dxdy
updateDxDy(event, mSelectedFlags, activePointerIndex);
//判断是否需要移动 里面回回调onMove
moveIfNecessary(viewHolder);
//这个主要处理拖动到边缘后移动recyclerview的 不是本期重点
mRecyclerView.removeCallbacks(mScrollRunnable);
mScrollRunnable.run();
//重绘recyclerview
mRecyclerView.invalidate();
}
break;
}
`````
recycerview
里面item的移动流程
接收到move事件后 RecyclerView.invalidate()
->RecyclerView.onDraw()
->ItemTouchHelper.onDraw()
->ItemTouchHelper.onChildDraw()
最后在onChildDraw()
将对应的Child进行移动。
对于view的选中逻辑这里就不展开讲了
moveIfNecessary(viewHolder);
这里是判断是否要移动item的 这里面的逻辑下面再介绍
移动功能的实现
ItemTouchHelper(
object : ItemTouchHelper.Callback() {
//判断是否可侧滑和拖拽
override fun getMovementFlags(recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder): Int {
//我们可以上下左右拖拽 所以把UP,DOWN,LEFT,RIGHT都或一下,因为我们不支持滑动 所以第二个参数返回传0
return makeMovementFlags(ItemTouchHelper.UP or
ItemTouchHelper.DOWN or
ItemTouchHelper.LEFT or
ItemTouchHelper.RIGHT, 0)
}
//item被拖拽到可移动的位置时会回调
override fun onMove(recyclerView: RecyclerView, viewHolder:
RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
//得到item原来的position
val fromPosition = viewHolder.adapterPosition
//得到目标position
val toPosition = target.adapterPosition
if (fromPosition == toPosition) return false
val list = adapter.list
//将list的fromPosition移动到toPosition 移动方法类似冒泡
var form = fromPosition
for (i in IntProgression.fromClosedRange(
fromPosition,
toPosition,
toPosition.compareTo(fromPosition)
)) {
val temp = list[form]
list[form] = list[i]
list[i] = temp
form = i
}
adapter.notifyItemMoved(fromPosition, toPosition)
//如果已经被移动到目的地了 返回true
return true
}
//item被滑动到可以删除时会回调
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
}
}
).attachToRecyclerView(recyclerView)
好了 这样我们就可以实现图片的移动了,但是还不完整
效果如下
选中后放大 松手后缩小
当item的选中状态发生变化时,会回调ItemTouchHelper.Callback#onSelectedChanged()
我们可以在接收到状态改变的回调后改变大小
//重写callback的onSelectedChanged
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
when (actionState) {
//选中后回调
ACTION_STATE_DRAG -> {
//保存当前拖拽的view
draggingView = viewHolder
draggingView?.itemView?.apply {
scaleX = 1.1f
scaleY = 1.1f
}
}
//松手后回调
ACTION_STATE_IDLE -> {
draggingView?.let {
draggingView!!.itemView.apply {
scaleX = 1f
scaleY = 1f
}
draggingView = null
}
}
}
}
效果如下
接下来还差item的删除
删除功能的实现
首先我们要在用户选中后显示删除区域,用户松手后隐藏删除区域
首先我们先定义两个动画,动画效果可以自定义
val showAnimation by lazy {
TranslateAnimation(
0f, 0f, deleteView.height.toFloat(), 0f
).apply {
fillAfter = true
duration = 200
setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation?) {
deleteView.visibility = View.VISIBLE
}
override fun onAnimationEnd(animation: Animation?) {
}
override fun onAnimationRepeat(animation: Animation?) {
}
})
}
}
val hideAnimation by lazy {
TranslateAnimation(
0f, 0f, 0f, deleteView.height.toFloat()
).apply {
fillAfter = true
duration = 200
setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation?) {
}
override fun onAnimationEnd(animation: Animation?) {
deleteView.visibility = View.INVISIBLE
}
override fun onAnimationRepeat(animation: Animation?) {
}
})
}
}
然后在onSelectedChanged()
里面开启动画就可以了
//重写callback的onSelectedChanged
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
when (actionState) {
//选中后回调
ACTION_STATE_DRAG -> {
//显示删除区域
deleteView.startAnimation(showAnimation)
//保存当前拖拽的view
draggingView = viewHolder
draggingView?.itemView?.apply {
scaleX = 1.1f
scaleY = 1.1f
}
}
//松手后回调
ACTION_STATE_IDLE -> {
//隐藏删除区域
deleteView.startAnimation(hideAnimation)
draggingView?.let {
draggingView!!.itemView.apply {
scaleX = 1f
scaleY = 1f
}
draggingView = null
}
}
}
}
效果如下
接下来就是要判断拖拽的item是不是到了删除区域。根据之前所说的我们知道 当recyclerview
的item被移动的时候 会调用ItemTouchHelper.onChildDraw()
里面也会调用Callback
的onChildDraw()
我们可以重写该方法,然后根据移动的xy来判断item是否移动到删除区域,然后进行下一步的处理(记得要调用一下父类的onChildDraw
里面有移动的逻辑 ),代码如下
//item被移动的时候回调
override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
val deleteViewLocations = IntArray(2)
deleteView.getLocationInWindow(deleteViewLocations)
//删除区域的顶部
val deleteViewTop = deleteViewLocations[1]
val dragViewLocations = IntArray(2)
viewHolder.itemView.getLocationInWindow(dragViewLocations)
//拖拽区域的底部 itemView的高度要*1.1是因为我们view被放大了所以我们高度也要一起变大
val dragViewBottom = dragViewLocations[1] + viewHolder.itemView.height * 1.1f
//判断拖拽区域的底部是否越过删除区域的顶部 保存是否在删除区域 当用户松手的时候我们可以用这个变量判断是否在删除区域松手
if (dragViewBottom > deleteViewTop) {
isDelete = true
deleteView.alpha = .5f
} else {
isDelete = false
deleteView.alpha = 1f
}
}
然后我们判断onSelectedChanged()
回调的状态是ACTION_STATE_IDLE的话,判断isDelete的状态 如果是true的话则代表是删除操作
改一下onSelectedChanged()
的代码
//松手后回调
ACTION_STATE_IDLE -> {
//隐藏删除区域
deleteView.startAnimation(hideAnimation)
draggingView?.let {
//是否在删除区域松手
if (isDelete) {
//这里需要把拖拽的view gone掉 不然还会显示在UI上
draggingView!!.itemView.visibility = View.GONE
val list = adapter.list
list.removeAt(draggingView!!.adapterPosition)
adapter.notifyItemRemoved(draggingView!!.adapterPosition)
} else {
draggingView!!.itemView.apply {
scaleX = 1f
scaleY = 1f
}
}
draggingView = null
}
}
最后删除的效果如下
修改移动事件触发的阈值
移动和删除都完成了,但是会发现拖拽的item需要越过替换目标的时候才会触发onMove
的回调,有没有办法让这个回调提前一点呢,比如移动到75%的时候就触发。
顺着Callback.onMove往上找,看有没有提前调用onMove,最后发现是在ItemtouchHelper#moveIfNecessary()
里面被调用的,源码如下
//上面介绍了 这个函数会在控件被拖动的时候onTouchEvent里面调用
void moveIfNecessary(ViewHolder viewHolder) {
if (mRecyclerView.isLayoutRequested()) {
return;
}
if (mActionState != ACTION_STATE_DRAG) {
return;
}
//0 获取认为是移动事件的阈值 默认为0.5可以重写来修改
final float threshold = mCallback.getMoveThreshold(viewHolder);
final int x = (int) (mSelectedStartX + mDx);
final int y = (int) (mSelectedStartY + mDy);
//如果view被拖动的距离没有超过阈值 则return
if (Math.abs(y - viewHolder.itemView.getTop()) < viewHolder.itemView.getHeight() * threshold
&& Math.abs(x - viewHolder.itemView.getLeft())
< viewHolder.itemView.getWidth() * threshold) {
return;
}
//1
List<ViewHolder> swapTargets = findSwapTargets(viewHolder);
if (swapTargets.size() == 0) {
return;
}
//2
ViewHolder target = mCallback.chooseDropTarget(viewHolder, swapTargets, x, y);
if (target == null) {
mSwapTargets.clear();
mDistances.clear();
return;
}
final int toPosition = target.getAdapterPosition();
final int fromPosition = viewHolder.getAdapterPosition();
//找到target后调用callback的onMove
if (mCallback.onMove(mRecyclerView, viewHolder, target)) {
// keep target visible
mCallback.onMoved(mRecyclerView, viewHolder, fromPosition,
target, toPosition, x, y);
}
}
-
//该函数是寻找与拖动控件覆盖的view 会将return的结果以覆盖区域大小从大到小排序 private List<ViewHolder> findSwapTargets(ViewHolder viewHolder) { if (mSwapTargets == null) { mSwapTargets = new ArrayList<>(); mDistances = new ArrayList<>(); } else { mSwapTargets.clear(); mDistances.clear(); } final int margin = mCallback.getBoundingBoxMargin(); final int left = Math.round(mSelectedStartX + mDx) - margin; final int top = Math.round(mSelectedStartY + mDy) - margin; final int right = left + viewHolder.itemView.getWidth() + 2 * margin; final int bottom = top + viewHolder.itemView.getHeight() + 2 * margin; final int centerX = (left + right) / 2; final int centerY = (top + bottom) / 2; final RecyclerView.LayoutManager lm = mRecyclerView.getLayoutManager(); final int childCount = lm.getChildCount(); //遍历RecyclerView里面的所有view for (int i = 0; i < childCount; i++) { View other = lm.getChildAt(i); if (other == viewHolder.itemView) { continue; //myself! } //判断这个view是否被拖动view覆盖了 if (other.getBottom() < top || other.getTop() > bottom || other.getRight() < left || other.getLeft() > right) { continue; } final ViewHolder otherVh = mRecyclerView.getChildViewHolder(other); //调用canDropOver来确定这个被覆盖的view是否能被替换(我们可以利用canDropOver这个回调来限制不能被移动的view) if (mCallback.canDropOver(mRecyclerView, mSelected, otherVh)) { // find the index to add final int dx = Math.abs(centerX - (other.getLeft() + other.getRight()) / 2); final int dy = Math.abs(centerY - (other.getTop() + other.getBottom()) / 2); final int dist = dx * dx + dy * dy; int pos = 0; final int cnt = mSwapTargets.size(); //寻找插入顺序,大的排前面 for (int j = 0; j < cnt; j++) { if (dist > mDistances.get(j)) { pos++; } else { break; } } //添加到返回结果里面 mSwapTargets.add(pos, otherVh); mDistances.add(pos, dist); } } return mSwapTargets; }
-
//该函数是从被覆盖的view(dropTargets)里面中选取一个可以被拖动view替换的ViewHolder,然后将其返回 public ViewHolder chooseDropTarget(@NonNull ViewHolder selected, @NonNull List<ViewHolder> dropTargets, int curX, int curY) { int right = curX + selected.itemView.getWidth(); int bottom = curY + selected.itemView.getHeight(); ViewHolder winner = null; int winnerScore = -1; //curX是拖动view的当前x轴的位置 -去left 的到的就是x轴偏移量 final int dx = curX - selected.itemView.getLeft(); //curY是拖动view的当前Y轴的位置 -去top 的到的就是y轴偏移量 final int dy = curY - selected.itemView.getTop(); final int targetsSize = dropTargets.size(); //遍历dropTargets 寻找最合适的替换目标 赋值给winner for (int i = 0; i < targetsSize; i++) { final ViewHolder target = dropTargets.get(i); if (dx > 0) {//向右移 int diff = target.itemView.getRight() - right; //diff小于0 代表着拖动的view右边界已经越过了覆盖view的右边界 if (diff < 0 && target.itemView.getRight() > selected.itemView.getRight()) { //score的作用是为了寻找一个越界程度最大的target 那个gerget就是最终的winner final int score = Math.abs(diff); if (score > winnerScore) { winnerScore = score; winner = target; } } } if (dx < 0) {//向左移 int diff = target.itemView.getLeft() - curX; //diff大于0 代表着拖动的view左边界已经越过了覆盖view的左边界 if (diff > 0 && target.itemView.getLeft() < selected.itemView.getLeft()) { final int score = Math.abs(diff); if (score > winnerScore) { winnerScore = score; winner = target; } } } if (dy < 0) {//向上移 int diff = target.itemView.getTop() - curY; //diff大于0 代表着拖动的view上边界已经越过了覆盖view的上边界 if (diff > 0 && target.itemView.getTop() < selected.itemView.getTop()) { final int score = Math.abs(diff); if (score > winnerScore) { winnerScore = score; winner = target; } } } if (dy > 0) {//向下移 int diff = target.itemView.getBottom() - bottom; //diff小于0 代表着拖动的view下边界已经越过了覆盖view的下边界 if (diff < 0 && target.itemView.getBottom() > selected.itemView.getBottom()) { final int score = Math.abs(diff); if (score > winnerScore) { winnerScore = score; winner = target; } } } } return winner; }
看了源码就很清晰了
控件拖动的时候会触发ItemtouchHelper#moveIfNecessary()
moveIfNecessary()
里面
- 判断控件拖动的距离是否超过设定的阈值
- 0通过后,获取所有与拖动控件叠加的view
- 获取到的view不为空的话,选取一个最适合的view返回
- 如果2能选取到合适的view的话 则将替换的view作为参数调用onMove
根据源码我们发现 我们可以重写第2步,chooseDropTarget
(因为之前是要整个越过才作为符合条件的view,不符合我们的预期)选取我们想要的targetview进行返回
重写代码如下,复制父类的代码 然后增加一个阈值 修改修改里面的diff
override fun chooseDropTarget(
selected: RecyclerView.ViewHolder,
dropTargets: MutableList<RecyclerView.ViewHolder>,
curX: Int,
curY: Int
): RecyclerView.ViewHolder? {
val right = curX + selected.itemView.width
val bottom = curY + selected.itemView.height
var winner: RecyclerView.ViewHolder? = null
var winnerScore = -1f
val dx = curX - selected.itemView.left
val dy = curY - selected.itemView.top
//这里是0.75f 这个值不能小于getMoveThreshold()返回的阈值 如果想要小于的话,可以重写getMoveThreshold 返回一个最小值就好了
val onMoveThreshold = 0.75f
dropTargets.forEach { target->
val ignoreWidth = target.itemView.width * (1 - onMoveThreshold)
val ignoreHeight = target.itemView.height * (1 - onMoveThreshold)
if (dx > 0) {
val diff = target.itemView.right - right - ignoreWidth
if (diff < 0 && target.itemView.right > selected.itemView.right) {
val score = Math.abs(diff)
if (score > winnerScore) {
winnerScore = score
winner = target
}
}
}
if (dx < 0) {
val diff = target.itemView.left - curX + ignoreWidth
if (diff > 0 && target.itemView.left < selected.itemView.left) {
val score = Math.abs(diff)
if (score > winnerScore) {
winnerScore = score
winner = target
}
}
}
if (dy < 0) {
val diff = target.itemView.top - curY + ignoreHeight
if (diff > 0 && target.itemView.top < selected.itemView.top) {
val score = Math.abs(diff)
if (score > winnerScore) {
winnerScore = score
winner = target
}
}
}
if (dy > 0) {
val diff = target.itemView.bottom - bottom - ignoreHeight
if (diff < 0 && target.itemView.bottom > selected.itemView.bottom) {
val score = Math.abs(diff)
if (score > winnerScore) {
winnerScore = score
winner = target
}
}
}
}
return winner
}
可以了 最后效果如下
限制最后一个“+”不能移动
我们在修改移动阈值的时候 查看源码发现canDropOver()
如果返回flase的话 则不会被当成可替换的目标
好了很开心 简简单单一行代码
override fun canDropOver(recyclerView: RecyclerView,
current: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder): Boolean {
//这里判断逻辑简单起见 我就直接判断文案是否为“+”了 实际上可以判断viewholder的类型或其他方法
return adapter.list[target.adapterPosition] != "+"
}
”+“的确不能被越过了,但是我们发现 虽然不能被越过 但是它还是能被拖拽的。。。。
怎么办呢,记不记得之前介绍的getMovementFlags
这个函数,这个函数是来判断viewholder支持的交互类型的
那么很好 我们在里面判断 viewhoder是“+”的话 返回不可移动就可以了
最终代码如下
//判断是否可侧滑和拖拽
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
return if (canMove(viewHolder)) {
makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, 0)
} else {
makeMovementFlags(0, 0)
}
}
override fun canDropOver(recyclerView: RecyclerView, current: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
return canMove(target)
}
private fun canMove(holder: RecyclerView.ViewHolder): Boolean {
return adapter.list[holder.adapterPosition] != "+"
}
运行一下 发现终于不能被拖动了。。
最终效果
好了 虽然其中一些细节和微信的还会有一些差异 但是大体上差不多
如果想进一步了解拖动view是怎么被选中的以及一些列的事件传递 可以参考这篇文章