ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

RulerView源码解析之二:为什么卷尺松手会吸附到最近刻度?VelocityTracker+Scroller惯性滑动详解

RulerView源码解析之二:为什么卷尺松手会吸附到最近刻度?VelocityTracker+Scroller惯性滑动详解

【免费下载链接】RulerView

卷尺系列控件,包含:普通卷尺(如:体重)、金额尺、时间尺

项目地址:https://gitcode.com/gh_mirrors/ru/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 个新手极易踩坑的细节:

  1. computeCurrentVelocity(1000):参数1000表示速度单位是 px/s(默认是 px/1000ms,换算成每秒速度要乘 10)。
  2. getXVelocity()有正负:右滑为正、左滑为负,这是"手指的速度",不是"刻度移动的速度"。
  3. fling 时速度取反-xVelocity:手指向右滑,刻度应该向左走(就像用手指推尺子),两者方向天然相反,所以必须取反。这是源码注释里专门强调的点。
  4. MIN_FLING_VELOCITY是分流开关:慢速松手说明用户只是"微调了一下",此时甩出去反而体验很差,直接吸附(scrollToGradation())更合理。

步骤3:Scroller.fling —— 让卷尺自己"跑"起来

fling()是 Scroller 的精髓:给它一个初速度和滑动范围,它内部用减速曲线算出每一帧应该停在哪个位置,但注意——Scroller 自己不会触发重绘,它只负责"算位置",真正的"动起来"靠的是每帧回调。

fling的参数含义(对应上面代码):

参数传值含义
startX(int) mCurrentDistance当前已滚动的距离(起点)
startY0只用水平方向,忽略
velocityX-xVelocity初速度(取反后)
minX / maxX0/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()。这也提醒我们:吸附不是必须的,是否吸附取决于你的业务是否需要"档位"概念。


四、要点清单(建议收藏)

  1. VelocityTracker 只记不算:addMovement()持续喂点,computeCurrentVelocity(1000)在 ACTION_UP 时才算出 px/s 速度。
  2. 速度有方向:手指速度与刻度移动方向相反,fling时必须传-xVelocity。
  3. MIN_FLING_VELOCITY 是分流器:快速→fling 惯性甩出;慢速→直接吸附,两条路殊途同归。
  4. Scroller 只算不画:真正驱动靠computeScroll()逐帧取getCurrX()并invalidate()。
  5. 吸附就是一行Math.round:距离 ÷ 刻度间距四舍五入,再乘回,配合 min/max 边界保护。
  6. ACTION_DOWN 必须forceFinished(true):中断惯性动画,保证重新跟手。
  7. 阈值取系统默认(ViewConfiguration),手感与系统控件保持一致。

这套模式几乎是 Android 所有"可滑动选择器"的通用解法——体重秤、金额输入、日历滚轮、日期选择器,本质上都是同一套 VelocityTracker + Scroller + 吸附公式的变体。理解了它,你就拿到了自定义滑动控件的万能钥匙 🔑。

相关源码速查:

  • 核心控件源码:RuleView.java
  • 金额尺源码:MoneySelectRuleView.java
  • 时间尺源码:TimeRuleView.java
  • 自定义属性定义:attrs.xml
  • 使用与属性说明:README.md

【免费下载链接】RulerView

卷尺系列控件,包含:普通卷尺(如:体重)、金额尺、时间尺

项目地址:https://gitcode.com/gh_mirrors/ru/RulerView
点击查看免费下载

相关推荐

上一篇:LX Music桌面版:免费的跨平台音乐聚合播放器
下一篇:魔兽争霸3终极兼容性指南:5分钟解决经典游戏在现代系统的所有问题

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

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