在 Android 项目里,横向内容列表、分页视图和可滑动容器经常都需要一条明确的滚动指示器。系统自带的 ScrollBar 能用,但一旦设计稿对宽度、圆角、颜色甚至跟手感有要求,就很难继续复用默认方案。下面这篇实战会把问题拆成两步:先做一个只负责绘制和位移的 BottomLineView,再分别接到 RecyclerView、HorizontalScrollView 和 ViewPager2 上,让你判断这种“按比例驱动”的方案为什么更容易维护。
为什么要自己做滚动指示器
很多场景并不缺“能显示滚动状态”的组件,缺的是一条能和 UI 风格统一、还能适配不同容器的指示器。相比直接依赖系统 ScrollBar,自定义 View 更适合解决下面几个问题:
- 指示器宽度需要固定,而不是跟随系统默认规则变化。
- 背景线和前景指示器要分别控制颜色、圆角和粗细。
- 同一套视觉效果要复用到
RecyclerView、ViewPager2、HorizontalScrollView等不同控件上。
这也是本文方案的核心:不把逻辑绑死在某一个滚动控件里,而是统一转成 0.0 ~ 1.0 的滚动比例,再交给指示器自己绘制。
核心实现:一个只接收宽度和比例的 BottomLineView
BottomLineView 继承自 View,本身不关心外部到底是列表滚动、容器滚动还是分页切换。它只接收两个参数:

- 指示器宽度:决定绿色前景线的物理长度。
- 滚动比例:决定当前应该绘制到哪一个位置。
这样的设计把绘制职责和滚动数据来源彻底拆开,后续接任何横向滚动组件都只需要负责计算比例。
class BottomLineView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private lateinit var paintGray: Paint // 背景线画笔
private lateinit var paintGreen: Paint // 指示器画笔
private var indicatorWidth = 0f // 指示器的固定宽度
private var currentOffset = 0f // 当前偏移量
init {
initPaint()
}
private fun initPaint() {
// 背景线:灰色,圆角
paintGray = Paint().apply {
color = -0x333334 // 0xFFCCCCCC
strokeCap = Paint.Cap.ROUND
strokeWidth = 10f
isAntiAlias = true
}
// 指示器:绿色,圆角
paintGreen = Paint().apply {
color = -0xff0100 // 0xFF00FF00
strokeCap = Paint.Cap.ROUND
strokeWidth = 10f
isAntiAlias = true
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val centerY = height / 2f
val totalWidth = width.toFloat()
// 1. 绘制背景线
canvas.drawLine(0f, centerY, totalWidth, centerY, paintGray)
// 2. 绘制指示器(确保不越界)
val start = max(0f, min(currentOffset, totalWidth - indicatorWidth))
val end = start + indicatorWidth
if (indicatorWidth > 0) {
canvas.drawLine(start, centerY, end, centerY, paintGreen)
}
}
/** 设置指示器物理宽度 */
fun setIndicatorWidth(width: Float) {
this.indicatorWidth = width
invalidate()
}
/** 更新滚动比例 (0.0 ~ 1.0) */
fun updateScrollRatio(ratio: Float) {
val maxOffset = width - indicatorWidth
this.currentOffset = maxOffset * ratio
invalidate()
}
}
绘制逻辑里真正要注意的点
这段实现看起来不长,但有几个细节决定了它是否“顺滑”和“稳定”。
- 背景线和指示器都使用
Paint.Cap.ROUND,可以直接得到圆角端点。 strokeWidth = 10f与isAntiAlias = true组合,能让线条边缘更平滑。start = max(0f, min(currentOffset, totalWidth - indicatorWidth))用来防止指示器越界。updateScrollRatio(ratio: Float)不直接关心业务数据,只处理比例到像素偏移的转换。
其中最关键的是“边界控制”和“比例驱动”。前者避免绘制超出 View 可见范围,后者让外部接入逻辑始终保持一致。
适配不同滚动控件的统一思路
接入任意横向滚动组件时,可以先套用同一条公式:
ratio = 当前滚动进度 / 最大可滚动进度
只要最终能得到一个 0.0 ~ 1.0 的结果,就可以调用:
bottomLineView.updateScrollRatio(ratio)
接下来的区别,只在于每种控件怎样计算“当前进度”和“最大进度”。
场景一:与 RecyclerView 联动
这是最常见的一种接法。对于横向 RecyclerView,可以直接利用 computeHorizontalScrollOffset()、computeHorizontalScrollRange() 和 computeHorizontalScrollExtent() 获取滚动状态。

