RecyclerView 扩展(三) - 使用ItemTouch
最近楼主在忙碌于自己的毕设项目,在毕设当中需要实现一个滑动卡片的效果,楼主花了一点时间自己实现了一下,使用是ItemTouchHelper
和LayoutManager
方式实现的。我们先来看一下效果:
上面的效果说难也不难,说不难呢,但是这里面又有很多的小细节需要注意。
有人说,这动画很好做啊,使用
ViewPager
就可以实现了,这是没错的,但是ViewPager
一直有一个诟病--那就是View
的复用性不高。考虑到性能,RecyclerView
自然是当之无愧的王者,既然我们学过RecyclerView
,为什么不尝试着实现的呢?
1. 效果分析
看着这个动画麻烦,其实我们将它分为两个部分实现就非常简单了。首先,每个ItemView
是叠加样式展现的,这个效果在我们常用到的LayoutManger
没有这种样式,所以得需要我们自定义一个LayoutManager
来实现一个这种样式。这是其一。
其二,滑动切换的效果怎么实现呢?还记得我们之前分析过ItemTouchHelper
这个类吗?这个类的作用是用来实现侧滑删除以及长按拖动的效果的,而这里切换卡片的效果就相当于侧滑删除,只不过是侧滑时做的动画不一样。这里的动画主要包括卡片的位移和角度变化,而ItemTouchHelper
怎么实现根据手指滑动来做相应的动画呢?答案就在onChildDraw
方法里面。
其实,我们从ItemTouchHelper
的onChildDraw
方法里面就知道,原生只是做了水平位置的变化,所以,我们可以重写这个方法,从而加上我们想要的动画。
这样来分析,这个动画是不是非常简单呢?接下来,我们从看看代码吧。
2. LayoutManager
自定义LayoutManager
的相关知识,我在RecyclerView 源码分析(七) - 自定义LayoutManager及其相关组件的源码分析文章里面已经详细的解释了,这里我就不重复了。我们直接来看代码吧,关键代码在于onLayoutChildren
方法里面:
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
final int layoutCount = Math.min(getItemCount(), mMaxVisibleCount);
detachAndScrapAttachedViews(recycler);
for (int i = layoutCount - 1; i >= 0; i--) {
final View view = recycler.getViewForPosition(i);
addView(view);
measureChildWithMargins(view, 0, 0);
int widthSpace = getWidth() - getDecoratedMeasuredWidth(view);
int heightSpace = getHeight() - getDecoratedMeasuredHeight(view);
layoutDecoratedWithMargins(view, widthSpace / 2, heightSpace / 2,
widthSpace / 2 + getDecoratedMeasuredWidth(view),
heightSpace / 2 + getDecoratedMeasuredHeight(view));
// 给每个ItemView设置scale
view.setScaleX((float) Math.pow(DEFAULT_SCALE, i));
view.setScaleY((float) Math.pow(DEFAULT_SCALE, i));
if (i == 0) {
view.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
RecyclerView.ViewHolder childViewHolder = mRecyclerView.getChildViewHolder(v);
if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
// 这里需要手动告诉ItemTouchHelper可以侧滑
mItemTouchHelper.startSwipe(childViewHolder);
}
return false;
}
});
} else {
// 由于ItemView会复用,所以一定要设置null
view.setOnTouchListener(null);
}
}
}
相信上面的代码大家都能看的懂,这里我就不逐行的解释了。但是有一点需要我们特别注意:
for (int i = layoutCount - 1; i >= 0; i--) {
// ······
}
这里我们是倒着添加View
,也就是一个ItemView
虽然在RecyclerView
的内部index为0,但是在Adapter
中,却是layoutCount - 1
,这个在我们自定义ItemTouchHelper.Callback
时,会有很大的作用。
3.ItemTouchHelper.Callback
关于ItemTouchHelper
的知识,我在RecyclerView 扩展(二) - 手把手教你认识ItemTouchHelper文章里面已经详细的解释过了,所以在这里我也不重复了。我们直接来看实现代码,关键在onChildDraw
方法:
@Override
public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
// 跟着手指移动
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
final View itemView = viewHolder.itemView;
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
float ratio = dX / getThreshold(recyclerView, viewHolder);
if (ratio > 1) {
ratio = 1;
} else if (ratio < -1) {
ratio = -1;
}
// 跟着角度旋转
itemView.setRotation(ratio * 15);
for (int i = 0; i < mMaxVisibleCount - 1; i++) {
// 下面的ItemView跟着手指缩放
View child = recyclerView.getChildAt(i);
final float currentScale = (float) Math.pow(DEFAULT_SCALE, 2 - i);
final float nextScale = currentScale / DEFAULT_SCALE;
final float scale = (nextScale - currentScale);
child.setScaleX(Math.min(1, currentScale + scale * Math.abs(ratio)));
child.setScaleY(Math.min(1, currentScale + scale * Math.abs(ratio)));
}
}
}
上面代码的作用我在注释已经解释比较清楚了,这里就不解释了。不过这里还需要一点:
for (int i = 0; i < mMaxVisibleCount - 1; i++) {
// ······
}
这里我缩放的也是0 ~ mMaxVisibleCount - 1的ItemView
,请记住,这个不是ItemView
在Adapter
中的position
,而是ItemView
在RecyclerView
内部的index值。在前面的LayoutManager
中,我已经解释过,这俩是反着的。所以这里应该是0 ~ mMaxVisibleCount - 1。
整个实现就是这么的简单,其实还有坑没有说,比如说:
@Override
public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
super.clearView(recyclerView, viewHolder);
viewHolder.itemView.setRotation(0f);
}
在clearView
方法里面必须进行重置,因为ItemView
是复用的,不重置的话会出问题的。
在比如说,必须重写isItemViewSwipeEnabled
方法(虽然不重写也没有问题,但是官方文档建议重写):
@Override
public boolean isItemViewSwipeEnabled() {
return false;
}
4. 跟SwipeRefreshLayout事件冲突
使用上面代码来实现效果之后,我们会发现一个问题,如果将RecyclerView
放在SwipeRefreshLayout
内部,会出现事件冲突。
我简单的描述一下事件冲突的情况:当我们左右滑动时,这是正常的,每个ItemView
都是正常的侧换;但是一旦上下滑动时,正常来说应该是SwipeRefreshLayout
滑动,但是实际上还是ItemView
在侧滑。
关于解决方案的话,我有两种方案:1. 重写SwipeRefreshLayout
的onInterceptTouchEvent
方法,进行事件拦截,让事件不能传递到ItemView
中;2. 取消手动调用ItemTouchHelper
的startSwipe
方法,让ItemTouchHelper
自己来判断是否符合侧滑的条件。
这里,我特别的说明一下第一种方法。为什么要特别说明第一种方法呢?因为此方法有很大的问题:1. 会重写SwipeRefreshLayout
,这个造成了不必要的工作,这是其一;2. 重写了SwipeRefreshLayout
会破坏SwipeRefreshLayout
的结构,这个才是最大的缺点。
为什么重写SwipeRefreshLayout
会破坏它的结构呢?我们可以从SwipeRefreshLayout
的源码看出来,SwipeRefreshLayout
不会主动的拦截事件,因为SwipeRefreshLayout
是通过嵌套滑动机制来实现滑动,如果我们在onInterceptTouchEvent
方法里面进行事件拦截,就违背了SwipeRefreshLayout
的设计。所以,第一种方法是特别不推荐的!!!
其次,我们来看看第二种方案的实现方式,第二种方案非常简单,归根结底就是两句话:
- 在
Callback
里面不要重写isItemViewSwipeEnabled
方法,- 在
LayoutManager
里面不要在每个ItemView
的OnTouchListener
里面调用ItemTouchHelper
的startSwipe
方法。
我在这里简单的解释第二种方式为什么这样做就不会冲突了,不过要了解为什么不冲突,必须得了解以前为什么会冲突。
SwipeRefreshLayout
本身不会拦截事件,所以所有的事件都可以传递到RecyclerView
里面的每个ItemView
里面。因为我们在OnTouchListener
调用ItemTouchHelper
的startSwipe
表示选中了一个ItemView
可以侧滑,从而导致后面事件都会被该ItemView
消费,进而导致了事件冲突。
而取消startSwipe
方法的调用,让ItemTouchHelper
自己来选中一个可以侧滑的ItemView
,ItemTouchHelper
本身就处理了上下滑和左右滑的冲突的(如果没有处理,RecyclerView的上下滑跟ItemView的侧滑会冲突)。这就是第二种方式的原理。
5. 源码
为了方便大家的理解,我将自己的Demo代码上传到github,供大家参考:SlideCardDemo