ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RecyclerView选中居中实现:中心差、padding与滚动方案全解析

RecyclerView选中居中实现:中心差、padding与滚动方案全解析

1. 先把"居中"这件事说明白:三种语义和两套坐标

做横向选择器的时候,产品经理通常只丢过来一句话:选中的那个要放在中间。听起来是个很轻的需求,但真动手写的时候你会发现,RecyclerView 本身压根就没有"居中"这个概念——它只有"可见"和"不可见"两种状态,scrollToPosition的思路永远是把目标项怼到起始边,而不是怼到中间。我第一次接到这个需求时,天真地以为加个gravity="center"或者调一下LinearLayoutManager的构造参数就能解决,结果自然是白折腾一下午。

所以这篇内容想聊的是:在 RecyclerView 上实现"选中项居中"这一整条链路,从坐标系怎么建立、padding 怎么算、滚动方案怎么选,到选中判定该在什么时机收敛。核心关键词是RecyclerView 选中居中,围绕它展开的所有坑和取舍我都会写清楚。适合已经会用 RecyclerView、但被这个居中需求绊住过的 Android 开发看;完全没接触过 RecyclerView 的同学也能看懂,只是有些 API 的细节需要你自己补一下。

1.1 产品说的"居中",其实指的是哪一件事

在动手之前,必须先跟需求方确认清楚,这里的"居中"到底是哪一种行为。我总结下来,实际项目里常见的是三种,而且它们的实现难度差得很远。

第一种是主动定位式:用户点了某个 item,或者页面初始化时有一个默认选中项,代码要把这个 item 平滑滚到正中间。典型场景是日期选择条、Tab 栏、分段筛选器。这种场景下"居中"是一个动作,目标位置是明确的,实现相对直接。

第二种是吸附式:用户自己用手拖,手指松开之后,离中心点最近的那个 item 自动"吸"到正中,同时它变成选中态。典型场景是滚轮选择器、金额刻度尺。这种场景下"居中"是一个持续存在的不变量——列表永远处于"有一个 item 恰好居中"的状态。

第三种是常驻锁定式:屏幕正中间固定一个高亮框,用户拖动时 item 从框下面划过,停下来时框里的那一项就是选中项。这种其实不是让 item 居中,而是让"选中区域"居中,item 本身仍然在自由滚动。做尺子、做体重体重秤效果的时候常用。

这三种在代码上共用大量逻辑,但触发时机完全不同。很多需求文档里写的是"选中的放中间",但其实第一和第二都要。我的建议是一开始就把这两条拆成两个独立方法:scrollToCenter(position)负责主动定位,snapToNearest()负责松手吸附。混在一起写,后面加个动效就会互相打架。

1.2 scrollToPosition 为什么天生贴着左边缘

LinearLayoutManager.scrollToPosition(pos)的内部行为是:把目标 position 交给一个LinearSmoothScroller(如果是smoothScrollToPosition)或者在下一帧布局时直接把它布局到 offset 0 的位置。这个 offset 0 指的是内容区的起始边,在有 padding 的情况下就是paddingLeft的位置,不是 RecyclerView 的正中间。

换句话说,scrollToPosition实现的是"对齐到起始边",这个设计本身没问题——列表滚动本来就该这样。问题在于我们要的是"对齐到中心",这两个目标差了一个(容器宽度 - item 宽度) / 2的偏移量。

这个偏移量如果是个常量,那还好办,直接scrollToPositionWithOffset(pos, offset)一把梭就行。但实际情况往往没这么美好:

  • item 宽度可能是动态的,跟着文字长度走;
  • RecyclerView 宽度会随屏幕旋转、分屏、折叠屏展开而变化;
  • 有些设计稿里 item 之间有不等距的间距。

只要宽度不是固定的常量,这个偏移量就必须在运行时算。而一旦要在运行时算,你就得先回答一个更基础的问题:"中心"这个点在 RecyclerView 的坐标系里到底是多少?

1.3 用中心差这一个量统一所有场景

我后来把这段逻辑抽象成一个非常朴素的量,叫"中心差"(center offset)。定义是:

delta = (child.left + child.right) / 2 - containerCenterX containerCenterX = (recyclerView.width - paddingLeft - paddingRight) / 2 + paddingLeft

