如何改写ItemDecoration以支持RecyclerView的reverseLayout?
问题描述
我在GitHub上找到一款名为Wordphabet的应用,它可以将单词列表按首字母分类,滚动RecyclerView时首字母会与列表联动。我希望让该应用支持reverseLayout,实现类似Telegram评论区的滚动联动效果,但尝试后未能成功。以下是该应用的StickyLetterDecoration代码:
class StickyLetterDecoration( context: Context, words: List<String> = ListsProvider.WORDS ) : RecyclerView.ItemDecoration() { private val textPaint: TextPaint private val positionToInitialsMap: Map<Int, String> private val relativeDrawingPosition: Point<Float> private val itemPadding: Float init { val viewTextSize = context.resources.getDimensionPixelSize(R.dimen.word_text_size) positionToInitialsMap = ListsProvider.buildMapWithIndexes(words) textPaint = TextPaint(ANTI_ALIAS_FLAG).apply { alpha = 255 //完全可见 typeface = Typeface.create(Typeface.DEFAULT_BOLD, Typeface.ITALIC) //加粗斜体提升辨识度 textSize = viewTextSize * SCALING_FACTOR color = context.resources.getColor(R.color.initials_color) textAlign = Paint.Align.CENTER } val itemViewPadding = context.resources.getDimensionPixelOffset(R.dimen.word_padding) relativeDrawingPosition = Point(0f, 0f).apply { val rawPixelsMargin = context.resources.getDimensionPixelSize(R.dimen.word_left_margin) // 因为设置了textAlign = CENTER,x坐标是文本的中心位置 x = rawPixelsMargin / 2f // canvas.drawText的y参数是基线位置,不是中心,所以要计算基线偏移 val baseLine = (itemViewPadding * 2 + viewTextSize) / 2 + textPaint.textSize / 2 y = baseLine } itemPadding = itemViewPadding + (textPaint.textSize - viewTextSize)/2 } override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { var prevHeaderY = Float.MAX_VALUE var prevFoundPosition = NO_POSITION for (initialsPosition in (parent.size - 1) downTo 0) { // 反向遍历item,实现"推动"上方item的效果 val currentChild = parent.getChildAt(initialsPosition) if (childOutsideParent(currentChild, parent)) continue val childPosition: Int = parent.getChildAdapterPosition(currentChild) positionToInitialsMap[childPosition]?.let { // 当前item是分类首项,需要绘制首字母 val yDrawingPosition = (currentChild.top + currentChild.translationY + relativeDrawingPosition.y) .coerceAtLeast(relativeDrawingPosition.y) .coerceAtMost(prevHeaderY - relativeDrawingPosition.y - itemPadding) canvas.drawText(it, relativeDrawingPosition.x, yDrawingPosition, textPaint) prevFoundPosition = childPosition prevHeaderY = yDrawingPosition } } // 如果未找到可见的分类首项,说明当前屏幕显示的是两个分类之间的内容,需要找到对应的首字母 prevFoundPosition = if (prevFoundPosition != NO_POSITION) prevFoundPosition else parent.getChildAdapterPosition(parent[0]) + 1 for (initialsPosition in positionToInitialsMap.keys.reversed()) { if (initialsPosition < prevFoundPosition) { // 绘制当前分类的固定首字母 positionToInitialsMap[initialsPosition]?.let { val yDrawingPosition = (prevHeaderY - textPaint.textSize - itemPadding) .coerceAtMost(relativeDrawingPosition.y) canvas.drawText(it, relativeDrawingPosition.x, yDrawingPosition, textPaint) } break } } } /** * 判断item是否超出RecyclerView可视区域 */ private fun childOutsideParent(childView: View, parent: RecyclerView): Boolean { return childView.bottom < 0 || (childView.top + childView.translationY.toInt() > parent.height) } companion object { private const val SCALING_FACTOR = 1.5F private const val NO_POSITION = -1 fun buildMapWithIndexes(words: List<String> = ListsProvider.WORDS) = words .mapIndexed { index, string -> index to string[0].uppercase() } .distinctBy { it.second } .toMap() } }
适配reverseLayout的修改方案
反向布局(reverseLayout=true)下,RecyclerView的item从底部开始排列,滚动方向与正常布局相反,因此需要调整坐标计算、遍历逻辑和可视区域判断:
- 添加反向布局参数:给Decoration增加
isReverseLayout参数,用于区分布局方向 - 调整基线位置计算:反向布局下,首字母的基线需要基于item底部计算
- 修改item遍历顺序:从正向遍历替代原有的反向遍历,适配反向布局的item排列
- 更新可视区域判断逻辑:调整child是否超出屏幕的判断条件
- 重构Y坐标计算逻辑:基于item的bottom位置计算绘制坐标,适配反向滚动的"推动"效果
以下是修改后的完整代码:
class StickyLetterDecoration( context: Context, words: List<String> = ListsProvider.WORDS, private val isReverseLayout: Boolean = false ) : RecyclerView.ItemDecoration() { private val textPaint: TextPaint private val positionToInitialsMap: Map<Int, String> private val relativeDrawingPosition: Point<Float> private val itemPadding: Float init { val viewTextSize = context.resources.getDimensionPixelSize(R.dimen.word_text_size) positionToInitialsMap = buildMapWithIndexes(words) textPaint = TextPaint(ANTI_ALIAS_FLAG).apply { alpha = 255 typeface = Typeface.create(Typeface.DEFAULT_BOLD, Typeface.ITALIC) textSize = viewTextSize * SCALING_FACTOR color = context.resources.getColor(R.color.initials_color) textAlign = Paint.Align.CENTER } val itemViewPadding = context.resources.getDimensionPixelOffset(R.dimen.word_padding) relativeDrawingPosition = Point(0f, 0f).apply { val rawPixelsMargin = context.resources.getDimensionPixelSize(R.dimen.word_left_margin) x = rawPixelsMargin / 2f // 根据布局方向计算基线位置 y = if (isReverseLayout) { // 反向布局下,基线基于item底部偏移 (itemViewPadding * 2 + viewTextSize) / 2 + textPaint.textSize / 2 } else { (itemViewPadding * 2 + viewTextSize) / 2 + textPaint.textSize / 2 } } itemPadding = itemViewPadding + (textPaint.textSize - viewTextSize)/2 } override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { var prevHeaderY = if (isReverseLayout) Float.MIN_VALUE else Float.MAX_VALUE var prevFoundPosition = NO_POSITION // 根据布局方向选择遍历顺序 val childIndices = if (isReverseLayout) { 0 until parent.size } else { (parent.size - 1) downTo 0 } for (initialsPosition in childIndices) { val currentChild = parent.getChildAt(initialsPosition) if (childOutsideParent(currentChild, parent)) continue val childPosition: Int = parent.getChildAdapterPosition(currentChild) positionToInitialsMap[childPosition]?.let { initial -> val yDrawingPosition = if (isReverseLayout) { // 反向布局下,基于item底部计算绘制位置,实现向下推动的效果 (currentChild.bottom - currentChild.translationY - relativeDrawingPosition.y) .coerceAtMost(parent.height - relativeDrawingPosition.y) .coerceAtLeast(prevHeaderY + relativeDrawingPosition.y + itemPadding) } else { (currentChild.top + currentChild.translationY + relativeDrawingPosition.y) .coerceAtLeast(relativeDrawingPosition.y) .coerceAtMost(prevHeaderY - relativeDrawingPosition.y - itemPadding) } canvas.drawText(initial, relativeDrawingPosition.x, yDrawingPosition, textPaint) prevFoundPosition = childPosition prevHeaderY = yDrawingPosition } } // 处理未找到可见分类首项的情况 prevFoundPosition = if (prevFoundPosition != NO_POSITION) { prevFoundPosition } else { val firstVisibleChild = parent.getChildAt(0) ?: return val firstPos = parent.getChildAdapterPosition(firstVisibleChild) if (isReverseLayout) firstPos - 1 else firstPos + 1 } // 找到需要固定显示的首字母 val targetPosition = if (isReverseLayout) { positionToInitialsMap.keys.firstOrNull { it > prevFoundPosition } } else { positionToInitialsMap.keys.reversed().firstOrNull { it < prevFoundPosition } } targetPosition?.let { pos -> positionToInitialsMap[pos]?.let { initial -> val yDrawingPosition = if (isReverseLayout) { (prevHeaderY + textPaint.textSize + itemPadding) .coerceAtLeast(parent.height - relativeDrawingPosition.y) } else { (prevHeaderY - textPaint.textSize - itemPadding) .coerceAtMost(relativeDrawingPosition.y) } canvas.drawText(initial, relativeDrawingPosition.x, yDrawingPosition, textPaint) } } } /** * 判断item是否超出RecyclerView可视区域 */ private fun childOutsideParent(childView: View, parent: RecyclerView): Boolean { return if (isReverseLayout) { // 反向布局下,判断item是否超出屏幕顶部或底部 childView.top > parent.height || (childView.bottom - childView.translationY.toInt() < 0) } else { childView.bottom < 0 || (childView.top + childView.translationY.toInt() > parent.height) } } private fun buildMapWithIndexes(words: List<String> = ListsProvider.WORDS) = words .mapIndexed { index, string -> index to string[0].uppercase() } .distinctBy { it.second } .toMap() companion object { private const val SCALING_FACTOR = 1.5F private const val NO_POSITION = -1 } }
使用方式
在设置RecyclerView的LayoutManager时开启reverseLayout,并初始化Decoration时传入isReverseLayout=true:
val layoutManager = LinearLayoutManager(context).apply { reverseLayout = true stackFromEnd = true // 配合reverseLayout实现从底部开始显示 } recyclerView.layoutManager = layoutManager recyclerView.addItemDecoration(StickyLetterDecoration(context, words, isReverseLayout = true))
内容的提问来源于stack exchange,提问作者Android
相关产品推荐
相关产品推荐

