主要效果图
使用RecyclerView配合ItemTouchHelper实现,性能更好、更流畅!
支持大数量item的情况(即 RecyclerView内容较多,可滑动的情况)
下载Demo
部分效果演示.gif仅供参考,实际使用建议使用类似BRVAH的库再封装下
主要功能
在普通模式下,长按“我的频道”的item,可以拖拽排序并进入编辑模式
在编辑模式下,触摸“我的频道”的item,可以直接拖拽排序
在任意模式下,点击“其他频道”的item,移动到“我的频道”,并伴随移动动画
在编辑模式下,点击“我的频道”的item,移动到“其他频道”,并伴随移动动画
实现思路
一、实现拖拽排序
3种方式
1、WindowManager
我在之前的项目中使用的方式,大致思路是:获取需要拖拽的View,生成镜像View,添加到WindowManager,移动时,通过Touch事件的X、Y坐标,利用windowManager的updateViewLayout方法更新位置。需要自己维护onInterceptTouchEvent、onTouchEvent,并且在拖拽的item移动的高度超过一屏时,需要手动控制RecyclerView(ListView/GridView)的滚动,较为繁琐。
2、View的startDrag方法配合setOnDragListener
这种方式不需要处理RecyclerView(ListView/GridView)的onInterceptTouchEvent和onTouchEvent,实现起来更方便一些,详情可以参考官方教程(Drag & Drop)。
3、使用RecyclerView包的ItemTouchHelper
Demo使用的方式。只能用RecyclerView实现,但是性能、功能都很强大,实现也非常简单,ItemTouchHelper处理好了关于在RecyclerView上添加拖动排序与滑动删除的所有事情。
通过ItemTouchHelper.Callback的onMove回调方法,对数组集合进行交换位置,并通过notifyItemMove方法刷新界面,RecyclerView默认的item动画为DefaultItemAnimator,它的notifyItemMove方法使范围内item有一个很自然的位移动画。
二、实现不同Grid的item之间移动(伴随位移动画)
不同Grid的item之间移动.png这部分是Demo中逻辑最复杂的部分,移动的同时还要排序,并且Demo考虑了内容特别多(RecyclerView可滑动)的情况下的移动。
Demo实现的思路是依靠notifyItemMove方法实现 需要移动的item 的后面各个item的移动效果,例如在上图中,即item4、item5向左方向的移动动画,但是并不会有下方item3向上方item3移动的位移动画,所以这里还需要使用位移动画实现该效果。
三、状态-普通模式、编辑模式
普通模式下,逻辑很简单,长按“我的频道”的item,可以拖拽排序并进入编辑模式;
编辑模式下,可以直接拖拽“我的频道”的item,同时保证点击事件可用以及不能影响RecyclerView的滑动,Demo的解决方式是,对item设置setTouchListener事件,当MOVE事件与DOWN事件的触发的间隔时间大于100ms时,则认为是拖拽starDrag,小于100ms不做任何处理,return false。
核心代码
拖拽排序:
使用 ItemTouchHelper 和 ItemTouchHelper.Callback
/**
* ItemDragHelperCallback
* Created by YoKeyword on 15/12/29.
*/
public class ItemDragHelperCallback extends ItemTouchHelper.Callback {
@Override
public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
int dragFlags;
RecyclerView.LayoutManager manager = recyclerView.getLayoutManager();
if (manager instanceof GridLayoutManager || manager instanceof StaggeredGridLayoutManager) {
dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
} else {
dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
}
// 如果想支持滑动(删除)操作, swipeFlags = ItemTouchHelper.START | ItemTouchHelper.END
int swipeFlags = 0;
return makeMovementFlags(dragFlags, swipeFlags);
}
@Override
public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
// 不同Type之间不可移动
if (viewHolder.getItemViewType() != target.getItemViewType()) {
return false;
}
if (recyclerView.getAdapter() instanceof OnItemMoveListener) {
OnItemMoveListener listener = ((OnItemMoveListener) recyclerView.getAdapter());
listener.onItemMove(viewHolder.getAdapterPosition(), target.getAdapterPosition());
}
return true;
}
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {}
@Override
public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) {
// 不在闲置状态
if (actionState != ItemTouchHelper.ACTION_STATE_IDLE) {
if (viewHolder instanceof OnDragVHListener) {
OnDragVHListener itemViewHolder = (OnDragVHListener) viewHolder;
itemViewHolder.onItemSelected();
}
}
super.onSelectedChanged(viewHolder, actionState);
}
@Override
public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
if (viewHolder instanceof OnDragVHListener) {
OnDragVHListener itemViewHolder = (OnDragVHListener) viewHolder;
itemViewHolder.onItemFinish();
}
super.clearView(recyclerView, viewHolder);
}
@Override
public boolean isLongPressDragEnabled() {
// 不需要长按拖拽功能 我们手动控制
return false;
}
@Override
public boolean isItemViewSwipeEnabled() {
// 不需要滑动功能
return false;
}
}
getMovementFlags()可以指定需要拖拽的方向;
isLongPressDragEnabled()如果返回true,则支持长按拖拽,该Demo中,“其他频道”等不需要拖拽,所以返回false,手动调用ItemTouchHelper的startDrag方法启动拖拽。
onMove()是在拖动到新位置时候的回调方法,我们在这里做数组集合的交换操作,在这里我们把它暴漏出去,交给Adapter自己处理;
一般来说,实现拖拽排序的写法为:
@Override
public void onItemMove(int fromPosition, int toPosition) {
String item = mItems.get(fromPosition);
mItems.remove(fromPosition);
mItems.add(toPosition , item);
notifyItemMoved(fromPosition, toPosition);
}
onSelectedChanged()方法和clearView()方法,分别在item被选中以及取消选中的时候调用,这里同样将它们以接口暴漏出去,在Adapter的ViewHolder里实现接口,让item在选中时高亮;
//我的频道
class MyViewHolder extends RecyclerView.ViewHolder implements OnDragVHListener {
private TextView textView;
private ImageView imgEdit;
public MyViewHolder(View itemView) {
super(itemView);
textView = (TextView) itemView.findViewById(R.id.tv);
imgEdit = (ImageView) itemView.findViewById(R.id.img_edit);
}
// item 被选中时
@Override
public void onItemSelected() {
textView.setBackgroundResource(R.drawable.bg_channel_p);
}
// item 取消选中时
@Override
public void onItemFinish() {
textView.setBackgroundResource(R.drawable.bg_channel);
}
}
最终在Activity中,调用:
ItemDragHelperCallback callback = new ItemDragHelperCallback();
ItemTouchHelper touchHelper = new ItemTouchHelper(callback);
touchHelper.attachToRecyclerView(recyclerView);
以上部分,可参照Demo1
不同Grid的item的移动(item的删除和添加)
Demo中仅仅使用一个RecyclerView实现,ViewType如下图所示:
RecyclerView的ViewType.png首先是需要移动的item的位移动画(即"不同Grid的item之间移动.png"图中的item3),因为item3向上方移动的动画以及item4、item5向左移动的动画是同时的,并且我们使用的notifyItemMove自带的动画,所以我们要在调用notifyItemMove()的同时,启动item3的位移动画。
所以我们需要制造一个item3的镜像ImageView,添加到recyclerView的父控件中,直接控制item3进行位移不会起作用,因为notifyItemMove的时候,RecyclerView处于动画和充绘界面中,item3并不受控制,并且因为RecyclerView的子控件的层级问题,当上方item向下方移动时,会被遮挡。
生成镜像ImageView代码如下:
/**
* 添加需要移动的 镜像View
*/
private ImageView addMirrorView(ViewGroup parent, RecyclerView recyclerView, View view) {
/**
* 我们要获取cache首先要通过setDrawingCacheEnable方法开启cache,然后再调用getDrawingCache方法就可以获得view的cache图片了。
buildDrawingCache方法可以不用调用,因为调用getDrawingCache方法时,若果cache没有建立,系统会自动调用buildDrawingCache方法生成cache。
若想更新cache, 必须要调用destoryDrawingCache方法把旧的cache销毁,才能建立新的。
当调用setDrawingCacheEnabled方法设置为false, 系统也会自动把原来的cache销毁。
*/
view.destroyDrawingCache();
view.setDrawingCacheEnabled(true);
final ImageView mirrorView = new ImageView(recyclerView.getContext());
Bitmap bitmap = Bitmap.createBitmap(view.getDrawingCache());
mirrorView.setImageBitmap(bitmap);
view.setDrawingCacheEnabled(false);
int[] locations = new int[2];
view.getLocationOnScreen(locations);
int[] parenLocations = new int[2];
recyclerView.getLocationOnScreen(parenLocations);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(bitmap.getWidth(), bitmap.getHeight());
params.setMargins(locations[0], locations[1] - parenLocations[1], 0, 0);
parent.addView(mirrorView, params);
return mirrorView;
}
最终,镜像ImageView启动位移动画的同时,调用notifyItemMove:
private void startAnimation(RecyclerView recyclerView, final View currentView, float targetX, float targetY) {
final ViewGroup viewGroup = (ViewGroup) recyclerView.getParent();
final ImageView mirrorView = addMirrorView(viewGroup, recyclerView, currentView);
Animation animation = getTranslateAnimator(
targetX - currentView.getLeft(), targetY - currentView.getTop());
currentView.setVisibility(View.INVISIBLE);
mirrorView.startAnimation(animation);
animation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
viewGroup.removeView(mirrorView);
if (currentView.getVisibility() == View.INVISIBLE) {
currentView.setVisibility(View.VISIBLE);
}
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
}
/**
* 获取位移动画
*/
private TranslateAnimation getTranslateAnimator(float targetX, float targetY) {
TranslateAnimation translateAnimation = new TranslateAnimation(
Animation.RELATIVE_TO_SELF, 0f,
Animation.ABSOLUTE, targetX,
Animation.RELATIVE_TO_SELF, 0f,
Animation.ABSOLUTE, targetY);
translateAnimation.setDuration(ANIM_TIME);
translateAnimation.setFillAfter(true);
return translateAnimation;
}
逻辑最复杂的部分来了:如何获取移动目标的位置?
比如:“我的频道”的item移动到"其他频道",这种情况比较简单,因为总是移动到“其他频道”的第一个item
正常情况下,可以这样获取:
View targetView = recyclerView.getLayoutManager().findViewByPosition(mMyChannelItems.size() + COUNT_PRE_OTHER_HEADER);
targetX = targetView.getLeft();
targetY = targetView.getTop();
但是当item足够多的时候,一屏幕不能容纳的时候,会有下面的情况:
这时,item x+4 向下移动的同时,RecyclerView同时会向下滚动,导致“其他频道”的内容向上移动,这时再使用上面的方式获取目标位置就不正确了,要这样获取:
// 移动后 高度将变化 (我的频道Grid 最后一个item在新的一行第一个)
if ((mMyChannelItems.size() - COUNT_PRE_MY_HEADER) % spanCount == 0) {
View preTargetView = recyclerView.getLayoutManager().findViewByPosition(mMyChannelItems.size() + COUNT_PRE_OTHER_HEADER - 1);
targetX = preTargetView.getLeft();
targetY = preTargetView.getTop();}
同样的道理,“其他频道”移动到“我的频道”一样的处理方式,不过细节更多、更复杂些,这里就不说明了,感兴趣的可以在文章最后查看源码。
编辑模式下的Touch事件传递
当MOVE事件与DOWN事件的触发的间隔时间大于100ms时,则认为是拖拽starDrag,小于100ms不做任何处理,return false。这样item的点击事件、RecyclerView的滚动事件都可以正常执行。
myHolder.textView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (isEditMode) {
switch (MotionEventCompat.getActionMasked(event)) {
case MotionEvent.ACTION_DOWN:
startTime = System.currentTimeMillis();
break;
case MotionEvent.ACTION_MOVE:
if (System.currentTimeMillis() - startTime > SPACE_TIME) {
mItemTouchHelper.startDrag(myHolder);
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
startTime = 0;
break;
}
}
return false;
}
});
总结
Demo里对于频道的排序和移动,要考虑的细节还是挺多的,但是理清好思路,解决起来并不是很困难。
从最终Demo效果来看,RecyclerView配合ItemTouchHelper的实现方式,确实比今日头条、网易新闻 性能更高效、动画更流畅。
网友评论
我是来砸场子的,谢谢作者文章,但是写法上还是太乱,由于这个原因我封装了一下RecyclerView的Adapter、Holder、监听器,重实现了一下博主demo。
谢谢博主文章,有些地方写得很不错
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<android.support.v7.widget.Toolbar
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<android.support.v7.widget.RecyclerView
android:id="@+id/recy"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false"/>
</LinearLayout>
demo中FrameLayout下只嵌套RecyclerView没有问题
mItemTouchHelper是继承ItemTouchHelper.Callback的类,不熟悉可以看下ItemTouchHelper相关的介绍
mItemTouchHelper是继承ItemTouchHelper.Callback的类,不熟悉可以看下ItemTouchHelper相关的介绍