child.left和child.right是 item View 相对于 RecyclerView 左上角的坐标,这个值是 LayoutManager 布局时写进去的,getLeft()拿到的就是它,包含 padding 的影响。containerCenterX是内容可视区的水平中心。

delta > 0表示 item 中心在容器中心的右边,需要往左滚delta;delta < 0反之。滚动量就是delta本身,方向靠正负号自解释,不需要额外判断。

这个公式的好处是它对reverseLayout、RTL 布局、item 宽度不一致这些情况天然兼容——因为它只比较两个绝对坐标的中点在哪儿,不关心 item 是从左往右排还是从右往左排。我之前写过一版判断layoutManager.reverseLayout然后手动翻转方向的代码,后来发现纯属给自己找麻烦,用中心差根本不需要这个分支。

还有一个容易忽略的点:containerCenterX的算法里,如果左右 padding 相等,那(W - P - P)/2 + P就等于W/2,可以直接写width / 2。但如果你的设计是"左边留白多一点、右边留白少一点"(比如为了配合某个悬浮按钮),就必须老老实实按公式算。我见过有人直接写width/2然后左右 padding 不一样,结果居中永远偏十几像素,查了半天以为是测量精度问题。


2. 首尾也能顶到中线:padding 与 clipToPadding 的配合

中心差的公式解决的是"怎么滚",但还有一个前置问题没解决:列表的第一个和最后一个 item,能不能滚到中间?

答案是默认不能。RecyclerView 的滚动范围受内容总宽度限制,滚到头就停。假设 item 宽度 80dp、列表 10 个、屏幕宽 360dp,那么滚动偏移的最大值大约是10 * 80 - 360 = 440dp,此时最后一个 item 的右边缘刚好贴住屏幕右边缘,它的中心在width - 40dp的位置,离屏幕中心差了 140dp。你怎么算中心差都滚不过去,因为没内容可滚了。

2.1 为什么必须把 padding 算进布局而不是套在外面

最直觉的做法是在 RecyclerView 外面套一层 FrameLayout,里面左右各放一个width = (parentWidth - itemWidth)/2的空白 View。这个做法能用,但我不推荐,原因有三个:

第一,多一层布局就多一次测量,在这个场景里测量成本其实不低,因为 RecyclerView 的onMeasure会带着子 View 走一遍;第二,外层容器会干扰触摸事件的传递,你想在列表边缘做OverScrollDecorator之类的效果时会被吃掉一部分事件;第三,也是最烦的——如果外层容器设置了clipChildren=false来做叠加阴影,注意力就会被分散到别的地方。

正确做法是直接给 RecyclerView 设左右 padding,并且把clipToPadding设成false:

