简介: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时置为 true,ACTION_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里因为符号写反,整整调了一个下午才把方向捋顺,后来所有手势相关代码都强制自己把坐标轴画出来,省下无数后悔药时间。希望这篇拆解对你也有同样的价值。
本文还有配套的精品资源,点击获取