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提供了三个回调:onPageSelected、onPageScrollStateChanged、onPageScrolled。很多人只用了前两个,但真正让指示器「丝滑」的,是第三个。
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的核心知识点串起来了。你可以看到,从绘制到联动,再到自定义,是一条完整的技术链路。
11.5 避坑指南与最佳实践
做PageIndicator,有几个坑我反复踩过,今天一并告诉你:
- 不要用Handler延迟更新:很多人为了做动画,用Handler.postDelayed。结果页面快速滑动时,指示器乱跳。直接用
onPageScrolled的实时数据就好。 - 注意生命周期:ViewPager销毁时,记得移除
OnPageChangeListener,否则会内存泄漏。我见过线上App因为这个原因导致OOM。 - 适配RTL布局:如果你的App支持阿拉伯语等从右到左的语言,指示器的方向要跟着翻转。别问我怎么知道的,被中东用户骂过。
- 性能优化:如果指示器数量很多(比如超过20个),不要在
onDraw里创建对象。把Paint、Path等对象缓存起来,避免频繁GC。
我的习惯:把指示器封装成一个独立的库模块。这样Launcher、设置页、引导页都能复用。而且换主题时,只需要改几个颜色变量,不用到处改代码。
嗯,关于PageIndicator,核心就是这些。你把它理解成一个「会动的数据可视化组件」,一切就都顺了。数据驱动绘制,联动靠回调,自定义靠接口——这三板斧砍下去,什么花样的指示器都能做出来。