
简介Android自定义上下滚动控件项目资源面向需要实现类似密码盘数字滚动效果的开发者适用于自定义输入界面、动态数据展示等场景可作为自定义View学习与改造的参考。资源从基础类创建、onDraw绘制、触摸事件处理、ValueAnimator动画到自定义属性均有完整代码适合初、中级开发者深入理解Android自定义控件的核心流程。压缩包共76个文件包含java源码、class编译产物、xml布局与配置、png图标素材以及可直接安装的apk测试包整体仅1.55MB目录结构清晰便于按模块查阅。已有403人学习使用。项目附带了UpDownScrollViewTest测试用例覆盖主要交互场景并适配了drawable-ldpi到drawable-xxhdpi等多密度资源可直接导入工程运行快速看到数字上下滚动的实际效果也可作为自定义控件的代码模板在此基础上扩展滚动速度、字体样式等属性。1. 自定义上下滚动控件为什么系统自带的滚动不够用接手一个模拟项目X的时候内容是一个高度可变的卡片流需求是让它能像橡皮筋一样拉到边界再弹回来同时滚动到后半段要有明显的阻尼减速。先把系统自带的 ScrollView 和 RecyclerView 拉出来试了一圈ScrollView 没有回弹惯性参数的调节全靠系统默认RecyclerView 的回收复用机制在这种固定内容列表上显得臃肿而且两者对滚动偏移的控制都是黑匣子没法在滚动过程中插入自己的逻辑。于是决定自己写一个自定义上下滚动控件继承最外层布局容器通过重写测量规则、触摸事件和滚动计算把上下滚动这件事彻底握在自己手里。这套实现适合需要定制回弹、阻尼、吸附等滚动行为的场景也适合把 Android 触摸和滑动原理摸清楚的人。整个拆解过程下面按模块展开每一步都有可以直接落地的代码。2. 滚动控件的基本功测量、触摸与动画基础在写任何自定义滚动控件之前心里必须先有一张图滚动控件的本质是内容高度大于视口高度通过改变子 View 的绘制偏移量来实现内容移动。这个偏移量就是 scrollY。所以第一件事是让父控件准确地知道「内容到底有多高」第二件事是决定「手指触摸事件怎么分派」第三件事是让「惯性滑动有动画载体」。这三件事分别对应测量、触摸和 OverScroller缺一个都会让滚动变得不跟手。2.1 测量让内容高度决定滚动范围自定义上下滚动控件最常见的父容器选择是 FrameLayout因为它不需要复杂的布局规则只需要把第一个子 View 的内容区域完整地测量出来。在 onMeasure 里我给子 View 的宽度约束沿用父容器的宽度规格高度规格则用 UNSPECIFIED表示不限制高度让子 View 按自身内容自然测量。override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { // 取第一个子View后续滚动就是平移这个View val child getChildAt(0) ?: return val widthSize MeasureSpec.getSize(widthMeasureSpec) // 宽度用EXACTLY保证子View横向填满父容器 // 高度用UNSPECIFIED让子View按内容真实测量 child.measure( MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) ) // 记录内容总高度 mContentHeight child.measuredHeight // 自己作为容器视口高度由父布局决定 val viewHeight MeasureSpec.getSize(heightMeasureSpec) setMeasuredDimension(widthSize, viewHeight) // 内容高度减去视口高度就是最大滚动范围如果内容不够高则不需要滚动 mMaxScrollY (mContentHeight - viewHeight).coerceAtLeast(0) }这段代码里有一个关键参数MeasureSpec.UNSPECIFIED。它告诉子 View 可以有多高就有多高不要因为父容器的高度而截断自己。如果这里用了父容器的高度尺寸子 View 会被测量成视口高度内容高度永远等于视口高度滚动范围就是 0。常见做法是在这里再加一个缓存比如onMeasure里不重复调用child.measure而是判断布局是否有效否则每次测量都会触发子 View 的重新布局在复杂内容上是性能痛点。2.2 触摸事件拦截与消费的分工滚动控件通常包含可以点击、长按、横向滑动的子 View。如果不做事件拦截这些手势会先被我们自己处理导致子 View 永远收不到按压事件。所以第一道关卡是onInterceptTouchEvent它负责决定「当前这串事件流是交给子 View 还是我自己」。override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { when (ev.actionMasked) { MotionEvent.ACTION_DOWN - { // 记录起始滑动位置同时终止正在进行的惯性滚动 mLastY ev.rawY.toInt() mScroller.abortAnimation() return false } MotionEvent.ACTION_MOVE - { // 位移超过系统触摸阈值后认为用户意图是垂直滚动 if (abs(ev.rawY.toInt() - mLastY) mTouchSlop) { // 告诉父容器不要拦截后续事件 parent.requestDisallowInterceptTouchEvent(true) return true } } } return super.onInterceptTouchEvent(ev) }这里mTouchSlop是ViewConfiguration.get(context).scaledTouchSlop拿到的系统阈值通常约等于 8dp。它存在的意义是过滤掉手指按下的微小抖动否则用户只是想点击一个按钮就被判定成滚动手势。requestDisallowInterceptTouchEvent(true)是嵌套滚动里的老朋友它让外层父容器在遇到左右滑、下拉刷新这类手势冲突时优先把这个控件的手势放行。注意返回false并不意味着不再拦截因为每个新的触摸事件序列都会重新走onInterceptTouchEvent。2.3 OverScroller 与惯性滑动的配合手指快速甩动后控件需要自动滚动一段距离再停下来。这个动画计算不用手动去插值官方提供了两个工具Scroller和OverScroller。前者不带越界回弹能力后者自带springBack和越界追踪适合做边界回弹效果。我一般直接选OverScroller即使在普通滚动场景也不会有额外负担。override fun computeScroll() { // OverScroller内部通过时间插值计算当前应该滚动的位置 if (mScroller.computeScrollOffset()) { scrollTo(mScroller.currX, mScroller.currY) // 动画没结束时继续触发下一帧重绘 postInvalidateOnAnimation() } }computeScroll是View的一个钩子会在绘制流程里被调用。只要computeScrollOffset返回true说明动画还在进行我们就手动scrollTo到当前位置并请求下一帧。这里有一个容易忽略的细节OverScroller的currY是动画当前值不是增量帧所以必须用scrollTo而不是scrollBy否则会把上次的位置叠加上去。要触发惯性滑动还需要从触摸事件里把速度算出来。VelocityTracker是标准做法它会根据手指移动的时间序列估算像素/秒速度。在ACTION_UP里拿到yVelocity后把它传给fling方法private fun flingContent(velocityY: Int) { mScroller.fling( scrollX, scrollY, 0, velocityY, 0, 0, -mOverDistance, mMaxScrollY mOverDistance, 0, 0 ) // 让computeScroll立刻被回调 postInvalidateOnAnimation() }参数含义拆开看前两个是起始滚动位置中间两个是初始速度紧接着是 x 方向的最小/最大边界然后 y 方向的最小/最大边界。mOverDistance是允许滚出边界的额外距离这个值对回弹手感影响很大太大会让人觉得控件很散太小又感觉不到回弹。拿到的velocityY是坐标系里的原始速度手指上滑时为负而scrollY向上滚动为正所以「手指快速上滑 → 内容继续向上滚」需要把速度取反这也是很多人首次在fling里翻车的原因。有了这三个基本功控件骨架已经能立起来了。但真正要手感好还需要在测量、事件、动画之间做调参这些放到下一章一起讲。3. 从零实现一个上下滚动控件核心代码与参数说明这一章直接给出一个可以跑通的最小实现。我用的是 Kotlin继承FrameLayout。整个控件不依赖外部库所有逻辑都集中在四个方法里onMeasure、onInterceptTouchEvent、onTouchEvent、computeScroll外加两个私有辅助方法。3.1 控件骨架与构造方法先把成员变量和构造方法搭好。OverScroller是滚动动画的核心它的位置和速度状态都在内部维护。class VerticalScrollLayout JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : FrameLayout(context, attrs, defStyleAttr) { // 惯性滚动器支持越界回弹 private val mScroller OverScroller(context, DecelerateInterpolator()) // 内容总高度每次onMeasure后更新 private var mContentHeight 0 // 最大滚动距离内容高度 - 视口高度 private var mMaxScrollY 0 // 手指上一次触摸的原始Y坐标 private var mLastY 0 // 系统判定为滑动的距离阈值 private var mTouchSlop 0 // 越界可滚动的额外距离用于回弹 private var mOverDistance 200 init { mTouchSlop ViewConfiguration.get(context).scaledTouchSlop // 必须设为可点击否则部分设备上ACTION_DOWN后事件序列会被吃掉 isClickable true } }这里有个经验isClickable true在触碰事件处理里非常关键。如果控件不可点击父容器在某些情况下会把触摸事件直接判为不消费导致你收不到后续的ACTION_UP惯性滑动永远触发不了。另外mOverDistance的单位是像素200px 在 3 倍屏上大约是 67dp属于比较克制的回弹距离可以根据实际 UI 调整。3.2 onMeasure 与内容高度计算上一章已经给出了初步的测量逻辑这里把防御性处理补全。比如内容高度小于视口高度时要让mMaxScrollY归零所有滚动逻辑都应该依赖这个值而不是直接判断内容大小。override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { // 没有子View时直接给一个默认尺寸避免拿到空指针 if (childCount 0) { setMeasuredDimension( MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec) ) return } val child getChildAt(0) val widthSize MeasureSpec.getSize(widthMeasureSpec) child.measure( MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) ) mContentHeight child.measuredHeight val viewHeight MeasureSpec.getSize(heightMeasureSpec) // 最小滚动范围不能为负数 mMaxScrollY (mContentHeight - viewHeight).coerceAtLeast(0) setMeasuredDimension(widthSize, viewHeight) }注意这里getChildAt(0)只取第一个子 View这是一个约定这种上下滚动控件只应该有一个内容子 View所有需要滚动的内容都要包在这个子 View 里。如果内容里包含多个 View应该先用一个LinearLayout包住再放进来。测量完成后scrollY的有效范围是0..mMaxScrollY。为什么不用getChildAt(0).bottom来算高度因为子 View 的bottom在没有完成布局时是不准确的只有在layout阶段之后才可靠onMeasure阶段只能依赖measuredHeight。3.3 onTouchEvent 实现拖动与惯性事件分发里onTouchEvent负责具体消费。这里需要维护一个VelocityTracker并在ACTION_MOVE中把位移换算成dy再更新滚动位置。override fun onTouchEvent(event: MotionEvent): Boolean { // 懒加载VelocityTracker避免每次触摸都重新创建 if (mVelocityTracker null) { mVelocityTracker VelocityTracker.obtain() } mVelocityTracker!!.addMovement(event) when (event.actionMasked) { MotionEvent.ACTION_DOWN - { mLastY event.rawY.toInt() mScroller.abortAnimation() return true } MotionEvent.ACTION_MOVE - { val curY event.rawY.toInt() // 手指上滑时 rawY 变小dy 为正scrollY 应该增大 val dy mLastY - curY mLastY curY scrollContentBy(dy) return true } MotionEvent.ACTION_UP - { // 让VelocityTracker累计所有move事件后计算最终速度 mVelocityTracker!!.computeCurrentVelocity(1000) val vy mVelocityTracker!!.yVelocity.toInt() // 只有速度超过阈值才触发惯性否则直接回弹 if (abs(vy) ViewConfiguration.get(context).scaledMinimumFlingVelocity) { flingContent(-vy) } else { springBackIfNeeded() } releaseTracker() return true } MotionEvent.ACTION_CANCEL - { releaseTracker() return false } } return super.onTouchEvent(event) } private fun scrollContentBy(dy: Int) { val current scrollY var targetY current - dy // 越界阻尼超出边界后继续拉动但位移只按比例传递 if (targetY 0) { targetY (targetY * 0.4f).toInt() } else if (targetY mMaxScrollY) { val over targetY - mMaxScrollY targetY mMaxScrollY (over * 0.4f).toInt() } scrollTo(scrollX, targetY) } private fun releaseTracker() { mVelocityTracker?.recycle() mVelocityTracker null }这里dy的正负号是一个高频踩坑点。scrollContentBy里是current - dy而ACTION_MOVE里是mLastY - curY两者结合后手指上滑时curY变小 →dy为正 → 目标位置scrollY - dy变小等等这里要注意我在scrollContentBy里写的是targetY current - dy但scrollTo用的是targetY作为新的 scrollY。手指上滑时dy mLastY - curY 正数那么targetY current - 正数scrollY 会变小而内容向上滚动时 scrollY 应该变大这不是反了吗如果按标准 Android 坐标手指上滑 → scrollY 增大。错误的写法会导致内容反向移动。为了避免混淆常见做法是在scrollContentBy里直接用scrollBy(0, dy)scrollBy内部就是scrollY dy配合上滑dy为正自然就向上滚了。上面代码里写targetY current - dy其实是反向的。让我修正一下作为一篇严谨的博文应该写成private fun scrollContentBy(dy: Int) { var targetY scrollY dy // 阻尼逻辑这里省略 ... scrollTo(scrollX, targetY) }这样手指上滑 dy 为正scrollY 增大内容向上移动符合直觉。前面 flingContent 中传入-vy也要再确认VelocityTracker 的 yVelocity 在手指上滑时为负值-vy就是正值而 OverScroller.fling 的 velocityY 正数表示向上滚动所以-vy是正确的。这样逻辑就自洽了。在真实项目中我一般会在scrollContentBy里加一个mIsDragging标记用来区分是用户拖动还是程序滚动这个标记在ACTION_DOWN时置为 trueACTION_UP时置为 false回弹和惯性逻辑里会用到它。3.4 边界回弹与阻尼效果边界回弹分两步拖动时超过边界的阻尼以及松手后回弹到边界。阻尼已经在scrollContentBy里实现了系数 0.4 表示超出部分的位移只有 40% 生效这让用户感觉到越拖越费劲。松手后的回弹用OverScroller.springBack实现它可以平滑地回到合法边界不需要自己写动画。private fun springBackIfNeeded() { val targetY when { scrollY 0 - 0 scrollY mMaxScrollY - mMaxScrollY else - -1 } if (targetY 0) { mScroller.springBack( scrollX, scrollY, 0, 0, 0, targetY, 0, mMaxScrollY ) postInvalidateOnAnimation() } }springBack的入参顺序是当前 x、当前 y、x 的 min/max、y 的 min/max注意这里传的是目标边界而不是越界距离。它会自动根据当前位置计算需要回弹的动画时长内部使用插值器手感比手动ValueAnimator实现要顺滑得多。我一般会在computeScroll里同时处理惯性滚动和回弹因为两者都会通过computeScrollOffset驱动不需要额外区分来源。如果要做更强的回弹效果比如公开一个setOverDistance方法让外部配置距离可以把它暴露成自定义属性。参数不宜过大200-400px 是一个常用区间具体数值要看产品想要的「软硬程度」。4. 避坑与常见问题排查上下滚动控件的 5 个翻车现场前面几章把控件搭起来了但在真实项目里尤其是把控件塞进复杂页面后各种奇怪的问题才会暴露出来。这一章集中记录我在模拟项目X里排查过的几个典型问题每条都是「现象 → 原因 → 解决」的路径很多问题只靠看代码是看不出来的。4.1 横向滑动被垂直滚动无脑拦截现象控件内部的横向RecyclerView在手指斜着滑动时非常不跟手横向滑动的距离一长垂直滚动控件就把事件抢走了。原因onInterceptTouchEvent里只要有位移就拦截没有判断这句话是标准做法吗不对。实际上我在初版代码里直接判断abs(dy) mTouchSlop没有考虑dx。斜着滑动时 dy 很容易达到阈值导致本应给横向列表的手势被垂直滚动消耗。解决在ACTION_MOVE里同时记录 x 和 y 的位移只有dy dx且dy mTouchSlop时才认为自己要处理MotionEvent.ACTION_MOVE - { val dx abs(ev.rawX.toInt() - mLastX) val dy abs(ev.rawY.toInt() - mLastY) if (dy mTouchSlop dy dx) { parent.requestDisallowInterceptTouchEvent(true) return true } }记得在ACTION_DOWN里保存mLastX。这个条件同时过滤掉纯横向手势让内部横向控件能正常工作。从那以后我每写一个自定义滚动控件都会默认把「角度判断」加到拦截逻辑里。4.2 手指松开后回弹停在错误位置现象往下拉到超出边界后松手控件没有回到 0 位置而是停在 0 和最大边界之间的某个整数上第二次拖动时 scrollY 还会轻微跳动。原因springBack后没有等待动画结束用户的第二次ACTION_DOWN触发了mScroller.abortAnimation()但此时scrollY还处在动画中间值导致后续计算基于一个非整数位置。另一个常见原因是computeScroll里scrollTo使用了currY而currY是 float 转 int 时有截断误差。解决在自己的ACTION_DOWN里先判断mScroller.isFinished()如果动画没有结束不要直接abort并重置mLastY而是把scrollY先取整到目标位置附近或者强制mScroller.springBack到最近合法值。最简单的方式是在abortAnimation之后立刻执行一次scrollTo(scrollX, scrollY.coerceIn(0, mMaxScrollY))保证起始位置合法。MotionEvent.ACTION_DOWN - { mScroller.abortAnimation() // 修正可能落在非法区域的scrollY scrollTo(scrollX, scrollY.coerceIn(0, mMaxScrollY)) mLastY event.rawY.toInt() return true }这样能消除大部分跳位问题。如果还出现需要检查scrollY在computeScroll里是否被多次赋值确保scrollTo每次都是基于最新动画位置。4.3 惯性滚动反向且速度越来越大现象手指快速上滑后内容先向上滚再突然反向冲回来或者一直向上滚停不下来。原因fling的velocityY参数符号搞反了导致OverScroller认为是要向下滚动。更隐蔽的是VelocityTracker.computeCurrentVelocity默认用的是非向下版本的坐标所以符号需要与控件坐标系配合。解决在flingContent内部统一做一次坐标转换把传入的速度转成「内容向上滚动为正」的方向。我会在ACTION_UP里这样写val vy mVelocityTracker!!.yVelocity.toInt() if (abs(vy) ViewConfiguration.get(context).scaledMinimumFlingVelocity) { // 手指上滑时 vy 为负取反后传给fling flingContent(-vy) }然后在flingContent里打印velocityY和scrollY的初始值确认方向。调试时可以在一个设备上反复快速滑动把日志打出来对照一下符号与自己假设是否一致。惯性速度过大通常是因为没有做速度钳制我用velocityY.coerceIn(-12000, 12000)限制一下防止在低端机上出现快速翻页。4.4 内容里有EditText点击聚焦时控件乱滚现象在卡片流里点一个EditText软键盘弹出来控件自动滚到顶部或者出现跳动。原因默认情况下EditText在获得焦点时会请求父容器滚动到它的可视区域系统会调用scrollTo来让焦点可见而我们的自定义控件没有处理焦点的requestRectangleOnScreen导致scrollY被系统改到未知位置。解决在自定义控件里重写requestChildRectangleOnScreen直接返回false告诉父容器我们不想自动滚动override fun requestChildRectangleOnScreen( child: View, rectangle: Rect, immediate: Boolean ): Boolean { return false }如果你确实需要软键盘弹出后把焦点项滚到可见区域那就要自己计算位置定义一套「目标 scrollY」的规则而不是放任系统随机调整。我一般选择不做自动滚动让用户手动滚到自己想要的位置这样交互更加可控。4.5 滚动监听时机太晚错过内容对齐的状态现象控件滚到指定位置后需要同步更新外部的联动 UI但 UI 更新总是慢一拍有时还跳两次。原因外部监听的是onScrollChanged回调但该回调在scrollTo过程中被频繁调用而且最后一帧动画结束时可能没有回调导致外部拿到的scrollY不是最终值。解决在computeScroll里判断computeScrollOffset() false时单独发布一次「滚动结束」通知。我设了一个Zoom阻尼回调接口override fun computeScroll() { val more mScroller.computeScrollOffset() if (more) { scrollTo(mScroller.currX, mScroller.currY) postInvalidateOnAnimation() } else if (!mScroller.isFinished) { // 这一帧动画结束通知外部最终位置 mScroller.isFinished true onScrollStateListener?.invoke(scrollY) } }注意调用顺序isFinished在动画结束时需要手动标记否则下次ACTION_DOWN里的abortAnimation会破坏状态。这个坑的根源是我一开始以为computeScrollOffset返回false后状态就自动正确了其实它的内部状态需要重新初始化。5. 让控件更好用的两个进阶特性滚动监听与边缘吸附基础滚动做出来后往往还要给控件加一些「舒服」的特性。这里分享两个我认为性价比最高的扩展也是我在多个滚动需求里反复用到的。5.1 滚动状态监听把开始、进行、结束三个阶段暴露给外部很多场景需要知道控件当前是否在滚动比如联动一个侧边字母索引滚动中高亮、滚动结束固定。我在控件里定义了一个简单的接口interface ScrollStateListener { fun onScrollChanged(scrollY: Int, contentHeight: Int, maxScrollY: Int) fun onScrollStart() fun onScrollEnd() }在ACTION_DOWN里调用一次onScrollStart()在拖动和惯性过程中调用onScrollChanged在computeScroll动画结束时调用onScrollEnd()。注意onScrollEnd()不能只放在ACTION_UP里因为惯性滚动在松手后才持续。我建议把mIsScrolling标志放在ACTION_DOWN和动画结束点这样能保证回调顺序不出错。5.2 边缘吸附松手后对齐到最近的卡片边界以卡片流为例每张卡片高度 160dp希望用户松手后内容自动停在卡片边界上避免看到卡片被截断。实现思路是ACTION_UP时根据scrollY对卡片高度取余判断偏差是否超过一半然后选最近的卡片位置用mScroller.startScroll平滑滚过去。private fun snapToCard() { val cardHeight cardHeightPx() val page (scrollY cardHeight / 2) / cardHeight val targetY page * cardHeight mScroller.startScroll( scrollX, scrollY, 0, targetY - scrollY, 250 ) postInvalidateOnAnimation() }参数里 250ms 是吸附动画的时长卡片流用 250-300ms 比较舒适。这个功能需要防护如果内容最后一张卡片不足一个卡片高度目标位置要钳制到mMaxScrollY否则会滚过头。我在这里吃过一次亏某次直接把 page * cardHeight 交给scrollTo结果最后一块空白色块被滚进来了还是靠日志定位到是边界钳制漏了。做了这么多滚动控件我养成了一个习惯每次拿到新需求先在纸上画出坐标系、正向方向、越界范围写清楚指头运动和 scrollY 的关系再动手。那次在模拟项目X里因为符号写反整整调了一个下午才把方向捋顺后来所有手势相关代码都强制自己把坐标轴画出来省下无数后悔药时间。希望这篇拆解对你也有同样的价值。本文还有配套的精品资源点击获取