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

垂直与水平RecyclerView同步问题及优化方案咨询

美食菜单双RecyclerView双向同步滚动优化方案

问题背景

我正在开发一款美食菜单布局:顶部是横向滚动的RecyclerView展示饮品、寿司等分类;底部是纵向滚动的RecyclerView展示对应分类下的菜品(比如饮品分类下的可口可乐、芬达)。需要实现双向滚动同步:滚动垂直RecyclerView时联动横向分类,滚动横向RecyclerView时联动垂直菜品列表。目前垂直滚动同步正常,但水平滚动同步存在不稳定问题。

原实现代码如下:

import android.graphics.Typeface
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView

class TwoRecyclerViews(
    private val recyclerViewHorizontal: RecyclerView,
    private val recyclerViewVertical: RecyclerView,
    private var indices: List<Int>,
    private var isSmoothScroll: Boolean = false,
) {

private var attached = false

private var horizontalRecyclerState = RecyclerView.SCROLL_STATE_IDLE
private var verticalRecyclerState = RecyclerView.SCROLL_STATE_IDLE


private val smoothScrollerVertical: RecyclerView.SmoothScroller =
    object : LinearSmoothScroller(recyclerViewVertical.context) {
        override fun getVerticalSnapPreference(): Int {
            return SNAP_TO_START
        }
    }


fun attach() {
    recyclerViewHorizontal.adapter
        ?: throw RuntimeException("Cannot attach with no Adapter provided to RecyclerView")

    recyclerViewVertical.adapter
        ?: throw RuntimeException("Cannot attach with no Adapter provided to RecyclerView")

    updateFirstPosition()
    notifyIndicesChanged()
    attached = true
}

private fun detach() {
    recyclerViewVertical.clearOnScrollListeners()
    recyclerViewHorizontal.clearOnScrollListeners()
}

fun reAttach() {
    detach()
    attach()
}

private fun updateFirstPosition() {
    Handler(Looper.getMainLooper()).postDelayed({
        val view = recyclerViewHorizontal.findViewHolderForLayoutPosition(0)?.itemView
        val textView = view?.findViewById<TextView>(R.id.horizontalCategoryName)
        val imageView = view?.findViewById<ImageView>(R.id.categorySelectionIndicator)
        imageView?.visibility = View.VISIBLE
        textView?.setTypeface(null, Typeface.BOLD)
        textView?.setTextColor(recyclerViewVertical.context.getColor(R.color.primary_1))
    }, 100)
}

fun isAttached() = attached

private fun notifyIndicesChanged() {
    recyclerViewHorizontal.addOnScrollListener(onHorizontalScrollListener)
    recyclerViewVertical.addOnScrollListener(onVerticalScrollListener)
}

private val onHorizontalScrollListener = object : RecyclerView.OnScrollListener() {
    override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
        horizontalRecyclerState = newState
    }

    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        super.onScrolled(recyclerView, dx, dy)

        val linearLayoutManager: LinearLayoutManager =
            recyclerView.layoutManager as LinearLayoutManager?
                ?: throw RuntimeException("No LinearLayoutManager attached to the RecyclerView.")

        var itemPosition =
            linearLayoutManager.findFirstCompletelyVisibleItemPosition()

        if (itemPosition == -1) {
            itemPosition =
                linearLayoutManager.findFirstVisibleItemPosition()
        }

        if (horizontalRecyclerState == RecyclerView.SCROLL_STATE_DRAGGING ||
            horizontalRecyclerState == RecyclerView.SCROLL_STATE_SETTLING
        ) {
            for (position in indices.indices) {
                val view = recyclerView.findViewHolderForLayoutPosition(indices[position])?.itemView
                val textView = view?.findViewById<TextView>(R.id.horizontalCategoryName)
                val imageView = view?.findViewById<ImageView>(R.id.categorySelectionIndicator)
                if (itemPosition == indices[position]) {
                    if (isSmoothScroll) {
                        smoothScrollerVertical.targetPosition = indices[position]
                        recyclerViewVertical.layoutManager?.startSmoothScroll(smoothScrollerVertical)
                    } else {
                        (recyclerViewVertical.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(
                            indices[position], 16.dpToPx()
                        )
                    }
                    imageView?.visibility = View.VISIBLE
                    textView?.setTypeface(null, Typeface.BOLD)
                    textView?.setTextColor(recyclerView.context.getColor(R.color.primary_1))
                } else {
                    imageView?.visibility = View.GONE
                    textView?.setTypeface(null, Typeface.NORMAL)
                    textView?.setTextColor(recyclerView.context.getColor(R.color.secondary_5))
                }
            }
        }
    }
}

