Android 自定义「阶段进度条(步轴)」实践
动画阶段点型 Step Bar + 连续填充型 Stage Bar
一、背景 & 需求
很多业务不是简单的 0–100% 进度,而是分阶段的流程,比如:
注册流程:填写信息 → 验证 → 完成
工厂/机器人:初始化 → 运行中 → 校验 → 完成
审批流程:发起 → 审核中 → 通过 → 归档
这类 UI 一般叫做阶段进度条 / 步轴(Step Progress Bar)。
本文实现并对比两种常用方案,两种都是带动画的:
方案一:动画阶段点型 Step Bar(Node Type + 动画)
方案二:连续填充型 Stage Bar(Animated + 可配置阶段数 + 文案)
二、方案一:动画阶段点型 Step Bar(Node Type + 动画)
1. 效果说明
一条横向线段
N 个等间距的圆点节点
当前步骤之前的线段 & 节点高亮,之后是灰色
切换步骤时,有平滑动画:
前景线从上一个节点滑到下一个节点
节点一个个被“点亮”
示意(逻辑上类似这样):
步骤 1 步骤 2 步骤 3 步骤 4●────────●────────○────────○
↑ 动画时前景线从第一个点平滑滑到第二个点
2. 适用场景
步骤明确、节点含义很强的流程:注册、支付、审批、操作引导
希望用户直观看到“第几步”,同时又有推进动画反馈
3. 思路简述(分享时可以这么讲)
- 普通 Step Bar:用
currentStep: Int直接决定前景线长度和节点状态 - 动画版:增加一个
animatedStep: Float setStep(step, animate=true)时,用ValueAnimator把animatedStep从旧值 平滑过渡到新值(例如 1.0f → 2.0f)绘制前景线长度:按
animatedStep来算节点是否高亮:
i <= animatedStep就算“到达”
4. 完整代码:AnimatedStepProgressBar.kt
package com.example.widgetimport android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import android.view.animation.DecelerateInterpolator
import kotlin.math.min/*** 方案一:动画阶段点型 Step Bar(Node Type + 动画)** 特性:* - totalSteps:总步骤数(>=2),可代码配置* - setStep(step, animate):切换步骤,带平滑动画* - 带前景线、背景线、多节点圆点*/
class AnimatedStepProgressBar @JvmOverloads constructor(context: Context,attrs: AttributeSet? = null,defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {/** 总步骤数,至少 2 个 */var totalSteps: Int = 4set(value) {field = value.coerceAtLeast(2)if (currentStep > field) currentStep = fieldanimatedStep = min(animatedStep, field.toFloat())invalidate()}/** 当前目标步骤(0..totalSteps) */var currentStep: Int = 0private set/** 用于绘制的“动画步骤值”,0f ~ totalSteps(允许小数,控制平滑) */private var animatedStep: Float = 0fprivate var animator: ValueAnimator? = null// 颜色可按需调整private val reachedColor = 0xFF4CAF50.toInt() // 已完成(绿)private val unreachedColor = 0xFFBDBDBD.toInt() // 未完成(灰)private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {style = Paint.Style.STROKEstrokeWidth = dp(3f)strokeCap = Paint.Cap.ROUND}private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {style = Paint.Style.FILL}private val circleRadius = dp(6f)override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {val desiredHeight =(circleRadius * 2 + paddingTop + paddingBottom + dp(8f)).toInt()val width = getDefaultSize(suggestedMinimumWidth, widthMeasureSpec)val height = resolveSize(desiredHeight, heightMeasureSpec)setMeasuredDimension(width, height)}override fun onDraw(canvas: Canvas) {super.onDraw(canvas)if (totalSteps < 2) returnval usableWidth = width - paddingLeft - paddingRightval startX = paddingLeft.toFloat()val endX = (paddingLeft + usableWidth).toFloat()val centerY = height / 2fval spacing = usableWidth / (totalSteps - 1).toFloat()// 1. 背景线(灰色)linePaint.color = unreachedColorcanvas.drawLine(startX, centerY, endX, centerY, linePaint)// 2. 前景线(绿),长度由 animatedStep 决定linePaint.color = reachedColorval reachedEndX = startX + spacing * animatedStepcanvas.drawLine(startX, centerY, reachedEndX, centerY, linePaint)// 3. 节点圆点:索引 <= animatedStep 时视为“到达”for (i in 0 until totalSteps) {val cx = startX + spacing * ival isReached = i.toFloat() <= animatedStep + 1e-3fcirclePaint.color = if (isReached) reachedColor else unreachedColorcanvas.drawCircle(cx, centerY, circleRadius, circlePaint)}}/*** 设置当前步骤(0..totalSteps)* @param step 目标步骤* @param animate 是否使用动画*/fun setStep(step: Int, animate: Boolean = true) {val target = step.coerceIn(0, totalSteps)if (!animate) {animator?.cancel()currentStep = targetanimatedStep = target.toFloat()invalidate()return}if (animator?.isRunning == true && target == currentStep) returncurrentStep = targetval start = animatedStepval end = target.toFloat()animator?.cancel()animator = ValueAnimator.ofFloat(start, end).apply {duration = 350Linterpolator = DecelerateInterpolator()addUpdateListener {animatedStep = it.animatedValue as Floatinvalidate()}start()}}private fun dp(value: Float): Float =value * resources.displayMetrics.density
}
5. 使用示例
XML:
<com.example.widget.AnimatedStepProgressBarandroid:id="@+id/stepBar"android:layout_width="match_parent"android:layout_height="40dp"android:layout_marginHorizontal="16dp"android:layout_marginTop="24dp" />
代码:
val stepBar = findViewById<AnimatedStepProgressBar>(R.id.stepBar)// 配置总步骤数(可根据业务变化)
stepBar.totalSteps = 5// 初始化到第 0 步(无动画)
stepBar.setStep(0, animate = false)// 点击“下一步”按钮时推进
btnNext.setOnClickListener {val next = (stepBar.currentStep + 1).coerceAtMost(stepBar.totalSteps)stepBar.setStep(next, animate = true)
}
三、方案二:连续填充型 Stage Bar
(Animated + 可配置阶段数 + 文案)
1. 效果说明
- 一条“胶囊形”进度条:黄色底 + 蓝色填充
maxStage控制总阶段数(3、4、6… 任意都行)- 每切换阶段,蓝条平滑过渡到新的占比(1/maxStage、2/maxStage …)
- 顶部可以显示一段文字,例如:
阶段 2 / 6:处理中初始化 → 连接设备 → 校验 → 完成等提示文案
相比方案一,这个更偏“整体进度 + 动画体验”,而不是单纯强调节点。
2. 适用场景
任务执行、导入导出、机器人任务、数据处理等连续进度感更强的场景
希望通过文字+动画给用户舒适的反馈
3. 自定义属性 attrs.xml
<?xml version="1.0" encoding="utf-8"?>
<resources><declare-styleable name="StageProgressBar"><!-- 顶部文案 --><attr name="spb_labelText" format="string" /><!-- 顶部文字颜色 --><attr name="spb_labelTextColor" format="color" /><!-- 顶部文字大小 --><attr name="spb_labelTextSize" format="dimension" /><!-- 最大阶段数(>=1),默认 4 --><attr name="spb_maxStage" format="integer" /></declare-styleable></resources>
4. 完整代码:StageProgressBar.kt
(动画 + 可配置阶段数 + 顶部文案)
package com.example.widgetimport android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import android.view.animation.DecelerateInterpolator
import com.example.myapplication.R // 换成你的 R 包名/*** 方案二:连续填充型 Stage Bar* - maxStage:阶段总数(XML + 代码可配置)* - setStage(stage, animate):阶段切换动画* - labelText:顶部文案,颜色和字号也可 XML/代码配置*/
class StageProgressBar @JvmOverloads constructor(context: Context,attrs: AttributeSet? = null,defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {/** 最大阶段数(>=1) */var maxStage: Int = 4set(value) {val newValue = value.coerceAtLeast(1)if (field == newValue) returnfield = newValueif (currentStage > field) currentStage = fieldprogressFraction = currentStage / field.toFloat()requestLayout()invalidate()}/** 当前阶段 [0..maxStage] */var currentStage: Int = 0private set/** 实际绘制用 0f ~ 1f,由动画驱动 */private var progressFraction: Float = 0fprivate var animator: ValueAnimator? = null// 背景 / 填充颜色private val bgColor = 0xFFF9C54A.toInt() // 黄色背景private val fgColor = 0xFF1E6CFF.toInt() // 蓝色进度// 进度条高度private val barHeightDp = 8fprivate val barHeightPx get() = dp(barHeightDp)// 顶部文案var labelText: String = ""set(value) {field = valueinvalidate()}private var labelTextColor: Int = 0xFFFFFFFF.toInt()private var labelTextSizePx: Float = sp(12f)private val textBarSpacing = dp(4f)private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {style = Paint.Style.FILLcolor = bgColor}private val fgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {style = Paint.Style.FILLcolor = fgColor}private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {textAlign = Paint.Align.CENTER}private val rect = RectF()init {if (attrs != null) {val ta = context.obtainStyledAttributes(attrs,R.styleable.StageProgressBar,defStyleAttr,0)maxStage = ta.getInt(R.styleable.StageProgressBar_spb_maxStage,maxStage).coerceAtLeast(1)labelText = ta.getString(R.styleable.StageProgressBar_spb_labelText) ?: ""labelTextColor = ta.getColor(R.styleable.StageProgressBar_spb_labelTextColor,labelTextColor)labelTextSizePx = ta.getDimension(R.styleable.StageProgressBar_spb_labelTextSize,labelTextSizePx)ta.recycle()}textPaint.color = labelTextColortextPaint.textSize = labelTextSizePx}override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {val fm = textPaint.fontMetricsval textHeight = fm.bottom - fm.topval desiredHeight =(paddingTop +textHeight +textBarSpacing +barHeightPx +paddingBottom).toInt()val width = getDefaultSize(suggestedMinimumWidth, widthMeasureSpec)val height = resolveSize(desiredHeight, heightMeasureSpec)setMeasuredDimension(width, height)}override fun onDraw(canvas: Canvas) {super.onDraw(canvas)val widthF = width.toFloat()val fm = textPaint.fontMetricsval textHeight = fm.bottom - fm.topval textBaselineY = paddingTop - fm.topval barTop = paddingTop + textHeight + textBarSpacingval barBottom = barTop + barHeightPxval left = paddingLeft.toFloat()val right = widthF - paddingRightval radius = (barBottom - barTop) / 2f// 1. 顶部文字if (labelText.isNotEmpty()) {canvas.drawText(labelText, widthF / 2f, textBaselineY, textPaint)}// 2. 黄色背景条rect.set(left, barTop, right, barBottom)canvas.drawRoundRect(rect, radius, radius, bgPaint)// 3. 蓝色进度条if (progressFraction > 0f) {val totalWidth = right - leftval progressRight = left + totalWidth * progressFractionrect.set(left, barTop, progressRight, barBottom)canvas.drawRoundRect(rect, radius, radius, fgPaint)}}/*** 设置阶段(0..maxStage)*/fun setStage(stage: Int, animate: Boolean = true) {val targetStage = stage.coerceIn(0, maxStage)val targetFraction = targetStage / maxStage.toFloat()if (!animate) {animator?.cancel()currentStage = targetStageprogressFraction = targetFractioninvalidate()return}if (targetStage == currentStage && animator?.isRunning == true) returncurrentStage = targetStageanimator?.cancel()animator = ValueAnimator.ofFloat(progressFraction, targetFraction).apply {duration = 400Linterpolator = DecelerateInterpolator()addUpdateListener { anim ->progressFraction = anim.animatedValue as Floatinvalidate()}start()}}/** 代码动态改文字颜色 */fun setLabelTextColor(color: Int) {labelTextColor = colortextPaint.color = labelTextColorinvalidate()}/** 代码动态改文字大小(px) */fun setLabelTextSizePx(sizePx: Float) {labelTextSizePx = sizePxtextPaint.textSize = labelTextSizePxrequestLayout()invalidate()}private fun dp(value: Float): Float =value * resources.displayMetrics.densityprivate fun sp(value: Float): Float =value * resources.displayMetrics.scaledDensity
}
5. 使用示例
XML:
<com.example.widget.StageProgressBarandroid:id="@+id/stageBar"android:layout_width="match_parent"android:layout_height="wrap_content"android:layout_marginHorizontal="16dp"android:layout_marginTop="24dp"xmlns:app="http://schemas.android.com/apk/res-auto"app:spb_maxStage="6"app:spb_labelText="阶段 1 / 6:初始化中"app:spb_labelTextColor="@android:color/white"app:spb_labelTextSize="14sp" />
代码:
val bar = findViewById<StageProgressBar>(R.id.stageBar)fun updateStage(stage: Int) {bar.setStage(stage, animate = true)bar.labelText = "阶段 $stage / ${bar.maxStage}"
}updateStage(1)btnNext.setOnClickListener {val next = (bar.currentStage + 1).coerceAtMost(bar.maxStage)updateStage(next)
}
四、两种方案对比总结(技术分享可以直接放这一页)
| 维度 | 方案一:动画阶段点型 Step Bar | 方案二:连续填充型 Stage Bar |
|---|---|---|
| 表现形式 | 一条线 + 多个圆点节点 | 胶囊形连续进度条 |
| 动画 | 线段和节点随步骤“逐步点亮” | 整体蓝色填充条按比例平滑变化 |
| 强调点 | “第几步 / 下一个节点是谁” | “整体完成了多少 / 当前处于哪一阶段” |
| 阶段数配置 | totalSteps(代码配置) | maxStage(XML + 代码) |
| 文案支持 | 默认无,可额外扩展 | 内置 labelText + 颜色/字号可配置 |
| 适用场景 | 注册、审批、分步引导 | 任务执行、数据处理、机器人任务、长流程 |
| UI 风格 | 节点明确、结构清晰 | 更现代、有节奏感的进度展示 |
一张嘴上的总结:
想突出「这是一个 4 步操作,现在在第 2 步」→ 用 方案一 动画节点 Step Bar
想突出「任务整体完成进度 + 状态文案」→ 用 方案二 连续填充 Stage Bar
