在 RecyclerView
中,使用 ItemDecoration
来添加分割线或者侧边装饰条是十分常用的操作,可以实现分割线、装饰条的实现与列表元素解耦,和跨列表元素之间的装饰交互。
但是, ItemDecoration
的画面显示实现是需要开发者自己覆盖 onDraw
方法,在卡片之外自行将画面元素绘制上去。因其绘制过程与卡片本身没有关系,可以想象到的是,卡片在动画过程中不会自动地影响到 ItemDecoration
中所绘制的元素,需要开发者进行额外的适配工作。
贯穿分割线的绘制 ItemDecoration
最常见的一个用法是用来绘制卡片之间的贯穿型分割线。实现方法也非常简单,在需要绘制分割线的两张卡片之间,使用 getItemOffsets
方法留出足够的空间, onDraw
时在前一个卡片的 bottom
和后一个卡片的 top
之间绘制分割线即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 override fun getItemOffsets ( outRect: Rect , view: View , parent: RecyclerView , state: RecyclerView .State ) { super .getItemOffsets(outRect, view, parent, state) outRect.bottom = DIVIDER_HEIGHT } override fun onDraw (c: Canvas , parent: RecyclerView , state: RecyclerView .State ) { super .onDraw(c, parent, state) (0 until parent.childCount - 1 ).forEach { it -> val rect = RectF( 0f , it.bottom.toFloat(), parent.width, it.bottom.toFloat() + DIVIDER_HEIGHT ) c.drawRect(rect, DIVIDER_PAINT) } }
此实现在静态展现时还尚可,但是如果 RecyclerView
使用了默认的元素动画,在元素添加、删除等有位移动画时,分割线并不会随着动画一起移动,而是提前直接在位移结束位置上绘制出来,等着卡片元素到位。
RecyclerView 元素动画的实现 为了解决这个问题,首先需要知道元素动画是如何实现的。
在使用默认动画的情况下,添加、删除一些卡片后,其余没有发生变化,但是需要移动位置的卡片是通过 SimpleItemAnimator
里的 animatePersistence
进行的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Override public boolean animatePersistence (@NonNull RecyclerView.ViewHolder viewHolder, @NonNull ItemHolderInfo preInfo, @NonNull ItemHolderInfo postInfo) { if (preInfo.left != postInfo.left || preInfo.top != postInfo.top) { if (DEBUG) { Log.d(TAG, "PERSISTENT: " + viewHolder + " with view " + viewHolder.itemView); } return animateMove(viewHolder, preInfo.left, preInfo.top, postInfo.left, postInfo.top); } dispatchMoveFinished(viewHolder); return false ; }
其中调用的是 DefaultItemAnimator
里的 animateMove
方法,最终由 animateMoveImpl
执行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 @Override public boolean animateMove (final RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) { final View view = holder.itemView; fromX += (int ) holder.itemView.getTranslationX(); fromY += (int ) holder.itemView.getTranslationY(); resetAnimation(holder); int deltaX = toX - fromX; int deltaY = toY - fromY; if (deltaX == 0 && deltaY == 0 ) { dispatchMoveFinished(holder); return false ; } if (deltaX != 0 ) { view.setTranslationX(-deltaX); } if (deltaY != 0 ) { view.setTranslationY(-deltaY); } mPendingMoves.add(new MoveInfo (holder, fromX, fromY, toX, toY)); return true ; } void animateMoveImpl (final RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) { final View view = holder.itemView; final int deltaX = toX - fromX; final int deltaY = toY - fromY; if (deltaX != 0 ) { view.animate().translationX(0 ); } if (deltaY != 0 ) { view.animate().translationY(0 ); } final ViewPropertyAnimator animation = view.animate(); mMoveAnimations.add(holder); animation.setDuration(getMoveDuration()).setListener(new AnimatorListenerAdapter () { @Override public void onAnimationStart (Animator animator) { dispatchMoveStarting(holder); } @Override public void onAnimationCancel (Animator animator) { if (deltaX != 0 ) { view.setTranslationX(0 ); } if (deltaY != 0 ) { view.setTranslationY(0 ); } } @Override public void onAnimationEnd (Animator animator) { animation.setListener(null ); dispatchMoveFinished(holder); mMoveAnimations.remove(holder); dispatchFinishedWhenDone(); } }).start(); }
可以看出来,位移动画的实现是通过修改其 translationX
translationY
属性执行的。修改 translation
实现位移也是动画实现的常用方案,那么只要在绘制分割线的时候将 translation
属性也纳入计算即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 override fun getItemOffsets ( outRect: Rect , view: View , parent: RecyclerView , state: RecyclerView .State ) { super .getItemOffsets(outRect, view, parent, state) outRect.bottom = DIVIDER_HEIGHT } override fun onDraw (c: Canvas , parent: RecyclerView , state: RecyclerView .State ) { super .onDraw(c, parent, state) (0 until parent.childCount - 1 ).forEach { it -> val rect = RectF( 0f , it.bottom.toFloat() + it.translationY, parent.width, it.bottom.toFloat() + DIVIDER_HEIGHT + it.translationY ) c.drawRect(rect, DIVIDER_PAINT) } }
此时,分割线便能在列表元素进行位移动画过程中跟随一起移动了。
侧边装饰条的实现 在项目中,使用 ItemDecoration
实现列表的侧边装饰条也是很常见的操作。例如在卡片侧面绘制一条竖线,用来显示时间线效果。在接下来的讨论中,我们简单考虑这样一个需求:
数据结构中带有颜色字段,需要在卡片左侧绘制对应颜色的方块;
方块的绘制需要使用 ItemDecoration
实现;
方块需要跟随卡片进行位移、显隐动画。
简单实现 基于上一环节的成果,很快就能写出这样一段代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 data class Data ( val content: String, @ColorInt val color: Int ) { override fun toString () : String { return "Data(content=$content , color=#${String.format("%06x" , color)} " } } class SideDecoration (context: Context, private val dataGetter: ((Int ) -> Data?)) : RecyclerView.ItemDecoration() { private val paint = Paint() private val padding = context.resources.getDimensionPixelSize(R.dimen.item_side_padding) override fun onDraw (c: Canvas , parent: RecyclerView , state: RecyclerView .State ) { super .onDraw(c, parent, state) parent.children.map { it to parent.getChildAdapterPosition(it) }.map { (view, index) -> val data = dataGetter(index) Log.d("SideDecoration" , "getting data to draw of index $index " ) view to data }.map { (view, data ) -> RectF( 0f , view.top + view.translationY, padding.toFloat(), view.bottom + view.translationY ) to data }.forEach { (rect, data ) -> paint.color = data ?.color ?: return @forEach c.drawRect(rect, paint) } } override fun getItemOffsets ( outRect: Rect , view: View , parent: RecyclerView , state: RecyclerView .State ) { super .getItemOffsets(outRect, view, parent, state) val index = parent.getChildAdapterPosition(view) Log.d("SideDecoration" , "getting offset of index $index " ) val data = dataGetter(index) data ?.let { outRect.left = padding } } } class YourFragment : Fragment () { ... override fun onViewCreated (view: View , s: Bundle ?) { ... recycler.addItemDecoration(SideDecoration(view.context, dataList::getOrNull)) ... } ... }
然而,运行起来后却会发现,如果进行元素移除操作,被删除元素的方块会在动画开始之前消失,整体效果比贯穿分割线没有动画还更难看。
问题定位 首先需要来确定,为什么在删除动画开始之前,这个元素的装饰绘制会失效。
梳理一下事件发生顺序:
在数据列表中,删除一个数据;
将删除完数据的列表更新到 Adapter
中;
RecyclerView
响应数据变化,开始执行动画;
ItemDecoration
在动画过程中不停绘制方块;
根据 view
的位置 getChildAdapterPosition
,通过 dataGetter
在列表中获取数据;
根据获取到的数据判断方块信息;
梳理完顺序,应该很快就能发现问题: 在步骤 4.1 中,dataGetter
其实是不可能拿到正确的元素的,只可能有两种情况:
getChildAdapterPosition
返回了一个不合法的位置,使得 dataGetter
返回了空数据;
getChildAdapterPosition
返回了原来的位置,使得 dataGetter
返回了不属于这个卡片的数据。
具体情况,可以使用log大法来判断:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 class SideDecoration (context: Context, private val dataGetter: ((Int ) -> Data?)) : RecyclerView.ItemDecoration() { private val paint = Paint() private val padding = context.resources.getDimensionPixelSize(R.dimen.item_side_padding) override fun onDraw (c: Canvas , parent: RecyclerView , state: RecyclerView .State ) { super .onDraw(c, parent, state) parent.children.map { it to parent.getChildAdapterPosition(it) }.map { (view, index) -> val data = dataGetter(index) Log.d("SideDecoration" , "getting data to draw of index $index " ) if (data == null ) { Log.w( "SideDecoration" , "Data null!! view data ${view.findViewById<TextView>(R.id.title).text} " ) } view to data }.map { (view, data ) -> RectF( 0f , view.top + view.translationY, padding.toFloat(), view.bottom + view.translationY ) to data }.forEach { (rect, data ) -> paint.color = data ?.color ?: return @forEach c.drawRect(rect, paint) } } override fun getItemOffsets ( outRect: Rect , view: View , parent: RecyclerView , state: RecyclerView .State ) { super .getItemOffsets(outRect, view, parent, state) val index = parent.getChildAdapterPosition(view) Log.d("SideDecoration" , "getting offset of index $index " ) val data = dataGetter(index) if (data == null ) { Log.w( "SideDecoration" , "Data null!! view data ${view.findViewById<TextView>(R.id.title).text} " ) } data ?.let { outRect.left = padding } } }
运行后观察log输出:
D/MainViewModel: Removing data Data(content=db1c87, color=#ff0000ff at 1
D/SideDecoration: getting offset of index -1
W/SideDecoration: Data null!! view data db1c87
…
D/SideDecoration: getting data to draw of index -1
…
W/SideDecoration: Data null!! view data db1c87
…
可以看出,运行过程中出现了大量 getChildAdapterPosition
返回 -1 的情况,对应的 view 正是被删除数据所绑定的 view 。
查看 getChildAdapterPosition
的源代码,发现其最终都是调用到 androidx.recyclerview.widget.RecyclerView.LayoutParams#getViewAdapterPosition
,其注释如下:
Returns the up-to-date adapter position that the view this LayoutParams is attached to corresponds to. @return the up-to-date adapter position this view. It may return {@link RecyclerView#NO_POSITION} if item represented by this View has been removed or its up-to-date position cannot be calculated.
那么,当数据被删除,对应 ViewHolder
被调用 remove 之后,显然就是返回 RecyclerView#NO_POSITION
,即 -1
了。
从另一个角度考虑, ViewHolder
动画是发生在数据删除之后的, dataGetter
本身也不可能在新的数据列表之中找到一个被删除的数据给 ItemDecoration
进行方块绘制。
实现替换 以上问题的根源在于,数据被移除之后不能找到这个被删除数据提供给 ItemDecoration
进行方块绘制,那么要考虑的解决思路就是如何将 ItemDecoration
所需要的数据传递进去。
回到绘制方法 override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State)
中,我们在这里能得到的只有绘制用的画布、 RecyclerView
本身,和他相关联的 state
状态;进一步扩展的话,还能拿到 RecyclerView
当前能展现的子View。
在这里,我们仍然可以考虑将相关的数据存储在 Fragment
或者 ViewModel
中。但是保存已经被删除的数据并不是一个优雅的操作:
并且我们也不知道什么时候可以真正地把他们释放;
ItemDecoration
也没有合适的 key 去准确获取到对应的数据。
因此,还是需要另外寻找存放位置。
考虑到侧边方块是附属于每一个卡片元素的,RecyclerView
本身,和他相关联的 state
状态并不适合存储这些卡片紧密相关的数据,那么一个合理的存储位置就应当是 RecyclerView
当前能展现的子View。
那么如何在一个 View
中存储绘制相关数据呢?一个简单朴素的方法就是使用 android.view.View#setTag(int, java.lang.Object)
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 class SideTagDecoration (context: Context) : RecyclerView.ItemDecoration() { private val paint = Paint() private val padding = context.resources.getDimensionPixelSize(R.dimen.item_side_padding) override fun onDraw (c: Canvas , parent: RecyclerView , state: RecyclerView .State ) { super .onDraw(c, parent, state) parent.children.map { it to it.getTag(R.id.tag_color) as Int }.map { (view, color) -> RectF( 0f , view.top + view.translationY, padding.toFloat(), view.bottom + view.translationY ) to ColorUtils.setAlphaComponent(color, (view.alpha * 255 ).toInt()) }.forEach { (rect, color) -> paint.color = color c.drawRect(rect, paint) } } override fun getItemOffsets ( outRect: Rect , view: View , parent: RecyclerView , state: RecyclerView .State ) { super .getItemOffsets(outRect, view, parent, state) val content = view.getTag(R.id.tag_content) as String if (content.isNotBlank()) { outRect.left = padding } } }
在这个 ItemDecoration
的绘制过程中,我们读取了 ItemView
里设置的颜色信息,那么就需要在合适的地方设置进去,这里就需要 ViewHolder
的协助了:
1 2 3 4 5 6 7 8 9 10 11 class ViewHolder (parent: ViewGroup) : RecyclerView.ViewHolder( LayoutInflater.from(parent.context).inflate(R.layout.rv_item_layout, parent, false ) ) { private val bindings = RvItemLayoutBinding.bind(itemView) fun bind (data : Data ) { bindings.root.setTag(R.id.tag_color, data .color) bindings.root.setTag(R.id.tag_content, data .content) bindings.title.text = data .content } }
在这一套实现方案中,我们仅仅将 ItemDecoration
所需要的颜色数据存入 itemView
的 tag 数据中,保证了额外数据的最小化; ItemDecoration
也实现了获取已经被删除的数据(以及正常存在数据)的获取,还节省了一个 lambda 表达式的传入,有着还不错的表现。
总结 在平时的开发中,我其实挺少使用 View.setTag
方法,认为这个东西比较古老,还可能容易引起内存问题;但是在这个场景下,合理的使用 setTag
其实很能快速地解决我们的问题。
示例项目已经在 Github 上开源:Recyclerview-ani 项目地址 。
可以在侧边栏中分别进入普通实现版本 (Common Decoration
) 和 Tag 实现版本 (Tag Decoration
) ,点击 ADD
REMOVE
按钮观察其表现。