private val onVerticalScrollListener = object : RecyclerView.OnScrollListener() {
    override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
        verticalRecyclerState = newState
    }

    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        super.onScrolled(recyclerView, dx, dy)

        val linearLayoutManager: LinearLayoutManager =
            recyclerView.layoutManager as LinearLayoutManager?
                ?: throw RuntimeException("No LinearLayoutManager attached to the RecyclerView.")

        var itemPosition =
            linearLayoutManager.findFirstCompletelyVisibleItemPosition()

        if (itemPosition == -1) {
            itemPosition =
                linearLayoutManager.findFirstVisibleItemPosition()
        }

        if (verticalRecyclerState == RecyclerView.SCROLL_STATE_DRAGGING ||
            verticalRecyclerState == RecyclerView.SCROLL_STATE_SETTLING
        ) {
            for (position in indices.indices) {
                val view = recyclerViewHorizontal.findViewHolderForAdapterPosition(indices[position])?.itemView
                val textView = view?.findViewById<TextView>(R.id.horizontalCategoryName)
                val imageView = view?.findViewById<ImageView>(R.id.categorySelectionIndicator)
                if (itemPosition == indices[position]) {
                    (recyclerViewHorizontal.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(
                        indices[position], 16.dpToPx()
                    )
                    imageView?.visibility = View.VISIBLE
                    textView?.setTypeface(null, Typeface.BOLD)
                    textView?.setTextColor(recyclerViewVertical.context.getColor(R.color.primary_1))
                } else {
                    imageView?.visibility = View.GONE
                    textView?.setTypeface(null, Typeface.NORMAL)
                    textView?.setTextColor(recyclerViewVertical.context.getColor(R.color.secondary_5))
                }
            }
        }
    }
}


}

原实现的核心问题

  1. 滚动循环触发:水平滚动触发垂直滚动时,垂直滚动的onScrolled会被调用,反过来又触发水平滚动的监听,形成循环,导致同步不稳定
  2. 低效遍历:每次滚动都遍历所有indices,频繁更新UI和触发滚动,造成性能浪费
  3. 位置判断不准确:水平滚动时使用findFirstCompletelyVisibleItemPosition/findFirstVisibleItemPosition,但没有考虑分类项的选中逻辑,容易出现匹配错误
  4. 初始化不可靠:用Handler.postDelayed初始化第一个分类的选中状态,延迟时间不可控,可能导致UI更新不及时

优化后的实现方案

import android.graphics.Typeface
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView

