【免费下载链接】RulerView
卷尺系列控件,包含:普通卷尺(如:体重)、金额尺、时间尺
RulerView 是一个 Android 卷尺系列控件,包含普通卷尺(体重尺)、金额尺(MoneySelectRuleView)和时间尺(TimeRuleView)三种控件,被大量开发者用来做体重选择、金额选择、时间选择等场景。本文是源码解析第二篇,带你彻底弄懂一个核心问题:为什么卷尺松手后会自动吸附到最近的刻度线?它背后正是 Android 自定义 View 惯性滑动的经典组合——VelocityTracker 追踪滑动速度 + Scroller 驱动惯性滚动,最后用Math.round完成"磁吸"对齐。读完这篇文章,你也能给自己的自定义控件加上同样顺滑的惯性滑动体验。
先看下 demo 里实际的效果,感受这个"松手即吸附"的顺滑度:
一、先搞懂整体流程:松手后发生了什么?
很多新手看到"惯性滑动"四个字就绕晕了,其实整个流程可以拆成一条清晰的时间线:
| 阶段 | 发生了什么 | 由谁负责 |
|---|---|---|
| ① 手指按住滑动 | 每收到一个触摸点就记录下来 | VelocityTracker |
| ② 手指离开(ACTION_UP) | 根据记录算出"离开瞬间的速度" | VelocityTracker |
| ③ 速度 ≥ 阈值 | 卷尺以这个速度"甩出去",逐渐减速 | Scroller.fling() |
| ④ 速度 < 阈值 | 不走惯性,直接平滑对齐到最近刻度 | scrollToGradation() |
| ⑤ 惯性滚动到终点 | 最后再吸附一次,保证停在刻度线上 | computeScroll() |
一句话总结:速度够快就"甩",速度不够就"贴",无论哪种情况最终都会落在整数刻度上。
三个关键常量在构造函数里就取自系统,保证和系统控件(如 ViewPager)手感一致,见 RuleView.java:
ViewConfiguration viewConfiguration = ViewConfiguration.get(context); TOUCH_SLOP = viewConfiguration.getScaledTouchSlop(); // 滑动阈值 MIN_FLING_VELOCITY = viewConfiguration.getScaledMinimumFlingVelocity(); // 惯性最小速度 MAX_FLING_VELOCITY = viewConfiguration.getScaledMaximumFlingVelocity(); // 惯性最大速度💡
TOUCH_SLOP是"误触保护":手指按下后位移小于这个值,视为点击而非滑动,避免手抖导致刻度乱跳。
二、关键步骤详解
步骤1:VelocityTracker 持续收集触摸点
速度不是一次算出来的,而是靠积累出来的。onTouchEvent()一进来就先干两件事:获取跟踪器(懒加载),然后把当前触摸点喂给它,见 RuleView.java#L316-L325:
if (mVelocityTracker == null) { mVelocityTracker = VelocityTracker.obtain(); } mVelocityTracker.addMovement(event);obtain():从对象池取实例(VelocityTracker 是重量级对象,用完应recycle())addMovement(event):把每个触摸事件都记下来,内部维护一个带时间戳的点序列
可以把它理解成"行车记录仪":它不关心你现在滑到哪,只负责把"每个时刻你在哪"都记下来。
步骤2:ACTION_UP 时计算速度并做二选一判断
手指抬起的瞬间,才是"算速度"的时机。核心代码见 RuleView.java#L347-L361:
case MotionEvent.ACTION_UP: // 计算速度:使用1000ms为单位 mVelocityTracker.computeCurrentVelocity(1000, MAX_FLING_VELOCITY); // 获取速度。速度有方向性,水平方向:左滑为负,右滑为正 int xVelocity = (int) mVelocityTracker.getXVelocity(); // 达到速度则惯性滑动,否则缓慢滑动到刻度 if (Math.abs(xVelocity) >= MIN_FLING_VELOCITY) { // 速度具有方向性,需要取反 mScroller.fling((int) mCurrentDistance, 0, -xVelocity, 0, 0, (int) mNumberRangeDistance, 0, 0); invalidate(); } else { scrollToGradation(); } break;这里有 4 个新手极易踩坑的细节:
computeCurrentVelocity(1000):参数1000表示速度单位是 px/s(默认是 px/1000ms,换算成每秒速度要乘 10)。getXVelocity()有正负:右滑为正、左滑为负,这是"手指的速度",不是"刻度移动的速度"。- fling 时速度取反
-xVelocity:手指向右滑,刻度应该向左走(就像用手指推尺子),两者方向天然相反,所以必须取反。这是源码注释里专门强调的点。 MIN_FLING_VELOCITY是分流开关:慢速松手说明用户只是"微调了一下",此时甩出去反而体验很差,直接吸附(scrollToGradation())更合理。
步骤3:Scroller.fling —— 让卷尺自己"跑"起来
fling()是 Scroller 的精髓:给它一个初速度和滑动范围,它内部用减速曲线算出每一帧应该停在哪个位置,但注意——Scroller 自己不会触发重绘,它只负责"算位置",真正的"动起来"靠的是每帧回调。
fling的参数含义(对应上面代码):
| 参数 | 传值 | 含义 |
|---|---|---|
| startX | (int) mCurrentDistance | 当前已滚动的距离(起点) |
| startY | 0 | 只用水平方向,忽略 |
| velocityX | -xVelocity | 初速度(取反后) |
| minX / maxX | 0/mNumberRangeDistance | 滑动范围,防止甩出最大值/最小值 |
mCurrentDistance是"当前值距最小值的像素距离",它是整个控件的唯一真实数据源,所有数值都由它反推("数值×10转int"防浮点精度问题的思路见 convertValue2Number())。
步骤4:computeScroll() 逐帧驱动 + 终点吸附
惯性滚动期间,系统每帧会回调computeScroll(),这是"动画驱动"的标准写法,见 RuleView.java#L402-L412:
@Override public void computeScroll() { if (mScroller.computeScrollOffset()) { // 还没滑完,返回true if (mScroller.getCurrX() != mScroller.getFinalX()) { mCurrentDistance = mScroller.getCurrX(); // 更新当前距离 calculateValue(); // 重算数值 + 触发重绘 } else { scrollToGradation(); // 到达终点:吸附到刻度 } } }流程是:computeScrollOffset()返回 true 说明还在滚动 → 取本帧应处的位置getCurrX()更新mCurrentDistance→ 重算数值并invalidate()触发重绘 →当getCurrX()追上getFinalX()(到达终点)时,调用scrollToGradation()做最后一次吸附。
吸附的核心:一行 Math.round
"为什么松手会吸附到最近刻度"的答案,浓缩在 scrollToGradation() 里:
private void scrollToGradation() { // 距离 ÷ 刻度间距,四舍五入得到"最近的第几格",再乘回去 mCurrentNumber = mMinNumber + Math.round(mCurrentDistance / gradationGap) * mNumberUnit; mCurrentNumber = Math.min(Math.max(mCurrentNumber, mMinNumber), mMaxNumber); // 边界保护 mCurrentDistance = (mCurrentNumber - mMinNumber) / mNumberUnit * gradationGap; currentValue = mCurrentNumber / 10f; ... }原理非常朴素:当前距离 ÷ 一格刻度宽度 = 当前在第几格(小数),Math.round四舍五入 = 最近整数格,再乘回去 = 最近刻度的精确位置。这就完成了"磁吸"。之后再Math.min/Math.max夹在[min, max]范围内,保证极端甩动也不会越界。
📌 注意这里吸附是直接跳变到目标刻度(而非再做一段动画),因为 fling 终点本来就离目标刻度很近,直接对齐视觉上是"落定",体验反而更干脆。
别忘了:手指再次按下要"急停"
如果惯性还在滚动时用户又按下了控件,必须立刻终止动画,否则会出现"手指跟手了但画面还在自动走"的错乱。ACTION_DOWN分支里一行代码解决,见 RuleView.java#L326-L330:
case MotionEvent.ACTION_DOWN: mScroller.forceFinished(true); // 强制结束惯性滚动 mDownX = x; isMoved = false; break;外部调用setCurrentValue()跳转数值时同样会先forceFinished(true)再启动新的平滑滚动,见 setCurrentValue(),避免两套滚动叠加打架。
三、另外两个控件:同套路,小差异
这套"VelocityTracker + Scroller + 吸附"的骨架在另外两个控件里完整复用,但细节上各有取舍。
MoneySelectRuleView(金额尺):吸附逻辑与 RuleView 一致——Math.round(mCurrentDistance / unitGap) * valueUnit对齐到百元整数刻度,见 scrollToGradation()。它额外处理了ACTION_CANCEL场景:触摸序列被系统打断(如来电弹窗)时,用abortAnimation()结束动画并直接把数值置为终点值,见 onTouchEvent(),比单纯forceFinished更严谨。
TimeRuleView(时间尺):支持惯性滑动(fling逻辑完全相同,见 onTouchEvent()),但它的computeScroll()里没有再做吸附——因为时间尺最小单位是秒级、数值精度本身就足够高,"吸附"没有意义,反而需要秒级精确定位,见 computeScroll()。这也提醒我们:吸附不是必须的,是否吸附取决于你的业务是否需要"档位"概念。
四、要点清单(建议收藏)
- VelocityTracker 只记不算:
addMovement()持续喂点,computeCurrentVelocity(1000)在 ACTION_UP 时才算出 px/s 速度。 - 速度有方向:手指速度与刻度移动方向相反,
fling时必须传-xVelocity。 - MIN_FLING_VELOCITY 是分流器:快速→fling 惯性甩出;慢速→直接吸附,两条路殊途同归。
- Scroller 只算不画:真正驱动靠
computeScroll()逐帧取getCurrX()并invalidate()。 - 吸附就是一行
Math.round:距离 ÷ 刻度间距四舍五入,再乘回,配合 min/max 边界保护。 - ACTION_DOWN 必须
forceFinished(true):中断惯性动画,保证重新跟手。 - 阈值取系统默认(
ViewConfiguration),手感与系统控件保持一致。
这套模式几乎是 Android 所有"可滑动选择器"的通用解法——体重秤、金额输入、日历滚轮、日期选择器,本质上都是同一套 VelocityTracker + Scroller + 吸附公式的变体。理解了它,你就拿到了自定义滑动控件的万能钥匙 🔑。
相关源码速查:
- 核心控件源码:RuleView.java
- 金额尺源码:MoneySelectRuleView.java
- 时间尺源码:TimeRuleView.java
- 自定义属性定义:attrs.xml
- 使用与属性说明:README.md
【免费下载链接】RulerView
卷尺系列控件,包含:普通卷尺(如:体重)、金额尺、时间尺
相关推荐
Buzz 字幕调整指南:把过短或过长的字幕快速改到合适长度
Buzz 字幕调整指南:把过短或过长的字幕快速改到合适长度 刚导出的字幕文件经常是这样的:一行只有两三个字,或者一句话被挤得超长,贴到播放器里根本没法看。Buz
语音AI 应用本地部署桌面应用为什么SFFocusViewLayout滑动如此丝滑?UICollectionViewLayout核心机制深度解析
为什么SFFocusViewLayout滑动如此丝滑?UICollectionViewLayout核心机制深度解析 你有没有在 iOS 应用里刷到过这种列表:中
Qwen3.8-27B-AP-GGUF完整解析:为什么它是同尺寸下最接近Qwen原版的GGUF量化包
Qwen3.8 27B AP GGUF完整解析:为什么它是同尺寸下最接近Qwen原版的GGUF量化包 Qwen3.8 27B AP GGUF 是开源模型 Qwe
人工智能大模型模型量化本地部署
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考