11、页面指示器(PageIndicator):PageIndicator的绘制原理、与ViewPager的联动、自定义指示器样式。

页面指示器,说白了就是那一排小圆点或者小横条。你刷抖音、翻相册、看引导页,底下总有几个点跟着动。嗯,这东西看着简单,但真要自己写一个,坑还挺多的。

我记得刚做Launcher那会儿,产品经理丢过来一个需求:「指示器要带弹性动画,还要能自定义图标」。我当时心想,不就几个圆点嘛,画一下不就行了?结果一做就是两天。今天我就把这里面的门道给你捋清楚。

11.1 绘制原理:从Canvas到自定义View

PageIndicator本质上是一个自定义View。它的核心就两件事:画点算位置

每个指示器单元,其实就是一个圆。选中态和未选中态,无非是颜色、大小、透明度不一样。我习惯把每个点的状态抽象成一个数据类:

data class IndicatorDot(
    val centerX: Float,   // 圆心X
    val centerY: Float,   // 圆心Y
    val radius: Float,    // 半径
    val alpha: Float,     // 透明度
    val scale: Float      // 缩放比例
)

绘制的时候,就是遍历这个列表,一个个画圆。你可能会问:「那动画怎么搞?」其实很简单——不要直接改数据,而是用属性动画驱动

举个例子,当页面从第0页滑到第1页,位置偏移是0→1。这时候第0个点从选中态变成非选中态,第1个点反过来。我只需要把偏移量映射到每个点的alpha和scale上:

// 伪代码,核心逻辑
fun updateIndicator(position: Float) {
    val currentPage = position.toInt()
    val offset = position - currentPage

    // 当前页的点缩小
    dots[currentPage].scale = 1f - offset * 0.3f
    dots[currentPage].alpha = 1f - offset * 0.5f

    // 下一页的点放大
    dots[currentPage + 1].scale = 0.7f + offset * 0.3f
    dots[currentPage + 1].alpha = 0.5f + offset * 0.5f

    invalidate()
}

关键点:不要用硬编码的固定值。把选中态半径、非选中态半径、透明度范围都抽成可配置参数。这样换样式时,改几个变量就行。

11.2 与ViewPager的联动:OnPageChangeListener的秘密

ViewPager提供了三个回调:onPageSelectedonPageScrollStateChangedonPageScrolled。很多人只用了前两个,但真正让指示器「丝滑」的,是第三个。

onPageScrolled(position, offset, offsetPixels) 这个回调,会在手指滑动时持续触发。position是当前页索引,offset是0到1的小数。你想想看,如果只在onPageSelected里更新指示器,那动画就是「跳」过去的,没有过渡。

我踩过这个坑。有一次做桌面翻页,指示器只在页面停稳后才更新,结果用户反馈「指示器反应慢半拍」。后来改成在onPageScrolled里实时更新,问题就解决了。

正确的联动方式是这样的:

viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
    override fun onPageScrolled(position: Int, offset: Float, offsetPixels: Int) {
        // 实时更新指示器
        indicator.updateIndicator(position + offset)
    }

    override fun onPageSelected(position: Int) {
        // 这里可以做些额外操作,比如更新标题
    }

    override fun onPageScrollStateChanged(state: Int) {
        // 状态变化时,可以控制动画是否启用
    }
})

小技巧:当手指快速滑动时,onPageScrolled的offset可能会跳过中间值。这时候可以结合ViewPropertyAnimator做补间动画,保证指示器最终归位。

11.3 自定义指示器样式:从圆点到一切

默认的圆点样式太单调了。实际项目中,指示器可以是横条、图标、甚至是文字。怎么做?把「绘制逻辑」和「数据模型」解耦。

我一般会定义一个接口:

interface IndicatorDrawable {
    fun draw(canvas: Canvas, dot: IndicatorDot, isSelected: Boolean)
}

然后实现不同的样式:

  • 圆点样式:直接画圆,选中态放大+变色
  • 横条样式:画圆角矩形,宽度随选中态变化
  • 图标样式:用Bitmap或VectorDrawable,选中态加滤镜
  • 文字样式:画文字,选中态加粗+变色

举个例子,横条指示器的绘制核心:

class BarIndicatorDrawable : IndicatorDrawable {
    override fun draw(canvas: Canvas, dot: IndicatorDot, isSelected: Boolean) {
        val paint = Paint().apply {
            color = if (isSelected) Color.WHITE else Color.GRAY
            alpha = (dot.alpha * 255).toInt()
        }
        val width = if (isSelected) 40f * dot.scale else 20f
        val height = 6f
        val left = dot.centerX - width / 2
        val top = dot.centerY - height / 2
        canvas.drawRoundRect(left, top, left + width, top + height, 3f, 3f, paint)
    }
}

注意:自定义样式时,一定要处理好触摸反馈。如果指示器本身可点击跳转,记得重写onTouchEvent,把点击坐标映射到对应的指示器单元上。我曾经因为没做坐标映射,导致点击指示器总是跳到第一页。

11.4 知识体系总览

下面这张图,把PageIndicator的核心知识点串起来了。你可以看到,从绘制到联动,再到自定义,是一条完整的技术链路。

PageIndicator 知识体系 绘制原理 ViewPager联动 自定义样式 Canvas绘制圆点 属性动画驱动 状态数据模型 onPageScrolled回调 实时更新指示器 补间动画兜底 IndicatorDrawable接口 圆点/横条/图标 触摸坐标映射 核心:数据驱动绘制 + 实时联动 + 接口化自定义

11.5 避坑指南与最佳实践

做PageIndicator,有几个坑我反复踩过,今天一并告诉你:

  • 不要用Handler延迟更新:很多人为了做动画,用Handler.postDelayed。结果页面快速滑动时,指示器乱跳。直接用onPageScrolled的实时数据就好。
  • 注意生命周期:ViewPager销毁时,记得移除OnPageChangeListener,否则会内存泄漏。我见过线上App因为这个原因导致OOM。
  • 适配RTL布局:如果你的App支持阿拉伯语等从右到左的语言,指示器的方向要跟着翻转。别问我怎么知道的,被中东用户骂过。
  • 性能优化:如果指示器数量很多(比如超过20个),不要在onDraw里创建对象。把Paint、Path等对象缓存起来,避免频繁GC。

我的习惯:把指示器封装成一个独立的库模块。这样Launcher、设置页、引导页都能复用。而且换主题时,只需要改几个颜色变量,不用到处改代码。

嗯,关于PageIndicator,核心就是这些。你把它理解成一个「会动的数据可视化组件」,一切就都顺了。数据驱动绘制,联动靠回调,自定义靠接口——这三板斧砍下去,什么花样的指示器都能做出来。