class TwoRecyclerViews(
    private val recyclerViewHorizontal: RecyclerView,
    private val recyclerViewVertical: RecyclerView,
    private var indices: List<Int>,
    private var isSmoothScroll: Boolean = false,
) {

    private var attached = false
    // 滚动互锁标记,避免双向滚动循环触发
    private var isSyncingHorizontalToVertical = false
    private var isSyncingVerticalToHorizontal = false

    private val smoothScrollerVertical: RecyclerView.SmoothScroller =
        object : LinearSmoothScroller(recyclerViewVertical.context) {
            override fun getVerticalSnapPreference(): Int {
                return SNAP_TO_START
            }
        }

    fun attach() {
        recyclerViewHorizontal.adapter ?: throw RuntimeException("Horizontal RecyclerView must have an Adapter")
        recyclerViewVertical.adapter ?: throw RuntimeException("Vertical RecyclerView must have an Adapter")

        initFirstCategoryState()
        setupScrollListeners()
        attached = true
    }

    private fun detach() {
        recyclerViewVertical.clearOnScrollListeners()
        recyclerViewHorizontal.clearOnScrollListeners()
    }

    fun reAttach() {
        detach()
        attach()
    }

    fun isAttached() = attached

    // 初始化第一个分类的选中状态,避免使用Handler延迟
    private fun initFirstCategoryState() {
        recyclerViewHorizontal.post {
            updateCategorySelectionState(0)
        }
    }

    private fun setupScrollListeners() {
        recyclerViewHorizontal.addOnScrollListener(horizontalScrollListener)
        recyclerViewVertical.addOnScrollListener(verticalScrollListener)
    }

    // 统一更新分类选中状态的方法
    private fun updateCategorySelectionState(selectedPosition: Int) {
        // 先重置所有分类的状态
        for (position in indices.indices) {
            val view = recyclerViewHorizontal.findViewHolderForLayoutPosition(position)?.itemView
            resetCategoryViewState(view)
        }
        // 更新选中分类的状态
        val selectedView = recyclerViewHorizontal.findViewHolderForLayoutPosition(selectedPosition)?.itemView
        selectedView?.let { view ->
            val textView = view.findViewById<TextView>(R.id.horizontalCategoryName)
            val imageView = view.findViewById<ImageView>(R.id.categorySelectionIndicator)
            imageView.visibility = View.VISIBLE
            textView.setTypeface(null, Typeface.BOLD)
            textView.setTextColor(recyclerViewVertical.context.getColor(R.color.primary_1))
        }
    }

    private fun resetCategoryViewState(view: View?) {
        view?.let {
            val textView = it.findViewById<TextView>(R.id.horizontalCategoryName)
            val imageView = it.findViewById<ImageView>(R.id.categorySelectionIndicator)
            imageView.visibility = View.GONE
            textView.setTypeface(null, Typeface.NORMAL)
            textView.setTextColor(recyclerViewVertical.context.getColor(R.color.secondary_5))
        }
    }

    private val horizontalScrollListener = object : RecyclerView.OnScrollListener() {
        override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
            super.onScrollStateChanged(recyclerView, newState)
            // 滚动结束后重置互锁标记
            if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                isSyncingHorizontalToVertical = false
            }
        }

        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)
            if (isSyncingVerticalToHorizontal) return // 避免垂直触发的滚动被处理

            val layoutManager = recyclerView.layoutManager as LinearLayoutManager
            // 找到当前完全可见的第一个分类位置
            val currentPosition = layoutManager.findFirstCompletelyVisibleItemPosition().takeIf { it != -1 }
                ?: layoutManager.findFirstVisibleItemPosition()

            if (currentPosition != -1 && !isSyncingHorizontalToVertical) {
                isSyncingHorizontalToVertical = true
                // 同步垂直列表到对应位置
                syncVerticalRecyclerViewToPosition(currentPosition)
                // 更新分类选中状态
                updateCategorySelectionState(currentPosition)
            }
        }
    }

    private val verticalScrollListener = object : RecyclerView.OnScrollListener() {
        override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
            super.onScrollStateChanged(recyclerView, newState)
            if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                isSyncingVerticalToHorizontal = false
            }
        }

        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)
            if (isSyncingHorizontalToVertical) return // 避免水平触发的滚动被处理

            val layoutManager = recyclerView.layoutManager as LinearLayoutManager
            val currentPosition = layoutManager.findFirstCompletelyVisibleItemPosition().takeIf { it != -1 }
                ?: layoutManager.findFirstVisibleItemPosition()

            if (currentPosition != -1 && !isSyncingVerticalToHorizontal) {
                // 找到当前垂直位置对应的分类索引
                val matchedCategoryIndex = indices.indexOfFirst { it <= currentPosition }
                    .takeIf { it != -1 } ?: 0

                isSyncingVerticalToHorizontal = true
                // 同步水平列表到对应分类
                syncHorizontalRecyclerViewToPosition(matchedCategoryIndex)
                // 更新分类选中状态
                updateCategorySelectionState(matchedCategoryIndex)
            }
        }
    }

    private fun syncVerticalRecyclerViewToPosition(categoryPosition: Int) {
        val targetVerticalPosition = indices.getOrNull(categoryPosition) ?: return
        if (isSmoothScroll) {
            smoothScrollerVertical.targetPosition = targetVerticalPosition
            recyclerViewVertical.layoutManager?.startSmoothScroll(smoothScrollerVertical)
        } else {
            (recyclerViewVertical.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(
                targetVerticalPosition, 16.dpToPx()
            )
        }
    }

    private fun syncHorizontalRecyclerViewToPosition(categoryPosition: Int) {
        (recyclerViewHorizontal.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(
            categoryPosition, 16.dpToPx()
        )
    }

    // 扩展方法,dp转px
    private fun Int.dpToPx(): Int {
        val density = recyclerViewHorizontal.context.resources.displayMetrics.density
        return (this * density).toInt()
    }
}

关键优化点

  1. 滚动互锁机制:新增isSyncingHorizontalToVertical和isSyncingVerticalToHorizontal标记,避免双向滚动监听循环触发,彻底解决同步不稳定问题
  2. 统一UI更新逻辑:提取updateCategorySelectionState和resetCategoryViewState方法,减少重复代码,确保UI状态更新一致
  3. 优化位置匹配:垂直滚动时,通过indices.indexOfFirst找到当前菜品位置对应的分类索引,匹配更准确
  4. 移除不可靠的延迟初始化:用recyclerViewHorizontal.post替代Handler.postDelayed,确保RecyclerView布局完成后再更新UI
  5. 减少无效操作:滚动监听中先判断互锁标记,避免不必要的计算和滚动触发,提升性能

内容的提问来源于stack exchange,提问作者Ayodele Kayode

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.10 18:31:08