recyclerView.apply { clipToPadding = false // 关键:允许 padding 区域内的 item 被绘制 setPadding(sidePadding, 0, sidePadding, 0) }

clipToPadding默认是true,意思是"padding 区域内的内容会被裁掉"。如果不关掉这个开关,你会看到第一个 item 滚到中间时被左边那块 padding 区域切掉一半,视觉上像是渲染错位。这个坑我踩过两次,第一次还以为是自己算的中心差有问题,加了一堆日志才发现是裁剪导致的。

关掉之后,padding 区域内的 item 会正常绘制出来,也就是用户能看到中间选中项两边"探出来"半个 item,这正是我们想要的效果。

2.2 paddingLeft 到底填多少,一个公式说清楚

假设所有 item 宽度一致,记为itemWidth,容器总宽W,那么:

sidePadding = (W - itemWidth) / 2

推导过程很简单:设左边 padding 为 P,滚动到 position 0 时(此时 RecyclerView 的滚动偏移为 0),item 0 的 left 就是 P,它的中心是P + itemWidth/2。要让它居中,就得让P + itemWidth/2 = W/2,解得P = (W - itemWidth)/2。右边同理。

这个公式成立之后,会带来一个非常舒服的副作用:scrollToPosition(0)和scrollToPosition(lastIndex)都能自然地让首尾项居中,不需要任何特殊处理。因为对 position 0 来说,滚到偏移 0 就是滚到 paddingLeft,而 paddingLeft 恰好让 item 0 居中;对最后一项来说,滚动到头的极限位置恰好也是它居中。

反过来说,如果你只给左边加 padding、右边不加(想省点事),那最后几项就永远居不了中。这种"半边好使半边不好使"的 bug 最难查,因为大部分人测试时只测前几个。

如果 item 宽度不一致怎么办?比如文字长度不同导致宽度在 60dp 到 100dp 之间浮动。这时候有两种处理:

一种是把 padding 按最大的 item 宽度来算(sidePadding = (W - maxItemWidth)/2)。缺点是宽度小的 item 滚到中间时会略微偏,但偏得很小,实际观感可以接受。

另一种是干脆把所有 item 强制设成相同的宽度,超出部分用ellipsize截断。这是我在大部分项目里的选择——统一宽度带来的收益远大于让文字完整显示。真遇到文案长的情况,宁可把字号缩小一号。

2.3 动态宽度下的 padding 重算时机

屏幕旋转、分屏、折叠屏展开都会让W变化,padding 必须重算。但重算的时机很讲究,写错了会有明显闪烁。

不能在onCreate里算,因为那时候width是 0。也不能在onMeasure之后随便找个地方算,因为setPadding本身会触发requestLayout,如果放在onLayout回调里会引发一次额外的布局遍历。

我最后用的是View.OnLayoutChangeListener,在里面判断宽度是否变化过:

recyclerView.addOnLayoutChangeListener { v, left, _, right, _, oldLeft, _, oldRight, _ -> val newWidth = right - left val oldWidth = oldRight - oldLeft if (newWidth == oldWidth || newWidth == 0) return@addOnLayoutChangeListener val side = (newWidth - itemWidthPx) / 2 v.setPadding(side, 0, side, 0) // padding 变了,滚动位置会失效,必须重新定位 if (selectedPosition >= 0) { (v.layoutManager as? LinearLayoutManager) ?.scrollToPositionWithOffset(selectedPosition, 0) } }

注意最后那行scrollToPositionWithOffset(selectedPosition, 0)。padding 变化之后,原来的滚动偏移量不再对应原来的视觉位置,如果不重新定位,用户从竖屏切到横屏会看到选中项跑到奇怪的地方去了。这个处理很容易被漏掉,因为它不属于"居中逻辑",属于"居中逻辑的维护成本"。

提示:scrollToPositionWithOffset(pos, offset)里的offset是相对内容区起始边(padding 之后)的距离,不是相对 RecyclerView 边缘。这个语义经常被误解,写的时候可以打个日志确认一下。

另外还有一个隐藏问题:如果 RecyclerView 还没完成一次 layout 就调scrollToPositionWithOffset,可能会被后续的 layout 覆盖掉。保险的做法是post { }一下,或者用pendingPosition这样的字段挂在onLayoutCompleted里处理。


3. 五种滚动方案的取舍,以及为什么最后选了 SmoothScroller

到这里,中心和 padding 都就位了,剩下的就是"怎么把 delta 用出去"。这里的选择其实挺多的,我按从简单到复杂排一下,顺便说下每种的实际适用边界。

3.1 逐项对比:从 scrollToPosition 到 LinearSmoothScroller

方案API是否平滑能否精确居中目标不在可见区适用场景
直接定位scrollToPosition(pos)否否(贴起始边)支持不需要居中,只要可见
带偏移定位scrollToPositionWithOffset(pos, offset)否是(需自己算 offset)支持offset 可预知且恒定
相对滚动smoothScrollBy(dx, 0)是是(dx 用中心差)不支持,只能滚有限距离目标已在可视区或相邻
平滑滚动器LinearSmoothScroller+ 自定义 dx是是支持主动定位式居中的主力
吸附助手LinearSnapHelper是(松手后)是不适用吸附式居中的主力

这里要特别说明smoothScrollBy(dx, 0)的限制。它的参数是"相对当前滚动多少",如果你要滚的距离超过了当前的滚动余量,RecyclerView 会直接滚到极限就停,不会帮你继续。所以它只适合"目标 item 已经在屏幕上"的情况——因为此时中心差必然在可视范围内。

我最初就是用smoothScrollBy一把梭的,测试的时候点前几个 item 都好好的,点到列表末尾就滚不动了。查了半天以为是列表没测量完,其实是滚动余量不够。这个错误非常典型,如果你也遇到"点远处 item 不居中",先怀疑这里。

scrollToPositionWithOffset倒是支持远距离,但它是瞬间跳,没有滑动动画。用在初始化(页面一进来就定位到默认项)是合适的,用在用户点击跳转上就很生硬。

所以最终我形成了这样的组合策略:

  • 初始化定位:scrollToPositionWithOffset(pos, offset),瞬间到位,无动画,避免一进页面就看到列表在飞。
  • 点击定位:自定义LinearSmoothScroller,平滑过渡,远近距离通吃。
  • 松手吸附:LinearSnapHelper或自己算中心差再smoothScrollBy。

3.2 自定义 calculateDxToMakeVisible 的实际写法

LinearSmoothScroller默认在onTargetFound里把目标 View 对齐到起始边,我们要覆盖的是它计算水平位移的那个方法。核心代码如下:

class CenterSmoothScroller(context: Context) : LinearSmoothScroller(context) { override fun onTargetFound( targetView: View, state: RecyclerView.State, action: Action ) { val dx = calculateDxToMakeCenter(targetView) val dy = 0 val time = calculateTimeForDeceleration(abs(dx)) if (time > 0) { action.update(dx, dy, time, mDecelerateInterpolator) } } private fun calculateDxToMakeCenter(view: View): Int { val parent = view.parent as? RecyclerView ?: return 0 val lp = view.layoutParams as RecyclerView.LayoutParams val left = view.left - lp.marginStart val right = view.right + lp.marginEnd val childCenter = (left + right) / 2 val contentCenter = (parent.width - parent.paddingLeft - parent.paddingRight) / 2 + parent.paddingLeft return childCenter - contentCenter } }

有几个细节值得展开:

为什么要减掉 margin?因为view.left和view.right是不包含 margin 的,而布局时 margin 是算进 item 占位里的。如果你的 item 有marginStart,那它的"视觉占位中心"和view.left/right的中点会差半个 margin,累积起来就是几个 dp 的偏移。我一开始没处理这个,测试同学盯着屏幕说"总感觉差一点点",肉眼看不出但对比线框能看出来。

onTargetFound只在目标进入可见区时才被调用。这意味着从 position 0 跳到 position 50,RecyclerView 会先用scrollToPosition类的逻辑快速滚到目标附近,然后才走onTargetFound做精细调整。这个"两段式"是LinearSmoothScroller的设计,不是你哪里写错了。

calculateTimeForDeceleration返回 0 的情况要注意。如果 dx 特别小(比如 1px),算出来的时间是 0,action.update不会被调用,滚动就中断了。加个if (time > 0)的判断,或者直接用calculateTimeForScrolling兜底。

用法就一行:

fun scrollToCenter(position: Int) { val scroller = CenterSmoothScroller(recyclerView.context).apply { targetPosition = position } recyclerView.layoutManager?.startSmoothScroll(scroller) }

3.3 LinearSnapHelper 能做什么、不能做什么

很多人搜到"RecyclerView 居中"会第一时间看到LinearSnapHelper,然后以为找到了银弹。这里必须把它的边界说清楚。

LinearSnapHelper做的是这样一件事:在滚动停止(fling 结束)时,找到当前距离中心最近的 child,计算它到中心还差多少,然后平滑滚过去。也就是说它是被动吸附,触发时机是松手之后,它不关心哪个 item 是"选中项",只关心"谁离中心最近"。

它解决的问题正好是前面说的第二种语义——吸附式。在滚轮选择器、刻度尺这种场景里,直接挂上:

val snapHelper = LinearSnapHelper() snapHelper.attachToRecyclerView(recyclerView)

就完事了,非常简单。

但它解决不了第一种语义:用户点了第 20 项,你希望它滚到中间。LinearSnapHelper只有在滚动停止后才会介入,你主动调用smoothScrollBy之后它确实会在结束时帮你微调一下,但结束时如果差得很远(比如差了 200px),它会再滚一次,视觉上就是"滚过去→停一下→又挪了一小段",很难看。

而且LinearSnapHelper的findSnapView默认依赖calculateDistanceToFinalSnap,它会考虑 padding 但不会考虑 margin,同样的 margin 偏移问题在它身上也会出现。如果 item 有 margin,吸附点会歪。

我的做法是:如果只做吸附式,用 SnapHelper;如果要做主动定位,自己算中心差;如果两者都要,就不要用 SnapHelper,自己写一个在SCROLL_STATE_IDLE时判断并吸附的监听器,这样两条路径的逻辑是统一的,不会互相干扰。


4. 谁是"选中项":滚动过程中的实时判定与收敛

滚动逻辑跑通之后,下一个问题是:什么时候该把回调抛出去告诉业务方"选中项变了"?

这个问题的坑比滚动本身还多。因为滚动是一个连续过程,在一秒钟内可能经过十几个 item,如果每经过一个就抛一次回调,业务方那边可能要去请求接口、更新标题、做埋点,直接被打爆。

4.1 每帧算一次中心不现实,改成算间隙

最粗暴的做法是在onScrolled里遍历所有可见 child,找|delta|最小的那个。这个方案能跑,但每帧都要遍历一遍可见 View 并调getLeft/getRight,在低端机上会有轻微掉帧,尤其是可见 item 有十几个的时候。

我后来用了一个更轻的思路:其实中心点必然落在某一个 item 的范围内,或者落在两个 item 之间的间隙里。所以只需要拿到"中心点所在的 item",不用全遍历。

recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { if (dx == 0) return val centerX = (rv.width - rv.paddingLeft - rv.paddingRight) / 2 + rv.paddingLeft val lm = rv.layoutManager as? LinearLayoutManager ?: return val first = lm.findFirstVisibleItemPosition() val last = lm.findLastVisibleItemPosition() // 优先检查当前选中项是否仍然覆盖中心,覆盖就不切换,避免抖动 val current = lm.findViewByPosition(selectedPosition) if (current != null && current.left <= centerX && current.right >= centerX) { return } // 否则在可见范围里找覆盖中心的那个 for (i in first..last) { val v = lm.findViewByPosition(i) ?: continue if (v.left <= centerX && v.right >= centerX) { updateSelection(i) return } } } })

那个"优先检查当前选中项"的分支是关键的抗抖动措施。想一下,如果两个 item 的边界正好压在中心线上,遍历时可能会在 5 和 6 之间反复横跳,表现出来就是选中态的放大动画一帧一帧地闪。加上这个前置判断之后,只有当前选中项完全离开中心才允许切换,视觉上就稳了。

这个思路和"每个 item 覆盖一个区间"的模型是一致的:整个列表可以看作一条数轴被分成若干段,中心点落在哪一段就是哪一项。之所以会出现边界抖动,是因为段被分得刚好等于 0 宽度,加个门槛就解决了。

4.2 减速停止后的确认逻辑与重复回调抑制

onScrolled给出的选中项是"过程态",用户还在拖,你不应该把这当成最终结果抛出去。真正的确认时机是onScrollStateChanged收到SCROLL_STATE_IDLE。

override fun onScrollStateChanged(rv: RecyclerView, newState: Int) { if (newState == RecyclerView.SCROLL_STATE_IDLE) { val center = findCenterItem(rv) ?: return if (center != lastNotifiedPosition) { lastNotifiedPosition = center onItemSelected(center) // 这里才真正回调业务 } } }

对于拖拽过程中的视觉变化(比如选中项放大),我单独用一个onScrolled里的updateHighlight(position)去驱动,只改 UI 不抛业务回调。把"视觉态"和"业务态"分成两条线,是这套逻辑能稳定跑下去的关键。混在一起的话,你就会为了"实时高亮"而不得不频繁回调业务,然后写一大堆防抖,最后还要处理防抖窗口内状态不一致的问题。

还有一种情况是代码滚动。你调了scrollToCenter(20),滚动过程中onScrolled会不断触发,最后IDLE时抛一次选中回调。但这个回调其实是你自己触发的,业务方如果拿它去"更新界面",就会造成一次多余的刷新。我在这种情况下用一个isProgrammaticScroll标志位跳过回调,只在用户手动操作时才抛。

4.3 代码滚动期间要屏蔽用户触摸

这个点有点反直觉,但很重要。

LinearSmoothScroller的滚动过程中,如果用户伸手去拖一把,两者会打架。表现是滚动突然一顿、或者跳到完全无关的位置。因为用户的 touch 事件会打断当前的 scroller,而 scroller 的剩余动作还在队列里排着。

两种处理方式:

一种是滚动期间设个标志位,在 RecyclerView 外层套一层FrameLayout覆写onInterceptTouchEvent,标志位为 true 时直接返回 true 把事件吃掉。这个方案干净,但会让用户在这一秒内感觉"点不动",有点闷。

另一种是让用户的触摸打断滚动并取消回调:

override fun onInterceptTouchEvent(e: MotionEvent): Boolean { if (e.actionMasked == MotionEvent.ACTION_DOWN && scrollState != SCROLL_STATE_IDLE) { stopScroll() isProgrammaticScroll = false } return super.onInterceptTouchEvent(e) }

第二种更符合直觉,用户在滚动过程中想操作,就让他操作,代码侧的滚动让位。我倾向于这个,因为用户主动操作的优先级永远最高。


5. 实测中最容易翻车的五个细节

上面讲的是主干逻辑,下面这些是我在好几个项目里反复踩过、又被反复问到的东西。它们不改变整体架构,但每一个都能让你多花半天时间。

5.1 margin 导致的中心点偏移

前面在calculateDxToMakeCenter里提过一嘴,这里单独展开,因为太容易犯了。

view.getLeft()返回的是这个 View 自己的左边界,不包含它的 margin。而 LinearLayoutManager 在布局时,会把 margin 计算进去——也就是说 item 之间的实际间隔是marginEnd(A) + marginStart(B),但B.getLeft() - A.getRight()得到的是这个间隔,而B.getLeft()本身已经是"排除自己 marginStart 之后"的值了。

所以如果你的 item 设置了marginStart = 8dp,那么view.left到它真正占据区域的左边,差了 8dp。计算中点的时候,真实视觉中点应该是(left - marginStart + right + marginEnd) / 2,而不是(left + right) / 2。

偏移量是(marginEnd - marginStart) / 2。如果左右 margin 相等,偏移为 0,不会有问题;如果不等(比如只设了marginStart),就会歪。

排查方法:把 item 背景设成半透明红色,然后画一条垂直中线,肉眼一看就知道歪没歪。

5.2 item 宽度不一致与放大效果的取舍

选中项通常会做一个放大效果(1.0 → 1.15)。这会带来两个问题。

第一,如果你用scaleX/scaleY来做放大,View 的布局尺寸不变,getLeft/getRight也不变,中心点不会移动,这个问题不存在。但视觉上 item 会向两边"膨胀",如果 item 间距小,可能和邻居重叠。

第二,如果你用layoutParams.width动态改宽度来做放大,getLeft/getRight会变,中心点会移动,你就需要在动画结束后重新计算一次位置。更麻烦的是改宽度会触发requestLayout,在滚动过程中改会导致列表跳动。

结论很明确:放大一律用scaleX/scaleY,不要动 layoutParams。间距不够就预先把 item 宽度调小、间距加大,给放大留出余量。我通常按itemWidth * 0.3来预留两侧空间,也就是未选中项之间要有大约 15% 宽度的间隙。

还有一个更隐蔽的问题:scaleX放大之后,item 的触摸区域不变(触摸判定用的是布局边界,除非你重写ViewGroup.dispatchTouchEvent)。所以用户视觉上点到放大后的边缘,可能实际点不到。这个体验瑕疵在小屏幕上比较明显,解决思路是让未选中项也保持足够的宽度,或者降低放大比例到 1.08 左右,让差异不明显。

5.3 数据不足一屏时的空白与居中

列表只有 3 个 item、屏幕能放 5 个,这时候会发生什么?

padding 方案下,左边 padding 是(W - itemWidth)/2,3 个 item 总宽 240dp,加上两边 padding 正好凑够屏幕宽。看起来是居中的,但你会发现列表依然可以左右拖动,因为内容宽度刚好等于容器宽度,理论上不该滚,实际上因为 padding 也算进内容宽度,可能会出现几个像素的"橡皮筋"效果。

处理方式有两种:数据不足一屏时禁用滚动,或者允许滚动但设置overScrollMode = OVER_SCROLL_NEVER关掉边缘光效。

recyclerView.overScrollMode = if (itemCount * itemWidth < width) { View.OVER_SCROLL_NEVER } else { View.OVER_SCROLL_IF_CONTENT_SCROLLS }

还有个更彻底的方案:数据不足时直接不加 padding,改成让 item 居中对齐。但这样代码里就多了一个分支,且首尾项能滚到中间的特性也没了(反正也滚不动,无所谓)。看具体情况选。

5.4 复用导致的闪烁与选中错位

这个问题和居中逻辑本身无关,但是"选中项高亮"必然要遇到。

错误的写法是在onClick里直接改 View 的背景色:

// 错误示范 holder.itemView.setOnClickListener { it.setBackgroundColor(SELECTED_COLOR) notifyItemChanged(oldSelected) oldSelected = position }

这么写的问题在于 ViewHolder 是复用的。当列表滚动、position 0 的 holder 被复用到 position 10,它在onBindViewHolder里不会被触发(RecyclerView 会调用 onBind),等等,其实会触发 onBind。真正的坑在于你只改了当前 holder 的背景,那个 holder 后来被复用到别的位置,背景色就跟着跑过去了。

正确做法是把选中状态保存在数据层或 adapter 的一个字段里,在onBindViewHolder里根据 position 和当前选中项的比较来设置:

override fun onBindViewHolder(holder: VH, position: Int) { val selected = position == selectedPosition holder.itemView.isSelected = selected // 配合 selector 使用 holder.itemView.scaleX = if (selected) 1.15f else 1.0f holder.itemView.scaleY = if (selected) 1.15f else 1.0f holder.itemView.alpha = if (selected) 1.0f else 0.6f } fun setSelected(position: Int) { val old = selectedPosition selectedPosition = position if (old >= 0) notifyItemChanged(old) notifyItemChanged(position) }

用isSelected配合res/drawable里的 selector 是最省事的。scale 和 alpha 这种属性动画没法走 selector,就在 bind 里直接设——但因为notifyItemChanged会走默认的 change 动画(淡入淡出),你可能需要关掉它或者自定义一个不发散动画的 ItemAnimator,否则高亮切换时会有一瞬间的闪烁感。

5.5 反向布局和 RTL 下的坐标

前面说中心差公式对reverseLayout天然兼容,这个结论是对的,但有个前提:你用的是view.left和view.right这两个绝对坐标。如果你图省事用了layoutManager.getDecoratedLeft或者用 offset 来计算,就会在reverseLayout=true时出错。

RTL 布局(阿拉伯语、希伯来语环境)下,paddingStart/paddingEnd会分别映射到paddingRight/paddingLeft,而paddingLeft/Right是物理方位。如果你在代码里写setPadding(side, 0, side, 0),因为左右相等,RTL 下也没问题。但如果你写过setPaddingRelative然后再去读paddingLeft,就会拿到交换后的值。

一个稳妥的写法是在计算时统一用物理方位:

val contentCenter = (rv.width - rv.paddingLeft - rv.paddingRight) / 2 + rv.paddingLeft

paddingLeft/paddingRight永远是物理的,不管 RTL 与否,和view.left/right的坐标系一致,不会出错。别用paddingStart。

还有一个坑:clipToPadding = false在不同版本的实现里有细微差异,极少数低版本设备上会有边界情况下的绘制异常。如果要做兼容,可以在首尾项上加个判断,或者干脆在首尾各塞一个不可见的占位 View(宽度等于 padding)。我一般不做这个兼容,因为影响面太小,加占位反而增加复杂度。


6. 关于这套方案我还想补两句

整套方案跑下来,代码量其实不大:一个自定义的CenterSmoothScroller(约 30 行)、一个滚动静音器(约 40 行)、一个setSelected方法(约 10 行)。真正的成本全在调试那些"差几个像素""偶尔抖一下""旋转后错位"的细节上。

我个人在实际操作中的一个体会是:这套东西永远先在纸上把坐标算清楚,再写代码。我习惯拿一张纸画出 RecyclerView 的矩形、padding 区域、item 的矩形,标上 left/right,然后写公式。这比在设备上反复打日志快得多,因为日志只能告诉你结果,不能告诉你应该是什么。我见过不少同学直接在onScrolled里Log.d一堆数字,然后对着满屏的数字猜哪一行是对的——这个效率太低了。

另一个想法是,如果你的需求其实只需要"吸附式居中",千万别上这套复杂方案。LinearSnapHelper+overscroll处理 +onScrollStateChanged就够了,十行代码解决。把两套语义都写进一个类里,后面维护的人会很痛苦——他自己都分不清哪段逻辑服务于哪个需求。

至于后面还能怎么扩展,比如加上惯性甩动的速度控制(calculateTimeForScrolling覆盖)、加上边界回弹效果(EdgeEffect自定义)、或者把整个选择器封装成一个通用 Component 供其他页面复用,这些都可以逐步迭代。但核心的那条"中心差"公式,从我第一次写到现在,一直没变过,仍然是最稳的那一块。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进