// 1. 设置指示器宽度
bottomLineView.setIndicatorWidth(100f)
// 2. 监听滚动
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
// 获取滚动参数
val offset = recyclerView.computeHorizontalScrollOffset() // 当前偏移
val range = recyclerView.computeHorizontalScrollRange() // 内容总宽
val extent = recyclerView.computeHorizontalScrollExtent() // 可见宽度
// 计算最大可滚动距离
val maxScroll = range - extent
if (maxScroll > 0) {
val ratio = offset.toFloat() / maxScroll
bottomLineView.updateScrollRatio(ratio)
}
}
})
这一段计算为什么适合 RecyclerView
RecyclerView 本身就提供了成熟的滚动测量接口,优势在于不需要你自己估算 item 宽度总和:
offset表示当前已经滚动出去的距离。range表示内容总宽度。extent表示当前可见区域宽度。maxScroll = range - extent表示真正还能滚动的最大距离。
只要 maxScroll > 0,就说明当前内容可横向滚动,此时再把偏移量转换为比例即可。
场景二:与 HorizontalScrollView 联动
HorizontalScrollView 的接法更直接,因为它本身就是一个横向容器。需要注意的是,使用 setOnScrollChangeListener 监听滚动要求 API 23,也就是 Android 6.0 以上;如果要兼容更低版本,就需要继承控件并重写 onScrollChanged。
horizontalScrollView.setOnScrollChangeListener { _, scrollX, _, _, _ ->
// 1. 获取子 View (内容)
val contentView = horizontalScrollView.getChildAt(0) ?: return@setOnScrollChangeListener
// 2. 计算最大可滚动距离 = 内容宽度 - 容器宽度
val maxScroll = contentView.width - horizontalScrollView.width
if (maxScroll > 0) {
val ratio = scrollX.toFloat() / maxScroll
bottomLineView.updateScrollRatio(ratio)
}
}
这里的关键差异是什么
和 RecyclerView 相比,HorizontalScrollView 没有现成的 computeHorizontalScrollXXX 三件套思路可直接照搬,重点变成了两件事:
- 先拿到实际承载内容的子 View:
getChildAt(0)。 - 用
内容宽度 - 容器宽度算出最大滚动距离。
当 scrollX 在 0 到 maxScroll 之间变化时,指示器就能稳定映射到整条底线的可移动范围。
场景三:与 ViewPager2 联动
ViewPager2 的特殊点在于,它不是连续内容区域的像素滚动,而是“页”和“页之间偏移量”的组合。因此它更适合按分页进度来算总比例。
viewPager2.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
super.onPageScrolled(position, positionOffset, positionOffsetPixels)
// 假设 Adapter 中有 itemCount
val itemCount = adapter.itemCount
if (itemCount > 1) {
// 计算总进度
// 当前页索引 + 当前页偏移百分比
val currentProgress = position + positionOffset
// 总的可移动页数 = 总页数 - 1
val totalProgress = (itemCount - 1).toFloat()
val ratio = currentProgress / totalProgress
bottomLineView.updateScrollRatio(ratio)
}
}
})
分页场景下的比例怎么算
这一段可以直接理解成“把页码变化压缩到 0.0 ~ 1.0”。
position表示当前页索引。positionOffset表示当前页向下一页滑动的百分比。currentProgress = position + positionOffset,得到连续分页进度。totalProgress = (itemCount - 1).toFloat(),得到总可移动页数。
这样做的好处是,哪怕每一页内容宽度本身并不需要参与计算,指示器依旧能跟着分页手势平滑移动。
这套方案适合放进项目的原因
把 UI 绘制封装在 BottomLineView 内部,再把外部输入统一收敛为 updateScrollRatio(0.0~1.0),带来的直接收益有三点:
- 视图层职责清晰,指示器不依赖具体滚动控件。
- 接入成本低,外部只需要负责计算比例。
- 扩展性更好,后续要接其他横向滚动组件也能沿用同一接口。
不管底层是 RecyclerView、HorizontalScrollView 还是 ViewPager2,只要能给出连续滚动进度,这个指示器的表现就能保持一致,也更容易做成项目里的通用组件。









