Android 自定义 View 实战:打造一个跟随滑动的丝滑指示器

远山来客
远山来客
发布时间:2026-08-22| 129人浏览过
原创

前言

在 Android 项目里,横向内容列表、分页视图和可滑动容器经常都需要一条明确的滚动指示器。系统自带的 ScrollBar 能用,但一旦设计稿对宽度、圆角、颜色甚至跟手感有要求,就很难继续复用默认方案。下面这篇实战会把问题拆成两步:先做一个只负责绘制和位移的 BottomLineView,再分别接到 RecyclerView、HorizontalScrollView 和 ViewPager2 上,让你判断这种“按比例驱动”的方案为什么更容易维护。

在 Android 项目里,横向内容列表、分页视图和可滑动容器经常都需要一条明确的滚动指示器。系统自带的 ScrollBar 能用,但一旦设计稿对宽度、圆角、颜色甚至跟手感有要求,就很难继续复用默认方案。下面这篇实战会把问题拆成两步:先做一个只负责绘制和位移的 BottomLineView,再分别接到 RecyclerView、HorizontalScrollView 和 ViewPager2 上,让你判断这种“按比例驱动”的方案为什么更容易维护。

为什么要自己做滚动指示器

很多场景并不缺“能显示滚动状态”的组件,缺的是一条能和 UI 风格统一、还能适配不同容器的指示器。相比直接依赖系统 ScrollBar,自定义 View 更适合解决下面几个问题:

  • 指示器宽度需要固定,而不是跟随系统默认规则变化。
  • 背景线和前景指示器要分别控制颜色、圆角和粗细。
  • 同一套视觉效果要复用到 RecyclerView、ViewPager2、HorizontalScrollView 等不同控件上。

这也是本文方案的核心:不把逻辑绑死在某一个滚动控件里,而是统一转成 0.0 ~ 1.0 的滚动比例,再交给指示器自己绘制。

核心实现:一个只接收宽度和比例的 BottomLineView

BottomLineView 继承自 View,本身不关心外部到底是列表滚动、容器滚动还是分页切换。它只接收两个参数:

BottomLineView 的绘制输入与位移计算关系图
BottomLineView 的输入与绘制关用一张结构图说明 BottomLineView 如何把固定宽度与滚动比例转换成最终绘制位置。
  • 指示器宽度:决定绿色前景线的物理长度。
  • 滚动比例:决定当前应该绘制到哪一个位置。

这样的设计把绘制职责和滚动数据来源彻底拆开,后续接任何横向滚动组件都只需要负责计算比例。

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() 获取滚动状态。

RecyclerView、HorizontalScrollView 与 ViewPager2 的滚动比例换算对比图
三种滚动控件如何统一成比例输入三类横向滚动组件虽然来源不同,但最终都能转换成统一的 0.0 到 1.0 比例。
// 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,只要能给出连续滚动进度,这个指示器的表现就能保持一致,也更容易做成项目里的通用组件。

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。