前言
今天说一个我们平时开发中经常用到的一个需求,那就是悬停效果,什么是悬停效果呢 直接看图:
就是RecyclerView分组时,当组头在顶部时需要悬浮,这个比如在选择地址、通讯录分组都有用到,在平时开发中大家一般都是直接用轮子,今天我们就来探究一下这个效果是如何实现的。
这个效果的实现,我看了好几个库的实现,其实原理都差不多,今天还是使用DslAdapter来说一下,主要是理解其原理,理解原理后改造轮子就很简单了。
正文
在DslAdapter中,悬停Item的实现类在HoverItemDecoration,首先它是继承至ItemDecoration,关于ItemDecoration在第一篇文章里有说:
[juejin.cn/post/700762… 开源库源码学习--DslAdapter的侧滑删除和拖拽功能)
这里还是再提一下,主要是3个方法,分别是设置间隔、在RecyclerView之前绘制和之后绘制,下面这张图比较好理解
看到这里,是不是大概能猜出这个悬停效果是怎么实现了呢,没错,就是利用这个onDrawOver函数,在RecyclerView上面再绘制一个悬停View,把原来的RecyclerView给遮盖住,达到看起来悬浮的效果。
直接看图:
然后向上滚动RecyclerView,这时分组1要被分组2给替换:
这里的需求就是要有一个分组1被顶掉的效果,同时悬停View只绘制一个即可,要了解什么时候分组2是RecyclerView的,什么时候是onDrawOver方法绘制的。
看懂原理,离实现就差一点点了,真的就一点点了。
onDrawOver
既然是通过onDrawOver来绘制的悬停View,那还是深入看一下这个函数:
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent,
@NonNull State state) {
onDrawOver(c, parent);
}
Draw any appropriate decorations into the Canvas supplied to the RecyclerView.
Any content drawn by this method will be drawn after the item views are drawn
and will thus appear over the views.
Params:
c – Canvas to draw into
parent – RecyclerView this ItemDecoration is drawing into
state – The current state of RecyclerView.
其中这个方法的回调时机特别多,只要itemView发生绘制或者滚动,这个方法都会回调,所以这就好办了,可以实时获取recyclerView的状态。
具体实现
根据上面思想,其实可以分为2部,第一步找到需要绘制的悬停View,第二步进行绘制悬停View,所以在onDrawOver方法里也是这2步:
//会调用很多次
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
Log.i(TAG, "onDrawOver: 在RecyclerView上绘制修饰")
if (state.isPreLayout || state.willRunSimpleAnimations()) {
return
}
//查找悬停View
checkHoverDecoration(parent)
//悬停的ViewHolder不为空,直接绘制
hoverViewHolder?.let {
if (!hoverDecorationRect.isEmpty) {
hoverCallback?.apply {
if (enableTouchEvent && enableDrawableState) {
addHoverView(it.itemView)
}
drawOverDecoration.invoke(canvas, paint, it, hoverDecorationRect)
}
}
}
}
但是这个过程十分复杂,容我慢慢说来。
1、获取RecyclerView显示出来的第一个ViewHolder。
通过方法:
//获取RecyclerView的第n个位置的ViewHolder
private fun firstChildViewHolder(parent: RecyclerView, childIndex: Int): RecyclerView.ViewHolder? {
if (parent.childCount > childIndex) {
return parent.findContainingViewHolder(parent.getChildAt(childIndex))
}
return null
}
//获取第一个ViewHolder
firstChildViewHolder(parent, 0)
注意,这里获取的是显示出来的第一个ViewHolder,而不是RecyclerView数据集中的第一个ViewHolder,即使在后面在RecyclerView上面绘制了悬浮View,也不影响,比如:
这里firstChildViewHolder的position就是0 -> 1 ->2,当然中间会回调很多次。
//表示第一个可见的ViewHolder的pos
firstChildAdapterPosition
2、判断第一个可见的ViewHolder是否设置了悬停,这里兵分2路,当它自己就设置了悬停,它自己就是组名或者是标题,这时它就是需要被悬停的ViewHolder:
假如第一个可见的ViewHolder没有设置悬停,这时需要向前查找,找到最近的一个悬停ViewHolder,也就是下图所示:
这2种情况都需要绘制同一个悬停ViewHolder,代码就不粘了,具体可以去看源库的HoverItemDecoration类,这里先直说思路。
3、通过第二步,就能获取需要悬停的ViewHolder,这时先不说绘制逻辑,后面再细说,假如分组1已经在悬停了,这里当继续向上滚动时会发生一个事,就是分组2会跑到分组1下面,再往上滑动,分组1要被顶上去,动图如:
这里第一个效果是往上滑动时,分组1要慢慢的消失,注意这里分组1现在是通过onDrawOver绘制在RecyclerView上的,而不是真正的RecyclerView中的Item,所以要自己处理逻辑和动画:
先找到下一个需要悬停的ViewHolder,假如这里每一个ViewHolder的高度都一样,那下一个需要悬停的View就是当前第一个可见的ViewHolder的下一个:
比如这里ViewHolder高度一样,当第一个可见是1时,发现下一个2是分组2,需要悬停,这时分组1就需要根据分组2的上移来向上移动,下一个悬停ViewHolder就是2,但是当ViewHolder高度不一样时,这就不一定了,比如:
这里1234比较窄,3是分组2,这时需要第一个可见还是1,但是下一个需要悬停的ViewHolder就是3了,而不是2,需要根据3的ViewHOlder的移动来上移分组1,所以这里的算法是根据绘制的高度来求出下一个悬停的ViewHolder,代码如下:
//查找下一个需要悬停的ViewHolder
fun findNextDecoration(
parent: RecyclerView,
adapter: RecyclerView.Adapter<*>,
decorationHeight: Int,
offsetIndex: Int = 1
): RecyclerView.ViewHolder? {
var result: RecyclerView.ViewHolder? = null
if (hoverCallback != null) {
val callback: HoverCallback = hoverCallback!!
//offsetIndex默认是1
val childIndex = findNextChildIndex(offsetIndex)
if (childIndex != RecyclerView.NO_POSITION) {
val childViewHolder = firstChildViewHolder(parent, childIndex)
if (childViewHolder != null) {
if (callback.haveHoverDecoration.invoke(
adapter,
childViewHolder.adapterPosition
)
) {
//如果下一个item 具有分割线
result = childViewHolder
} else {
//不具有分割线
if (childViewHolder.itemView.bottom < decorationHeight) {
//item的高度, 没有分割线那么高, 继续往下查找
result = findNextDecoration(
parent,
adapter,
decorationHeight,
offsetIndex + 1
)
} else {
}
}
}
}
}
return result
}
4、第三步中的gif图有上拉会被顶,然后下拉会把上一个分组给带出来,这里其实转一下思维,当下拉时,当前可见的第一个ViewHolder是悬停ViewHolder时,那它下一个悬停ViewHolder就是当前挨着的,所以一套代码即可处理。
5、通过上面步骤我们就能获取到需要悬停的View以及需要展示悬停View的Rect(当被顶时,这个会不断变化),我们就可以绘制ViewHolder了:
hoverViewHolder?.let {
if (!hoverDecorationRect.isEmpty) {
Log.i(TAG, "onDrawOver: 添加悬停的ViewHolder")
hoverCallback?.apply {
//是否可以点击
if (enableTouchEvent && enableDrawableState) {
addHoverView(it.itemView)
}
//绘制decoration
drawOverDecoration.invoke(canvas, paint, it, hoverDecorationRect)
}
}
}
Touch事件处理
通过上面的步骤,我们就可以绘制出悬浮在RecyclerView上的悬停View了,当然具体实现要考虑的细节比这多的多,还是那句话,这里只提供思路和方案,具体实现看代码。
上面到目前已经可以实现悬停效果了,但是有个问题,就是点击悬停View,因为这个虽然和RecyclerView的ViewHolder一样,但是它不是RecyclerView的ViewHolder,所以无法响应点击事件,如图:
会发现分组1是通过绘onDrawOver绘制出来的时候,就无法进行点击了,折叠和收起将不再起效果,所以要对触摸事件进行处理。
这里的处理还是通过addItemTouchListener来实现的,关于这个方法在本系列的第一篇文章里有仔细说明,可以查看,其实也就是在触摸事件传递给RecyclerView前给拦截和处理,代码如下:
addOnItemTouchListener(itemTouchListener)
//拦截逻辑
override fun onInterceptTouchEvent(
recyclerView: RecyclerView,
event: MotionEvent
): Boolean {
val action = event.actionMasked
if (action == MotionEvent.ACTION_DOWN) {
//Rect就有contains方法
isDownInHoverItem = hoverDecorationRect.contains(event.x.toInt(), event.y.toInt())
} else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
isDownInHoverItem = false
}
//当点击点在悬浮View上面时,拦截处理触摸事件
if (isDownInHoverItem) {
//L.i("onInterceptTouchEvent:$event")
Log.i(TAG, "onInterceptTouchEvent: $isDownInHoverItem 拦截Down事件")
onTouchEvent(recyclerView, event)
}
//当在悬浮item上时,处理触摸事件
return isDownInHoverItem
}
override fun onTouchEvent(recyclerView: RecyclerView, event: MotionEvent) {
Log.i(TAG, "onTouchEvent: eventAction = ${event.actionMasked}")
if (isDownInHoverItem) {
Log.i(TAG, "onTouchEvent: 处理触摸事件在HoverItem上")
hoverViewHolder?.apply {
if (hoverCallback?.enableDrawableState == true) {
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
Log.i(TAG, "onTouchEvent: down之后立马发出up")
recyclerView.postDelayed(cancelEvent, 160L)
} else {
recyclerView.removeCallbacks(cancelEvent)
}
//一定要调用dispatchTouchEvent, 否则ViewGroup里面的子View, 不会响应touchEvent
Log.i(TAG, "onTouchEvent: itemView自己分发事件")
itemView.dispatchTouchEvent(event)
if (itemView is ViewGroup) {
if ((itemView as ViewGroup).onInterceptTouchEvent(event)) {
itemView.onTouchEvent(event)
}
} else {
itemView.onTouchEvent(event)
}
} else {
Log.i(TAG, "onTouchEvent: 没有激活drawable点击效果")
//没有Drawable状态, 需要手动控制touch事件, 因为系统已经管理不了 itemView了
if (event.actionMasked == MotionEvent.ACTION_UP) {
Log.i(TAG, "onTouchEvent: itemView自己处理UP事件")
itemView.performClick()
}
}
}
}
}
这里其实很简单,就是当触摸事件在悬浮View的Rect范围内时,拦截并且处理点击事件,由于之前创建悬浮View时是通过创建ViewHolder来实现的,所以这里直接拿到itemView,进行分发事件即可。
总结
本章内容主要是介绍了悬停效果,平时用时以为蛮简单,看原理其中的实现还是挺复杂的,对于ItemDecoration的使用要理解到位,后面有其他需求也能快速实现。
这里只是分析原理,具体实现大家还是去看源码,细节地方很多,不再赘述。
还没有看过前面的文章可以跳转看一下,基本RecyclerView的几种常见操作都说了一遍: