需求
最近需要做一个如下图带步骤的进度UI, 步骤的数量可根据输入而变化:
我们就实现进度条和下面的文本描述, 本来想的是使用RecyclerView加GridLayoutManager来实现, 如分成五份后, 步骤圆点
刚好在每个Item的分界线上, 虽然可以在每个Item布局里画上一条线和半个圆做到, 始尾结点则用完整的圆形, 但是步骤圆点
下方的步骤描述
文本可就没办法了, 所以决定使用自定义View来实现.
准备
首先使用一个列表存储步骤文本
/**
* 步骤视图
* ```
* author: zcp
* created on: 2022/3/15 17:24
* ```
*/
class StepView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val stepTitleList = mutableListOf<String>()
/**
* 当前步数
*/
var stepIndex = 0
set(value) {
field = value
invalidate()
}
/**
* 添加步骤列表
* @param stepTitleList List<String>
*/
fun setList(stepTitleList: List<String>) {
this.stepTitleList.clear()
this.stepTitleList.addAll(stepTitleList)
invalidate()
}
}
测量
自定义View的第一步就是测量啦, 我们只需要处理wrap_content
的情况就行, 如果不处理的话wrap_content
的效果就和match_parent
一样了.
造成这个原因是:
子View中onMeasure(widthMeasureSpec, heightMeasureSpec)
方法里的宽和高测量规格是在其父母ViewGroup中生成的, 默认match_parent
和wrap_content
获取到的大小是一样的, 具体代码可以查看ViewGroup类里的getChildMeasureSpec()
方法.
如下代码, 我们暂时只需要处理当高为wrap_content
时, 提供一个默认高度就行, 而宽度则让它match_parent
和wrap_content
一样. 这样, 自定义View的测量就简单的完成了.
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec)
val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec)
if (heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(widthSpecSize, 44.dp)
} else {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
}
布局
onLayout()
方法用于ViewGroup给子View布局, 但是我们这个并不是一个ViewGroup, 所以无需做任何操作, View类里的onLayout()
本身就是一个空方法:
绘制
最后一步就是绘制出来了, 代码逻辑就是根据输入的步骤数量
算出每段的长度, 根据stepIndex步进
数决定使用的颜色, 和找到绘制需要的x
, y
点, 使用canvas
就能画出来了
View下方的描述
文本就有点麻烦, 因为每段步骤的长度是固定的, 如果描述文本超长了, 这时候还使用canvas.drawText()
方法的话需要手动再次计算换行的位置, 过于繁琐, 所以可以用StaticLayout
类来做, 这个类自动换行文本, 在TextView的内部也有它的身影噢.
绘制的代码有点长, 就不一行行解释了, 知道了思路就行, 其实就是简单的加减乘除计算位置的数学来的.
// ...
override fun onDraw(canvas: Canvas) {
if(stepTitleList.size == 0) {
return
}
val interval = width / (stepTitleList.size - 1)
stepTitleList.forEachIndexed { index, stepTitle ->
// 画圆
if (stepIndex > index) {
paint.style = Paint.Style.STROKE
paint.strokeWidth = lineWidth
} else {
paint.style = Paint.Style.FILL
}
paint.color = if (stepIndex >= index) highlightColor else normalColor
val cx = when (index) {
0 -> semicircleWidth + lineWidth
stepTitleList.lastIndex -> interval * index - (semicircleWidth + lineWidth)
else -> interval.toFloat() * index
}
canvas.drawCircle(cx, drawTop, semicircleWidth, paint)
// 画线
paint.color = if (stepIndex > index) highlightColor else normalColor
paint.strokeCap = Paint.Cap.BUTT
paint.strokeWidth = lineWidth
val starX: Float
val stopX: Float
when (index) {
0 -> {
starX = circleWidth + (lineWidth * 2) + circleLinePadding
stopX = interval - (semicircleWidth + lineWidth + circleLinePadding)
}
stepTitleList.lastIndex - 1 -> {
starX = interval * index + semicircleWidth + lineWidth + circleLinePadding
stopX = interval * (index + 1) - (circleWidth + (lineWidth * 2) + circleLinePadding)
}
stepTitleList.lastIndex -> {
starX = -1f
stopX = -1f
}
else -> {
starX = interval * index + semicircleWidth + lineWidth + circleLinePadding
stopX = interval * (index + 1) - (semicircleWidth + lineWidth + circleLinePadding)
}
}
if (starX > 0 || stopX > 0) {
canvas.drawLine(starX, drawTop, stopX, drawTop, paint)
}
// 绘制文字
textPaint.color = "#CDD2D8".toColorInt()
textPaint.textSize = 10.dp.toFloat()
val alignment = when (index) {
0 -> Layout.Alignment.ALIGN_NORMAL
stepTitleList.lastIndex -> Layout.Alignment.ALIGN_OPPOSITE
else -> Layout.Alignment.ALIGN_CENTER
}
val staticLayout = StaticLayout(
stepTitle, textPaint, interval,
alignment, 1f, 0f, false
)
val dy = drawTop + semicircleWidth + lineWidth + textPadding
val dx = when (index) {
0 -> 0f
stepTitleList.lastIndex -> interval.toFloat() * index - staticLayout.width
else -> interval.toFloat() * index - staticLayout.width / 2
}
canvas.save()
canvas.translate(dx, dy)
staticLayout.draw(canvas)
canvas.restore()
}
}
源码
代码已上传github.com/mainxml/StepView
代码中有个注释起来的init
方法块, 把注释解开就可以在Android Studio里预览了, 记得这是测试代码噢, 正式使用要注释掉的