垂直与水平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)) } } } } } }
原实现的核心问题
- 滚动循环触发:水平滚动触发垂直滚动时,垂直滚动的
onScrolled会被调用,反过来又触发水平滚动的监听,形成循环,导致同步不稳定 - 低效遍历:每次滚动都遍历所有
indices,频繁更新UI和触发滚动,造成性能浪费 - 位置判断不准确:水平滚动时使用
findFirstCompletelyVisibleItemPosition/findFirstVisibleItemPosition,但没有考虑分类项的选中逻辑,容易出现匹配错误 - 初始化不可靠:用
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() } }
关键优化点
- 滚动互锁机制:新增
isSyncingHorizontalToVertical和isSyncingVerticalToHorizontal标记,避免双向滚动监听循环触发,彻底解决同步不稳定问题 - 统一UI更新逻辑:提取
updateCategorySelectionState和resetCategoryViewState方法,减少重复代码,确保UI状态更新一致 - 优化位置匹配:垂直滚动时,通过
indices.indexOfFirst找到当前菜品位置对应的分类索引,匹配更准确 - 移除不可靠的延迟初始化:用
recyclerViewHorizontal.post替代Handler.postDelayed,确保RecyclerView布局完成后再更新UI - 减少无效操作:滚动监听中先判断互锁标记,避免不必要的计算和滚动触发,提升性能
内容的提问来源于stack exchange,提问作者Ayodele Kayode
相关产品推荐
相关产品推荐

