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

在 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 层的表现都一样丝滑流畅。

相关推荐
三少爷的鞋7 小时前
Java 线程自上而下,Kotlin 协程自下而上
android
好的佩奇8 小时前
Dart 之增强枚举
android·flutter·dart
梦影_9 小时前
MySQL进阶教程(五)—— SQL调优(下)
android·大数据·数据库·后端·sql·mysql·database
好的佩奇11 小时前
蓝牙之无线电与通信距离
android·flutter·蓝牙
vilya12 小时前
我把一套完整的 Python Agent 运行时塞进手机 APK:依赖闭包、Rust 轮子和三个真机坑
android
呆呆敲代码的小Y13 小时前
【实战精选面试题】C# 面试题详细解答,按难易程度排序
android·java·游戏·c#·游戏开发·unity面试题·c#面试题
思想觉悟15 小时前
SEAndroid实战
android·linux·c++
思想觉悟15 小时前
Aosp开发磨刀石之ADB速查
android·c++
我是唐青枫16 小时前
Kotlin Inheritance(继承)详解:语法、原理、实战 Demo
开发语言·kotlin
应用市场18 小时前
用本机跑的开源音乐模型,给安卓小游戏做 20 秒无缝循环背景音乐
android·python·游戏开发·soundpool·音频处理