资讯详情

资讯详情

Android RecyclerView横向选中项居中与滑动吸附实现

1. 先把需求拆开看居中这件事到底难在哪横向 RecyclerView 做选中项居中是那种看起来三五行代码、真做起来一堆边角的典型需求。我第一次接到这个活儿的时候也以为是调个smoothScrollToPosition就完了结果交付前一天被测试同学连着提了五个问题首尾项点不到中间、手指滑完停在半格、选中的高亮错位、快速滑动抖动、旋转屏幕后位置全乱。所以我后来养成一个习惯凡是涉及 RecyclerView 定位的需求先把场景、状态、误差来源三件事列清楚再动手写代码。先说说它到底能做什么。最常见的形态是一排横向排列的卡片或刻度用户左右滑动中间那一格被放大、加粗、描边或者变亮同时对外抛出一个当前选中第几项的回调点击任意一项列表会平滑滚动到那一项并且让它停靠在正中央。城市选择器、日期刻度尺、档位切换、尺码选择、图片画廊、评分滑动条本质上都是同一套东西。适合看这篇的人应该是已经能熟练使用 RecyclerView但在精确控制滚动位置这件事上还停留在scrollToPosition阶段的开发者当然如果你只是想找一个能直接抄走的实现后面也有完整代码。难点不在滚动而在居中这两个字背后的坐标系。RecyclerView 本身不提供任何居中某个 item的 API所有方案都是在跟它的锚点机制、padding 机制、回收机制打交道。而这三套机制互相影响你改一个另外两个就会出来找麻烦。比如你为了让首尾项能滑到中间给 RecyclerView 加了左右 padding结果发现scrollToPositionWithOffset的偏移量算出来总是差那么一点又比如你用了 SnapHelper看起来一切正常唯独ItemDecoration画的分隔线把中心位置推移了几个像素导致选中判定永远慢半拍。所以这篇文章我会按原理推导 → 三种实现路线对比 → 可复用代码 → 排错速查的顺序讲中间穿插我踩过的坑。前半部分是给需要理解机制的人看的后半部分可以直接复制到项目里跑。1.1 先把场景分成两类方案完全不同第一个分岔口是item 宽度是固定的还是不定宽的。固定宽度是最舒服的情况比如刻度尺每一格 60dp、档位卡片统一 120dp。这时你可以在任何时刻用纯数学算出偏移量不需要等 item 测量完成代码干净得像教科书。我绝大部分项目都尽量把设计稿往固定宽度上靠原因就在这里——把不确定性挡在业务之外比在代码里处理不确定性划算得多。不定宽的情况就麻烦了卡片宽度由标题文字撑开长标题宽、短标题窄。这时偏移量必须依赖测量结果而 RecyclerView 的测量是懒加载的屏幕外的 item 根本没有 View 实例。你只能先滚过去让它出现再拿它的宽度做二次校正。这个二次校正的过程会带来视觉上的跳变需要额外的处理把它藏起来具体做法我在第 3 节里讲。第二个分岔口是居中的触发者是用户的手指还是代码。用户手指滑动结束后的居中叫吸附特征是需要监听滚动状态、需要处理惯性、需要跟快速滑动对抗代码触发的居中叫定位特征是需要处理目标 item 还没被创建、需要平滑动画、需要防止连续调用导致动画排队。这两套逻辑最好分开写混在一起的代码我见过太多最后都变成了一坨if (isUserScrolling)的判断嵌套。1.2 三种实现路线的取舍对比我整理了一下这些年用过的方案做一个横向对比你先看表格建立印象细节在后面展开。方案核心 API优点短板适用场景padding 留白 LinearSnapHelpersetPaddingattachSnapHelper代码量最少吸附体验原生首尾占位影响 divider 绘制无法精确控制点击定位快速验证、纯滑动选择器scrollToPositionWithOffset布局管理器 API偏移量完全可控点击定位精准不平滑需要自己处理动画padding 场景偏移量需换算初始化定位、点击跳转自定义 SnapHelper继承SnapHelper吸附位置、优先级、回调全可控需要理解findSnapView和距离计算对体验要求高的正式产品这里有个经验不要一上来就写自定义 SnapHelper。我见过不少项目为了显得专业直接上自定义实现结果findSnapView里的边界判断写了三十行还是会在快速甩动时崩掉。正确的顺序是先试第一条路线够用就收工发现 padding 方案污染了 divider 或者首尾行为不符合设计预期再升级到第三条。第二条路线不是替代关系而是必须掌握的基础能力因为几乎所有方案最终都会调用它。2. 核心原理偏移量到底相对谁计算这一段是全文的地基。我发现很多人写这个功能出问题不是因为 API 不熟而是因为脑子里对offset 是相对谁的偏移这个概念是模糊的。模糊就会靠试试出来的数字换个机型就废了。2.1 scrollToPositionWithOffset 的坐标系LinearLayoutManager.scrollToPositionWithOffset(position, offset)的官方描述很简短把指定位置的 item 滚动到列表中并带上一个偏移量。关键在于这个 offset 的参考基准。它指的是目标 item 的左边缘横向距离 RecyclerView 内容区起始边也就是 paddingLeft 之后的位置的距离。也就是说offset 为 0 时item 会紧贴左 padding 内边缘offset 为 100 时item 的左边缘距离左 padding 内边缘 100px实际看起来就是往右推了 100px。这个定义意味着两件事。第一offset 可以为负数负值会让 item 被推到 padding 区域的左侧甚至部分移出屏幕这在需要预滚动一点的场合很有用。第二offset 的基准是padding 之后不是RecyclerView 左边缘所以当你有 padding 的时候公式里必须把 padding 单独拎出来算不能想当然地当成 0。顺带说一句这个方法有个不太显眼的行为它只是把目标位置记录下来真正的布局发生在下一次 layout 过程中。所以连续调用多次只有最后一次生效在onCreate里调用时因为 RecyclerView 还没测量宽度是 0算出来的偏移量自然是错的。稳妥的写法是在doOnLayout或者post之后调用这一点后面会展开。2.2 居中偏移量的推导过程现在把公式推一遍理解了推导换个方向、加上 padding、加上 margin 都不会错。设 RecyclerView 宽度为 W左右 padding 分别为 PL 和 PRitem 的实际占位宽度为 IW注意是含 margin 和 decoration 的getDecoratedMeasuredWidth不是 layout 参数里的width。那么可视内容区宽度是available W - PL - PR可视内容区的中心在 RecyclerView 坐标系里的横坐标是centerX PL available / 2我们希望 item 的中心点落在 centerX 上。item 左边缘在 RecyclerView 坐标系中的位置称为targetLeft则有targetLeft IW / 2 PL available / 2 targetLeft PL available / 2 - IW / 2而 offset 的定义是 item 左边缘相对 PL 的距离所以offset targetLeft - PL available / 2 - IW / 2 (available - IW) / 2结论很干净offset 等于可视宽度减去 item 宽度的一半与 paddingLeft 的具体数值无关。这个结论很好记也好用——只要你用这个公式加不加 padding 都不影响结果因为 padding 的作用已经被available吸收了。反过来验算一下如果 padding 为 0W 1080IW 200那么 offset (1080 - 200) / 2 440item 左边缘在 440中心在 540正好是屏幕正中。对上了。2.3 三个容易被忽略的宽度细节上面的推导里有个括号注解很重要值得单独拎出来说。计算 item 宽度时有三层宽度容易搞混。第一层是LayoutParams.width。它可能等于WRAP_CONTENT也可能等于MATCH_PARENT很多时候跟实际宽度没关系。第二层是view.getWidth()这是测量并布局之后的实际宽度但不含 decoration。第三层是layoutManager.getDecoratedMeasuredWidth(view)它把ItemDecoration贡献的左右间距也算进去了。如果你在用ItemDecoration画卡片之间的间隙而恰好用getWidth()去算偏移量结果就是每次偏几个像素而且这个误差会随着 item 数量累积滑到第 20 个的时候已经明显歪了。所以一律用getDecoratedMeasuredWidth这是我在项目里写进 code review checklist 的一条。还有一个更隐蔽的坑RecyclerView的clipToPadding。默认值是 true意思是子 View 会被裁剪在 padding 区域内。你要让第一个 item 能滑到中间去就必须把它设成 false否则第一个 item 滚到左边 padding 区域时会被切掉看起来像是不见了。但设成 false 之后滚动过程中 item 会进入 padding 区域如果你的ItemDecoration是按整个宽度绘制分割线的就会出现分割线画到了 padding 里的怪异现象。解决办法是让 decoration 的绘制范围跳过 padding或者干脆用 margin 代替 decoration后者更省事。3. 动手实现一个能直接抄走的居中控制器理论说完了开始写。我这里的实现思路是组合拳用 padding 提供滑动的边界空间用scrollToPositionWithOffset处理代码定位用自定义LinearSmoothScroller处理平滑动画用SnapHelper处理手指滑动后的吸附。四块各管一件事互不干扰。3.1 基础布局与初始化参数先看布局。横向 RecyclerView加上左右 padding关掉clipToPadding再配一个overScrollMode防止边缘的反馈弧线破坏视觉。androidx.recyclerview.widget.RecyclerView android:idid/rvPicker android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal android:clipToPaddingfalse android:overScrollModenever android:scrollbarsnone app:layoutManagerandroidx.recyclerview.widget.LinearLayoutManager /padding 的数值在代码里动态设置。为什么不在 XML 里写死因为 padding 的正确值依赖屏幕宽度而屏幕宽度在 XML 解析阶段拿不到。正确的写法是在测量完成后设置rvPicker.doOnLayout { val available it.width - it.paddingLeft - it.paddingRight val side (available - itemWidthPx) / 2 it.setPadding(side, 0, side, 0) }这段代码有个隐患setPadding会再次触发布局如果itemWidthPx是动态变化的可能出现循环触发的风险。我的处理方式是加一个判重只有 padding 值真的变了才设置if (it.paddingLeft ! side || it.paddingRight ! side) { it.setPadding(side, 0, side, 0) }另外itemWidthPx如果是通过resources.getDimensionPixelSize拿到的固定值要注意圆角屏幕和折叠屏的适配。我的习惯是用屏幕宽度乘以一个比例来推导 item 宽度比如屏幕的 0.28 倍这样在各种屏幕上视觉比例是一致的不会出现小屏上只露出一个半 item、大屏上露出五个半的割裂感。3.2 点击定位精确跳到中间点击某一项让它居中我封装成一个扩展函数。核心是先判断 item 宽度已知还是未知走不同分支。fun RecyclerView.scrollToCenter(position: Int, smooth: Boolean true) { val lm layoutManager as? LinearLayoutManager ?: return if (position !in 0 until (adapter?.itemCount ?: 0)) return val available width - paddingLeft - paddingRight if (available 0) { post { scrollToCenter(position, smooth) } return } // 已知固定宽度的快速路径 val fixedWidth (adapter as? FixedWidthAdapter)?.itemWidthPx ?: 0 if (fixedWidth 0) { val offset (available - fixedWidth) / 2 if (smooth) { startCenterSmoothScroll(lm, position) } else { lm.scrollToPositionWithOffset(position, offset) } return } // 未知宽度先滚过去等布局完成后二次校正 lm.scrollToPositionWithOffset(position, available / 2) post { val vh findViewHolderForAdapterPosition(position) ?: returnpost val v vh.itemView if (v.width 0) returnpost val decoratedWidth lm.getDecoratedMeasuredWidth(v) val decoratedLeft lm.getDecoratedStart(v) // 含 padding 偏移 val childCenter decoratedLeft decoratedWidth / 2 val viewportCenter paddingLeft available / 2 val delta childCenter - viewportCenter if (delta ! 0) scrollBy(delta, 0) } }这段代码里有两个值得解释的点。第一处是先粗滚再精校。未知宽度时第一次调用的 offset 只是让目标 item 大致出现在屏幕中部它肯定不准等布局完成后再用scrollBy补上差值。因为scrollBy是同步执行的、下一次绘制就生效用户看到的是一次连续的移动几乎察觉不到中间过程。第二处是decoratedStart的坐标系。getDecoratedStart返回的已经是 RecyclerView 坐标系下的位置含 padding 偏移所以后面直接用paddingLeft available / 2作为可视中心来比较两边基准是一致的。如果这里一不小心用available / 2去比较就漏掉了 paddingLeft结果就是 padding 越大偏得越多——这是我最早踩的坑当时排查了两个多小时。3.3 平滑滚动用 LinearSmoothScroller 精确控制落点RecyclerView.smoothScrollToPosition的目标是让 item 可见不是让 item 居中所以它天然不满足需求。想让它居中就得自己定义目标位置的计算方式。LinearSmoothScroller提供了一个calculateDtToFit钩子返回值就是还需要滚多少距离class CenterSmoothScroller(context: Context) : LinearSmoothScroller(context) { override fun calculateDtToFit( viewStart: Int, viewEnd: Int, boxStart: Int, boxEnd: Int, snapPreference: Int ): Int { val itemCenter (viewStart viewEnd) / 2 val boxCenter (boxStart boxEnd) / 2 return boxCenter - itemCenter } override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float { return 60f / displayMetrics.densityDpi } }viewStart/viewEnd是目标 item 在滚动方向上的起止坐标boxStart/boxEnd是可视区域的起止坐标。两者的中心差值就是需要的滚动距离正数表示往负方向滚负数反之LinearSmoothScroller会自己处理方向。calculateSpeedPerPixel的默认值是 25 毫秒每英寸我改成了 60 毫秒每英寸的反比形式实际效果是 1dp 约 0.375 毫秒。这个数值不是随便写的我拿真机上试了十几个不同距离的滑动60 左右的手感最自然——太慢会觉得拖沓太快又失去了定位的心理暗示。当然这属于主观参数你可以自己调。调用方式private fun startCenterSmoothScroll(lm: LinearLayoutManager, position: Int) { val scroller CenterSmoothScroller(lm.context).apply { targetPosition position } lm.startSmoothScroll(scroller) }有个细节startSmoothScroll会打断当前正在进行的滚动动画所以连续快速点击不同 item 时新的动画会直接接管不会出现排长队的现象。这对用户体验很重要值得记一下。3.4 吸附手指松手后的归位用户手动滑完停在两个 item 之间是最难受的必须自动吸附到最近的整数位置。这块我放到第 4 节单独讲因为它值得完整的篇幅。4. SnapHelper 路线从能用到好用吸附这件事Android 框架本身给了工具问题在于默认工具的行为是否符合预期。4.1 LinearSnapHelper 能做什么做不到什么LinearSnapHelper的定位逻辑是找一个离容器中心最近的子 View然后滚动到让它的中心对齐容器中心。听起来完全符合需求但它有两个前提条件。第一个前提是目标位置必须可达。如果第一个 item 左边没有多余空间那它在数学上就永远不可能滚到中心——RecyclerView 已经滚到头了。这就是为什么必须设置左右 padding或者通过ItemDecoration制造占位。设置 padding 之后第一个 item 左侧那 440px 的空间允许它继续往右滚从而到达中心。第二个前提是容器中心的计算受clipToPadding影响。在clipToPaddingfalse的情况下LinearSnapHelper会用getEnd() / 2 getStartAfterPadding() / 2这种方式来近似中心当 padding 左右不对称时会跟视觉中心有偏差。我们前面设的是左右对称 padding所以问题不大但如果你因为某种原因用了不对称 padding务必实测一下。除此之外LinearSnapHelper还有两个让我不太舒服的地方一是它不告诉你吸附到了哪一项选中态得自己从scrollState IDLE之后去判断二是它的吸附优先级纯粹按距离算没法干预。前者可以用RecyclerView.OnScrollListener配合findFirstVisibleItemPosition搭出来后者只能自己写。4.2 自定义 SnapHelper把选中判定一起解决我的做法是继承SnapHelper重写找目标 view和算距离这两个核心方法并在滚动结束的时候把选中位置抛出去。class CenterSnapHelper( private val onSnapped: ((Int) - Unit)? null ) : SnapHelper() { private var snapToStart true override fun attachToRecyclerView(rv: RecyclerView?) { super.attachToRecyclerView(rv) snapToStart (rv?.layoutManager as? LinearLayoutManager)?.reverseLayout false rv?.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (newState RecyclerView.SCROLL_STATE_IDLE) { findSnapView(recyclerView.layoutManager)?.let { v - val pos recyclerView.getChildAdapterPosition(v) if (pos ! RecyclerView.NO_POSITION) onSnapped?.invoke(pos) } } } }) } override fun findSnapView(lm: RecyclerView.LayoutManager): View? { lm as? LinearLayoutManager ?: return null if (lm.childCount 0) return null if (lm.childCount 1) return lm.getChildAt(0) val centerY lm.height / 2 val centerX lm.width / 2 return if (lm.canScrollHorizontally()) { lm.children.minByOrNull { abs(it.centerX() - centerX) } } else { lm.children.minByOrNull { abs(it.centerY() - centerY) } } } override fun calculateDistanceToFinalSnap( lm: RecyclerView.LayoutManager, targetView: View ): IntArray? { lm as? LinearLayoutManager ?: return null val out IntArray(2) if (lm.canScrollHorizontally()) { out[0] distanceToCenter(lm, targetView, OrientationHelper.createHorizontalHelper(lm)) } else { out[1] distanceToCenter(lm, targetView, OrientationHelper.createVerticalHelper(lm)) } return out } private fun distanceToCenter( lm: RecyclerView.LayoutManager, target: View, helper: OrientationHelper ): Int { val start helper.getDecoratedStart(target) val size helper.getDecoratedMeasurement(target) val itemCenter start size / 2 val rv lm as RecyclerView.LayoutManager val containerCenter if (lm.clipToPadding) { helper.startAfterPadding helper.totalSpace / 2 } else { helper.startAfterPadding (helper.end - helper.startAfterPadding) / 2 } return itemCenter - containerCenter } private fun View.centerX(): Int (left right) / 2 }这里findSnapView的实现用了children加minByOrNull比手写遍历清爽也避免了索引越界的可能。distanceToCenter里我特意保留了clipToPadding的分支判断因为很多项目里这个属性会被误设保留分支能少一类玄学问题。提示findSnapView在快速滑动的每一帧都可能被调用这个方法里千万不要做任何耗时操作也不要创建新对象。我上面用了minByOrNull在 item 可见数量较少10 个以内时开销可以忽略但如果一屏能看到几十个 item建议改成手动遍历复用一个临时变量。4.3 选中态的同步与复用陷阱吸附结束拿到 position接下来要做的是更新选中态。这里有个经典错误在回调里直接notifyItemChanged(oldPos)和notifyItemChanged(newPos)。看起来没毛病实际上在滚动过程中触发局部刷新会跟正在进行的滑动抢布局表现就是列表顿一下。更严重的后果是notifyItemChanged会让对应 item 重新绑定如果绑定逻辑里有动画会出现闪烁。我的做法是不走 notify而是在 adapter 里维护一个selectedPosition字段配合一个状态应用的方法只更新受影响的两个 ViewHolder 的视觉属性fun updateSelection(rv: RecyclerView, position: Int) { val adapter rv.adapter as? PickerAdapter ?: return if (adapter.selectedPosition position) return val old adapter.selectedPosition adapter.selectedPosition position listOf(old, position).forEach { pos - if (pos ! RecyclerView.NO_POSITION) { rv.findViewHolderForAdapterPosition(pos)?.let(adapter::applySelectedStyle) } } }applySelectedStyle里做的事情取决于视觉需求缩放用scaleX/scaleY加animate()透明度用alpha描边可以换背景 drawable。这些都作用在已有的 ViewHolder 上不触发重新绑定性能上几乎没有成本。顺便说一句getAdapterPosition的问题。这个方法在onBindViewHolder里同步取是安全的但如果在点击回调里异步取比如提交到线程池或者 post 延迟执行就有可能拿到NO_POSITION。新版本里它已经被标记为废弃统一改用absoluteAdapterPosition或者bindingAdapterPosition后者的语义是这个 ViewHolder 当前绑定的位置在列表有动画时更准确。5. item 宽度不固定时的完整流程不定宽是真实项目里更常见的情况设计稿往往会写卡片宽度随文字自适应。这一节把完整流程走一遍。5.1 二次校正的完整时序第一次粗滚——等待布局——精确补偿这个三步流程的关键在于等待布局这一步的时机选择。post是把它塞进主线程消息队列通常会在本次布局之后执行但并不是百分之百保证。更稳的做法是监听doOnNextLayoutrv.doOnNextLayout { val vh rv.findViewHolderForAdapterPosition(position) ?: returndoOnNextLayout val v vh.itemView val lm rv.layoutManager as LinearLayoutManager val decoratedWidth lm.getDecoratedMeasuredWidth(v) val decoratedStart lm.getDecoratedStart(v) val delta (decoratedStart decoratedWidth / 2) - (rv.paddingLeft (rv.width - rv.paddingLeft - rv.paddingRight) / 2) if (abs(delta) 1) rv.scrollBy(delta, 0) }这里加了一个abs(delta) 1的保护避免因为浮点取整反复触发微小滚动。这个阈值设成 1 像素是我实测下来的经验值设大了会有肉眼可见的偏移设成 0 则可能因为精度问题进入循环。5.2 用 ItemDecoration 帮助定位的技巧如果适配不定宽的需求觉得麻烦有一个折中方案把 item 宽度按最大可能宽度固定下来内部内容居中排布。这样视觉上还是不定宽的观感因为内容居中但计算偏移量时可以直接用固定值省掉二次校正的复杂性。代价是每个 item 占位变宽一屏能看到的数量变少。另一个技巧是把居中偏移这件事交给 ItemDecoration 的getItemOffsets。让第一个 item 前面多出(available - itemWidth)/2的间距最后一个后面同样然后配合LinearSnapHelper就能天然居中。这种写法代码量最少但 decoration 的偏移值在 item 宽度不定时同样需要动态计算而且会污染item 之间的间距这个语义。我一般在原型阶段用正式项目里还是老老实实写控制器。6. 常见问题与排查速查表下面这些是我和同事在真实项目里遇到过的问题按出现频率排序。6.1 偏移累积为什么越滑越偏最典型的症状是单独点击某一项它特别准滑动十几个之后选中态就慢慢歪了。原因通常是三个。一是计算 item 宽度时用了getWidth()而实际有ItemDecoration贡献的宽度每项差几个像素累积起来就明显了。二是 padding 用的是固定 dp 值没有跟随屏幕宽度变化导致available - itemWidth在不同机型上比例不一致。三是在smoothScrollToPosition之后又调用了scrollToPositionWithOffset两个滚动指令打架。排查方法很土但很有效在滚动停止时打印findFirstVisibleItemPosition和第一个可见 item 的getDecoratedStart跟前一次对比看差值是不是稳定在某个小数上。稳定偏移基本就是宽度算错了波动偏移多半是滚动指令冲突。6.2 快速滑动后的抖动与回弹用户用力一甩松手瞬间列表会先停一下再往回弹一截这个体验非常差。原因在于LinearSnapHelper的吸附动画在高速滑动时会被触发多次每次都以当时的中心重新计算目标。处理办法是让吸附只在速度降到阈值以下时才生效。SnapHelper本身不支持这个判断只能自己从onFling里拿速度或者在SCROLL_STATE_SETTLING阶段判断距离。我用过的最简单有效的方案是在onScrollStateChanged收到 IDLE 时才开始吸附同时把RecyclerView的setItemAnimator(null)——因为 item 动画在快速滑动时同样会加入额外的插值计算。这个改动我一开始很犹豫后来发现这个列表根本不需要 item 动画关掉之后流畅度提升肉眼可见。6.3 选中态错乱与复用复用的经典问题滑出屏幕再滑回来选中高亮变成了别的 item。根源是onBindViewHolder里没有根据selectedPosition重设视觉状态或者视觉状态是用isSelected这类依赖 View 实例自身状态的属性。解决方案只有一个所有视觉状态都从 adapter 的数据字段推导永远不依赖 View 的残留状态。补一条容易忽略的如果用了RecyclerView.setItemViewCacheSize调大了缓存被缓存的 ViewHolder 不会重新走onBindViewHolder所以notifyDataSetChanged之后视觉状态可能不刷新。这种情况需要手动遍历childCount更新。6.4 常见问题速查表现象高概率原因排查动作首项点不到中间clipToPaddingtrue或未设 padding检查 padding 与 clip 属性点击定位总是偏一点宽度未包含 decoration改用getDecoratedMeasuredWidth滑完不吸附SnapHelper 未 attach 或 childCount 2打印 layoutManager 状态吸附回弹明显高速被多次触发吸附只在 IDLE 状态吸附旋转屏幕后错位未恢复选中位置onSaveInstanceState保存 position选中的高亮跑到别的项视觉状态依赖 View 残留全部从 adapter 字段推导7. 体验打磨让居中这件事看起来对技术上居中了不代表观感上就对了。视觉上的居中感其实来自几个额外信号。7.1 缩放与透明度的联动选中项放大 1.15 到 1.2 倍相邻项略微缩小到 0.9这个组合是我试下来最稳的。超过 1.25 倍会显得突兀低于 1.1 倍又看不出来。透明度我一般只调节到 0.6 左右差异不做全透明因为全透明会让整排看起来残缺视觉上不舒服。实现上不要在onScrollStateChanged里做要在onScrolled里实时算。每个可见 item 距离中心的距离映射成一个插值因子val factor (1f - abs(v.centerX() - rvCenter) / (available / 2f)).coerceIn(0f, 1f) v.scaleX 0.9f 0.28f * factor v.scaleY 0.9f 0.28f * factor v.alpha 0.6f 0.4f * factor注意onScrolled调用非常频繁这段代码里不能创建对象、不能调用findViewHolderForAdapterPosition它会做查找只能直接遍历childCount拿子 View。我一开始写成了查 ViewHolder滑动明显掉帧改成直接操作 View 之后才恢复正常。7.2 边缘反馈与触感滑到第一个或最后一个 item 时需要一个到边界了的反馈否则用户会一直滑却动不了产生困惑。overScrollModenever关掉了默认的边缘光效就得自己补一个。最简单的是给 RecyclerView 加OnScrollListener在 IDLE 状态下检测findFirstCompletelyVisibleItemPosition() 0触发一次轻微的震动或者渐变提示。这里有个细节判断到边界不能用canScrollHorizontally因为设置了 padding 之后即使第一个 item 已经居中仍然能继续往右滚一点点。正确的判断是当前位置减去 padding 带来的可滚动量之后是否归零这个计算比较绕我一般直接用computeHorizontalScrollOffset() 0来做。我在实际项目里的体会是这个需求真正的门槛不在技术而在于想清楚所有状态。代码量其实不大控制器加起来两百行左右但它要同时应付手指滑动、代码定位、屏幕旋转、数据刷新、快速连点这五种输入每一种都会把状态机推到不同的分支。所以我的习惯是先画一张状态转移图纸上画不用工具把 tip 在哪个状态做了什么、scrollState 怎么变化、选中态什么时候更新全写清楚再动手。这张纸通常能省下后面几个小时的调试时间。最后分享一个小技巧如果你在真机上老是调不准偏移量可以在调试包里加一段临时代码在 RecyclerView 上加一层半透明的参考线画出可视中心那条竖线。肉眼看着代码跑起来的效果比对着 log 里的数字猜快十倍。这个方法我用了很多年几乎每次做横向选择器都会加上上线前再删掉。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →