在 Android 开发中,我们经常需要为 RecyclerView、ViewPager 或 HorizontalScrollView 添加一个可视化的滚动指示器。虽然系统自带的 ScrollBar 能满足基本需求,但如果 UI 设计要求指示器有固定的宽度、圆角以及特定的颜色,自定义 View 往往是最佳选择。

本文将带你使用 Kotlin 实现一个轻量级、高性能的水平滑动指示器组件 BottomLineView,并详细讲解如何适配主流的滚动控件。
1. 核心组件实现:BottomLineView
我们创建一个继承自 View 的类 BottomLineView。它不依赖具体的滚动控件,只接收指示器宽度 和滚动比例两个参数,实现了高度解耦。
kotlin
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()
}
}
2. 场景适配指南
下面是三种最常见的滚动控件适配方案。
场景一:与 RecyclerView 联动
这是最常用的场景。利用 computeHorizontalScrollXXX 系列方法可以精确计算滚动位置。
kotlin
// 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)
}
}
})
场景二:与 HorizontalScrollView 联动
HorizontalScrollView 需要 API 23 (Android 6.0) 以上才能通过 setOnScrollChangeListener 监听。如果是低版本,可能需要继承并重写 onScrollChanged。
kotlin
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)
}
}
场景三:与 ViewPager2 联动
ViewPager2 的计算逻辑略有不同,因为它是按"页"滚动的。
kotlin
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)
}
}
})
3. 总结
通过将 UI 绘制逻辑封装在 BottomLineView 内部,并将状态更新抽象为 updateScrollRatio(0.0~1.0) 接口,我们成功地让这个指示器适配了 Android 所有的水平滚动组件。无论底层是用 RecyclerView 还是 ViewPager,UI 层的表现都一样丝滑流畅。