You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何改写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从底部开始排列,滚动方向与正常布局相反,因此需要调整坐标计算、遍历逻辑和可视区域判断:

  1. 添加反向布局参数:给Decoration增加isReverseLayout参数,用于区分布局方向
  2. 调整基线位置计算:反向布局下,首字母的基线需要基于item底部计算
  3. 修改item遍历顺序:从正向遍历替代原有的反向遍历,适配反向布局的item排列
  4. 更新可视区域判断逻辑:调整child是否超出屏幕的判断条件
  5. 重构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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.19 23:27:08