简介:一份面向安卓开发者的自定义控件学习资料,实现类似行李箱密码盘的数字上下滚动选择效果。压缩包内是完整的安卓工程,包含Java源码、界面布局与图片资源、可运行安装包及测试用例,适合学习自定义视图绘制、触摸事件与动画机制的初中级开发者。资源共计七十六个文件,其中图片二十九张、编译后的类文件十七个、界面与配置文件十五个、源码四个,另含库文件和安装包,整体仅一点五五兆,结构轻量清晰。目前已有四百零三人学习下载。开发者可通过源码了解从自定义类创建、绘制视图、手势滑动处理到动画平滑滚动的完整实现,配合测试用例与示例工程快速验证效果,便于迁移到密码输入、数据选择等场景中。
1. 自定义上下滚动控件:什么时候才需要自己写滚动
“自定义上下滚动控件”这个标题看起来朴素,背后管着三类常见需求:纵向翻页卡片、长图预览、以及数据驱动场景下需要精确控制阻尼和回弹的滚动容器。系统默认的滚动组件在这些场景下往往差一口气:想改回弹曲线,没有入口;想精确控制滚动偏移量,事件被内部封装成黑匣子;想在一个页面里嵌套多个滚动态,事件分发马上打架。
我自己的经验是,与其到处找第三方库,不如从事件分发开始自己写一个滚动控件,把控制权全部握在手里。写完之后,把这套逻辑和方法沉淀下来,下次遇到任何纵向滚动态需求都可以直接套用。
这篇笔记是给那些准备动手或已经在踩坑的从业者看的:先讲清滚动底层机制,再给最小实现和参数,最后说排查和进阶。只需要具备自定义 View 的基础,后面代码可以直接抄。
2. 滚动底层机制:scrollTo、scrollBy 与 Scroller 的配合逻辑
自定义上下滚动控件的第一步,不是写事件,而是搞清楚系统里“滚动”到底改了什么。这个底层机制弄不明白,后面所有参数和边界判断都会变成玄学。
2.1 scrollTo 与 scrollBy:滚动的本质是平移视口而不是移动子 View
很多初学者第一次写滚动控件,会去改子 View 的 layout 位置,或者用 setTranslationY 把内容往上推。这种做法在小范围内能用,但一旦涉及惯性、回弹、事件拦截,就会因为“状态分散在多个 View 上”而失控。
View 内部维护了 mScrollX 和 mScrollY 两个字段。scrollTo 是瞬移到指定坐标,scrollBy 是在当前基础上做增量。真正绘制时,Canvas 会先平移到 (-mScrollX, -mScrollY),然后把子 View 画出来。所以滚动不是移动子 View,而是移动了“观察窗口”。
// 绝对滚动:把观察窗口对齐到内容第 800 像素处 scrollTo(0, 800); // 相对滚动:在当前基础上继续往内容下方滚 120 像素 // 等价于 scrollTo(getScrollX(), getScrollY() + 120) scrollBy(0, 120);这段代码说明了两个基本动作。scrollTo 适合做“目标导向”的滚动,比如点击回到顶部;scrollBy 适合做“增量导向”的连续滚动,但连续累加会出现误差累积,后面会详细讲。
判断当前滚动位置要用 getScrollY(),不要用子 View 的 getTop()。getTop() 表示子 View 在父容器里的布局位置,和滚动偏移量无关。这个区分如果不做,事件处理时很容易出现“明明滚到一半,拿到的值却始终是初始值”的现象。
2.2 Scroller:松手后的惯性动画是怎么被驱动的
滚动手势松手后,内容不能立刻停下,需要一个减速动画。Scroller 就是干这个的,但它本身不负责重绘,只负责“根据时间计算当前应该滚到哪个坐标”。真正驱动它跑起来的,是 computeScroll() 回调。
Scroller scroller = new Scroller(context, new DecelerateInterpolator(1.8f)); // 松手后的惯性滚动:velocityY 单位是像素/秒 scroller.fling( getScrollX(), getScrollY(), // 起始坐标 0, velocityY, // x 向速度为 0,y 向速度来自手势 0, 0, // x 方向边界:不横向滚动 0, getMaxScrollY(), // y 方向边界:0 到最大滚动偏移 0, 0 // 先不做 overshoot ); postInvalidateOnAnimation();computeScroll() 是标准的配合写法:
@Override public void computeScroll() { if (scroller.computeScrollOffset()) { scrollTo(scroller.getCurrX(), scroller.getCurrY()); invalidate(); } }DecelerateInterpolator 的参数 1.8f 是减速系数,值越大,初速度衰减越快,滑动距离越短。Scroller 的生命周期从 fling 开始,每次 invalidate 触发下一次 draw,draw 过程中进入 computeScroll(),直到 computeScrollOffset() 返回 false。
一个常见疑问是为什么不用 postInvalidateOnAnimation() 而是 invalidate()。在 computeScroll() 内部用 invalidate() 是系统控件的标准做法,它会把下一帧绘制排进当前线程的 UI 循环,动画结束后computeScrollOffset()返回 false,循环自然停止,不会多刷帧。
2.3 滚动边界怎么算:内容高度、容器高度与最大 scrollY
没有边界的滚动控件就是一个失控的皮筋。滚动边界的核心公式只有一行:
maxScrollY = Math.max(0, contentHeight - viewportHeight);contentHeight 是滚动内容的总高度,viewportHeight 是控件本身的可见高度。Math.max 保证了内容不足一屏时 maxScrollY 为 0,也就是根本不允许滚动。
边界值不能在每次滚动时都重新测量。子 View 的 layout 和测量开销不小,如果手指滑一下、测一遍,卡顿是必然的。我一般会在 onLayout 结束后缓存 maxScrollY,在数据更新或 onSizeChanged 时重新计算。
理解了这个公式,很多问题就清晰了:为什么内容变多时滚动位置会跳动?因为 maxScrollY 变了,而当前 scrollY 又没有及时收敛,两者叠加导致的视觉跳跃。这也是后面进阶章要处理的数据驱动场景的基础。
3. 手写一个最小可用的上下滚动控件:事件链与惯性实现
原理立住之后,可以动手了。这里从一个继承 ViewGroup 的最小实现讲起,不会引入 NestedScrolling 那套复杂接口,先把核心事件链跑通,后续再扩展。
3.1 事件分发三步走:DOWN、MOVE、UP 里各做什么
滚动控件的所有手感,都体现在事件处理里。DOWN 阶段要打断未完成的惯性动画并记录起点,MOVE 阶段判断位移是否超过 touchSlop 并滚动内容,UP 阶段决定是回弹还是进入 fling。
@Override public boolean onTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: if (!scroller.isFinished()) { scroller.abortAnimation(); // 手指按下瞬间打断惯性 } downY = (int) event.getY(); startScrollY = getScrollY(); // 记录手势开始时的滚动位置 isDragging = false; velocityTracker = VelocityTracker.obtain(); velocityTracker.addMovement(event); return true; case MotionEvent.ACTION_MOVE: int currentY = (int) event.getY(); int deltaY = currentY - downY; // 相对按下点的累计位移 if (!isDragging && Math.abs(deltaY) > touchSlop) { isDragging = true; // 越过阈值才进入拖拽状态 getParent().requestDisallowInterceptTouchEvent(true); } if (isDragging) { int targetY = startScrollY + deltaY; // 从起点直接算目标,不逐帧累加 if (targetY < 0) { targetY = (int) (targetY * DAMPING); // 顶部越界部分加阻尼 } else if (targetY > getMaxScrollY()) { targetY = (int) (getMaxScrollY() + (targetY - getMaxScrollY()) * DAMPING); } scrollTo(0, targetY); } velocityTracker.addMovement(event); return true; case MotionEvent.ACTION_UP: velocityTracker.addMovement(event); velocityTracker.computeCurrentVelocity(1000); int velocityY = (int) -velocityTracker.getYVelocity(); // 注意取负号 if (getScrollY() < 0 || getScrollY() > getMaxScrollY()) { springBack(); // 先回弹,再考虑惯性 } else if (Math.abs(velocityY) > flingMinVelocity) { scroller.fling(0, getScrollY(), 0, velocityY, 0, 0, 0, getMaxScrollY(), 0, 0); postInvalidateOnAnimation(); } velocityTracker.recycle(); return true; } return super.onTouchEvent(event); }逻辑说明:MOVE 阶段我没有用scrollBy(0, 每次增量),而是用“起点加上累计位移”的 scrollTo 方式。这样手指快速抖动时,内容不会产生累积误差,线条更跟手。dampping 只作用于越界的那部分位移,正常范围内是 1:1 跟随。
参数说明:touchSlop 用ViewConfiguration.get(context).getScaledTouchSlop()获取,单位是像素,代表系统认可的“最小滑动距离”。DAMPING 建议 0.4f,表示越界时每多滑 1 像素,内容只走 0.4 像素。velocityY 前面的负号很关键,向上滑动时 VelocityTracker 返回负值,而 Scroller 的 fling 需要正值才能让 scrollY 增大。
3.2 fling 的接入:速度获取、取负号与边界裁剪
上一节代码里速度取负号是最容易翻车的地方,这里单独拆开讲。Android 的 Y 轴向下为正,手指向上滑时 getYVelocity() 返回负值。Scroller.fling 的 velocityY 为正时,内容是向 scrollY 增大的方向滚动,也就是往内容下方滚。用户向上滑想看下方内容,两者刚好相反,所以必须取负。
int velocityY = (int) -velocityTracker.getYVelocity();fling 的边界裁剪也不能忽略。数据加载后内容变长,或者控件尺寸变化,fling 进行中 maxScrollY 可能比开始时的值大。最稳妥的做法是在 computeScroll() 里每次滚动前再裁一次:
@Override public void computeScroll() { if (scroller.computeScrollOffset()) { int currY = scroller.getCurrY(); currY = Math.max(0, Math.min(currY, getMaxScrollY())); scrollTo(scroller.getCurrX(), currY); invalidate(); } }这里的 Math.max/min 双重限制,保证动画过程中的任何一帧都不会越界。有些实现只在 fling 入参里限制边界,动画中途边界一变就失效,这属于典型的边界条件遗漏。
computeCurrentVelocity(1000) 的含义是“按 1000 毫秒为窗口计算速度”,返回值是像素/秒。窗口越大,速度越平滑,但对急停手势的响应越迟钝。一般保持 1000 即可,不需要改。
3.3 onLayout 与内容不足一屏时的兜底
滚动控件的内容排布不需要复杂规则,一个纵向排列的 onLayout 就够:
@Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { int childTop = getPaddingTop(); for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); int childHeight = child.getMeasuredHeight(); child.layout(left, childTop, left + child.getMeasuredWidth(), childTop + childHeight); childTop += childHeight; } int viewportHeight = bottom - top; maxScrollY = Math.max(0, childTop - viewportHeight); }内容不足一屏时,maxScrollY 为 0,滚动边界退化成一个点。此时如果用户强行上拉下滑,getScrollY() 会在 0 附近产生越界偏移,松手后需要回弹动画兜底。
private void springBack() { int targetY = Math.max(0, Math.min(getScrollY(), getMaxScrollY())); scroller.startScroll(0, getScrollY(), 0, targetY - getScrollY(), 250); postInvalidateOnAnimation(); }springBack 的本质不是补一个动画,而是把“非法状态收敛回合法状态”。如果省略这步,内容不足一屏时控件会像坏掉的弹簧,卡在越界位置回不来。250ms 的时长适合大多数场景,追求硬朗手感可以缩到 180ms。
4. 把控件参数化:自定义属性与三个必调参数
代码跑通之后,下一步是把写死的常量变成可配置参数。这一步决定了控件是“一次性 Demo”还是“可复用组件”。参数化不只是换个名字,而是把每个参数对应的手感变化讲清楚。
4.1 从 attrs.xml 到构造函数:自定义属性的声明与读取
先在 attrs.xml 里声明属性:
<resources> <declare-styleable name="SimpleVerticalScroll"> <attr name="overScrollDamping" format="float" /> <attr name="flingMinVelocity" format="integer" /> <attr name="springBackDuration" format="integer" /> </declare-styleable> </resources>构造函数里读取并设置默认值:
public SimpleVerticalScroll(Context context, AttributeSet attrs) { super(context, attrs); TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.SimpleVerticalScroll); damping = ta.getFloat(R.styleable.SimpleVerticalScroll_overScrollDamping, 0.4f); flingMinVelocity = ta.getInteger( R.styleable.SimpleVerticalScroll_flingMinVelocity, 3000); springBackDuration = ta.getInteger( R.styleable.SimpleVerticalScroll_springBackDuration, 250); ta.recycle(); }TypedArray 用完后必须 recycle(),否则会占用底层资源。这一步容易被 IDE 的自动补全忽略,短时间看不出问题,项目跑久了才会有资源泄漏的报错。
4.2 三个必调参数:滚动阈值、惯性速度与回弹阻尼
| 参数 | 默认值 | 作用 | 调整建议 |
|---|---|---|---|
| overScrollDamping | 0.4f | 越界时每多滑 1 像素,内容只走对应比例 | 想要“硬边界”调小到 0.2,想要橡皮筋效果调大到 0.6 |
| flingMinVelocity | 3000(像素/秒) | 低于此速度不启动惯性滚动 | 灵敏手指可以调到 2000,只接受快速翻页可以调到 4000 |
| springBackDuration | 250(毫秒) | 越界回弹动画时长 | 内容多时缩短到 200,追求细腻手感调到 400 |
flingMinVelocity 的判定要放在 UP 事件里,与 velocityY 的取负号配合使用。如果漏掉取负号,会出现“向上滑却往顶部回滚”的诡异现象,这是新手最容易遇到的手感问题。
damping 的实现要作用在“越界部分”而不是“整个位移”:
// 错误:整个位移都做阻尼,正常区域内手指和内容不同步 targetY = (int) (targetY * DAMPING); // 正确:只有越界的部分衰减,正常范围 1:1 跟随 if (targetY < 0) { targetY = (int) (targetY * DAMPING); }这个细节决定了“正常滑动是否跟手”:如果整个位移都做阻尼,手指滑了 100 像素,内容只走 40 像素,视觉上就是明显的延迟。
4.3 对外能力:设置监听器与回到顶部接口
一个滚动控件不能只有内部逻辑,还要暴露最小化的外部接口。常用的是两个:滚动位置监听和回到顶部。
public interface OnMyScrollChangeListener { void onMyScrollChanged(int scrollY, int maxScrollY); } private OnMyScrollChangeListener scrollListener; public void setOnMyScrollChangeListener(OnMyScrollChangeListener listener) { this.scrollListener = listener; } // 在 onTouchEvent 的 MOVE、computeScroll 里调用 if (scrollListener != null) { scrollListener.onMyScrollChanged(getScrollY(), getMaxScrollY()); } public void scrollToTop() { scroller.startScroll(0, getScrollY(), 0, -getScrollY(), springBackDuration); postInvalidateOnAnimation(); }调用时机要注意:不要在 DOWN 里回调,否则手指刚按下还没滑动就触发一次无意义的事件。一般只在“滚动位置真正变化”的路径里回调,也就是 MOVE 的 isDragging 分支和 computeScroll 的动画帧中。
回调里不要做耗时操作,尤其是不要在这个方法里再去测量子 View 或触发 requestLayout。滚动事件是一帧一帧来的,回调里耗 1ms,整个滚动过程就会掉帧,表现为内容卡顿和跳动,俗称滚动翻车。
5. 避坑与排查:上下滚动控件常见的五个翻车现场
控制逻辑本身不难,难的是组合场景里的各种冲突。这一章整理了五个最常见的问题,每条都按照现象、原因、解决三个步骤来排查。
5.1 嵌套在父级列表里被抢走事件
现象:把自定义控件放进一个纵向滚动的父容器里,手指滑动时父容器也在滚,内容各滚各的,手势一乱整个页面跟着抖。或者反过来,自定义控件把事件全吃了,父容器再也滚不动。
原因:事件分发顺序是父容器先看 onInterceptTouchEvent,子控件再看 onTouchEvent。父容器在 MOVE 阶段判断“这手势像是竖向滚动”就直接拦截,子控件根本没有机会处理。
解决:在子控件确认自己需要滚动的瞬间,主动通知父容器停止拦截:
if (!isDragging && Math.abs(deltaY) > touchSlop) { isDragging = true; getParent().requestDisallowInterceptTouchEvent(true); }注意在 UP 和 CANCEL 里要恢复允许拦截:
case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: getParent().requestDisallowInterceptTouchEvent(false); break;滚动到边界时,如果希望把剩余手势让给父容器,需要调用requestDisallowInterceptTouchEvent(false)并且把isDragging置为 false,这是个更复杂的联动逻辑,建议在控件稳定后再做。
5.2 快速滑动时内容跳动与首帧跳变
现象:手指快速上下滑动,内容突然从中间跳到顶部,或者从顶部跳回中间,视觉上是明显的“瞬移”。
原因:最常见的是 DOWN 阶段没有打断上一次的惯性动画。上一次 fling 还没结束,手指已经按下,Scroller 还在计算每一帧的滚动位置,而新手势的 targetY 也在驱动 scrollY,两个逻辑同时改值,滚动状态就乱套了。
解决:DOWN 阶段必须无条件中断动画:
if (!scroller.isFinished()) { scroller.abortAnimation(); }另一个原因是用了 scrollBy 逐帧累加。快速滑动时事件帧可能丢,累加量不连续,表现为跳动。统一改成“startScrollY + deltaY”的目标位计算,就不会因为丢帧而漂移。
5.3 内容不足一屏时回弹手感过重
现象:页面内容很少,高度只有屏幕的一半。手指一拉,控件像皮筋一样被拉出很大距离,松手后猛弹回去,画面非常生硬。
原因:阻尼没有生效。很多实现在 MOVE 里先 scrollTo 再判断边界,拖拽过程中 getScrollY() 已经被 clamp 在合法区间,越界效果根本没显示出来。只有当滚动目标被放开限制时,越界偏移才可见。
解决:阻尼要作用在“目标位置”上,而不是滚动之后:
int targetY = startScrollY + deltaY; if (targetY < 0) { targetY = (int) (targetY * DAMPING); // 顶部越界 } else if (targetY > getMaxScrollY()) { targetY = (int) (getMaxScrollY() + (targetY - getMaxScrollY()) * DAMPING); // 底部越界 } scrollTo(0, targetY);越界距离控制在 100 像素以内比较自然。如果阻尼调小了还是觉得重,检查一下是否在 XML 里对控件设置了固定的高度,导致 viewportHeight 比预期小,边界的回弹空间变得很短。
5.4 fling 结束后 scrollY 悄悄多了一段
现象:松手后动画结束,内容停在预料之外的位置。比如翻到第 10 项,停下来却显示第 9 项和第 10 项之间的缝隙,滚动条也对不上。
原因:computeScroll() 的退出条件写在动画循环里,但动画结束后没有做收敛。fling 过程中 maxScrollY 发生变化,导致 scroller 的 finalY 和最新的真实边界不一致。
解决:在 computeScroll() 的收尾阶段做一次强制收敛:
@Override public void computeScroll() { if (scroller.computeScrollOffset()) { scrollTo(scroller.getCurrX(), scroller.getCurrY()); invalidate(); } else if (getScrollY() < 0 || getScrollY() > getMaxScrollY()) { scrollTo(0, Math.max(0, Math.min(getScrollY(), getMaxScrollY()))); } }这个else if分支是动画结束后的兜底,确保任何边界变化都不会让控件停留在非法位置。没有这个兜底,fling 后的位置偏差会随时间积累,项目跑得越久越难排查。
5.5 绘制闪烁与选错视图层级
现象:滚动过程中内容边缘出现黑边或白闪,尤其是带圆角背景或阴影时特别明显。
原因:ViewGroup 默认对子 View 做裁剪,滚动时子 View 的绘制区域被裁剪到控件边界,边缘绘制顺序一变就闪。另一个原因是 onDraw 里做了耗时操作,比如重复创建 Paint 或解码图片,导致滚动帧率跟不上。
解决:先允许子 View 越界绘制:
<com.example.SimpleVerticalScroll android:layout_width="match_parent" android:layout_height="match_parent" android:clipChildren="false" />再检查 onDraw。一个滚动控件,onDraw 里只应该做简单的 canvas 绘制,任何对象创建、IO 操作、图片解码都要移到初始化阶段。还有一个容易被忽略的点是 setWillNotDraw(true) 的误用,如果控件设置了背景却调用了这个方法,背景不会绘制,看起来就像闪烁。
6. 进阶:数据驱动场景下怎么保住滚动位置
前面解决了“滑得动、停得住、参数可调”的问题,最后一个高频需求是:数据更新后,滚动位置不能乱。典型场景是翻页卡片,数据刷新后卡片高度变化,滚动位置如果按绝对像素保存,刷新后会指到完全不同的内容上。
常见做法是保存“锚点”:记录视口顶部对应的是哪一条数据,以及视口顶部距这条数据顶部的偏移量。数据刷新后,用新的行高重新计算位置。
// 数据刷新前调用:记录锚点 int anchorRow = findRowAt(getScrollY()); int offsetInRow = getScrollY() - getRowTop(anchorRow); // 数据刷新后:按新行高恢复位置 int newTop = getRowTop(anchorRow); int targetY = newTop + offsetInRow; scrollTo(0, Math.max(0, Math.min(targetY, getMaxScrollY())));findRowAt 和 getRowTop 是建立在数据源上的两个查询方法,不依赖 View 层级。如果行高没有变化,这个锚点算法退化成普通的像素保存,没有额外开销。
一个需要提醒的判断:不是所有纵向滚动场景都适合自绘。内容要复用成百上千个 item、需要预加载、需要复杂 item 类型时,成熟列表组件明显更划算。自绘滚动适合卡片数量少、滑动手感有强定制需求、数据变化不频繁的页面,比如翻页卡片、仪表盘、长图预览。
我早期做某个数据面板项目时,图省事直接保存 scrollY,数据一刷新位置全错。后来改成锚点方案,问题一次解决。从那以后,我每做一个自绘滚动控件,第一件事就是问自己:数据变了,位置还在吗?想清楚这个问题再动手写事件,能省掉后面一大半调试时间。希望帮到你。
本文还有配套的精品资源,点击获取