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

在 Android 开发中,我们经常需要为 RecyclerViewViewPagerHorizontalScrollView 添加一个可视化的滚动指示器。虽然系统自带的 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 层的表现都一样丝滑流畅。

相关推荐
robotx2 小时前
安卓线程相关
android
消失的旧时光-19432 小时前
Android 面试高频:JSON 文件、大数据存储与断电安全(从原理到工程实践)
android·面试·json
dalancon3 小时前
VSYNC 信号流程分析 (Android 14)
android
dalancon3 小时前
VSYNC 信号完整流程2
android
dalancon3 小时前
SurfaceFlinger 上帧后 releaseBuffer 完整流程分析
android
用户69371750013844 小时前
不卷AI速度,我卷自己的从容——北京程序员手记
android·前端·人工智能
程序员Android5 小时前
Android 刷新一帧流程trace拆解
android
墨狂之逸才5 小时前
解决 Android/Gradle 编译报错:Comparison method violates its general contract!
android
阿明的小蝴蝶6 小时前
记一次Gradle环境的编译问题与解决
android·前端·gradle
汪海游龙6 小时前
开源项目 Trending AI 招募 Google Play 内测人员(12 名)
android·github