Eason最近遇到一个需求,需要去展示分段式的进度条,为了给这个进度条想要的外观和感觉,在构建用户界面 (UI) 时,大家通常会依赖 SDK 提供的可用工具并尝试通过调整SDK来适配当前这个UI需求;但悲伤的是,大多数情况下它基本不符合我们的预期。所以Eason决定自己绘制它。
创建自定义视图
在 Android 中要绘制自定义动图,大家需要使用Paint并根据Path对象引导绘制到画布上。
我们可以直接在画布Canvas中操作上面的所有对象View。更具体地说,所有图形的绘制都发生在onDraw()回调中。
- class SegmentedProgressBar @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
- ) : View(context, attrs, defStyleAttr) {
- override fun onDraw(canvas: Canvas) {
- // Draw something onto the canvas
- }
回到进度条,让我们从开始对整个进度条的实现进行分解。
整体思路是:先绘制有一组显示不同角度的四边形,它们彼此间隔开并且具有没有空间的填充状态。最后,我们有一个波浪动画与其填充进度同步。
在尝试满足上述所有这些要求之前,我们可以从一个更简单的版本开始。不过不用担心。我们会从基础的开始并逐步深入浅出的!
绘制单段进度条
第一步是绘制其最基本的版本:单段进度条。
暂时抛开角度、间距和动画等复杂元素。这个自定义动画整体来说只需要绘制一个矩形。我们从分配 aPath和一个Paint对象开始。
- private val segmentPath: Path = Path()
- private val segmentPaint: Paint = Paint( Paint.ANTI_ALIAS_FLAG )
尽量不在onDraw()方法内部分配对象。这两个Path和Paint对象必须在其范围之内创建。在View很多时候调用这个onDraw回调时将导致你内存逐渐减少。编译器中的 lint 消息也会警告大家不要这样做。
要实现绘图部分,我们可能要选择Path的drawRect()方法。因为我们将在接下来的步骤中绘制更复杂的形状,所以更倾向于逐点绘制。
- moveTo():将画笔放置到特定坐标。
- lineTo(): 在两个坐标之间画一条线。
- 这两种方法都接受Float值作为参数。
从左上角开始,然后将光标移动到其他坐标。
下图表示将绘制的矩形,给定一定的宽度 ( w ) 和高度 ( h )。
在Android中,绘制时,Y轴是倒置的。在这里,我们从上到下计算。
绘制这样的形状意味着将光标定位在左上角,然后在右上角画一条线。
- path.moveTo(0f, 0f)
- path.lineTo(w, 0f)
在右下角和左下角重复这个过程。
- path.lineTo(w, h)
- path.lineTo(0f, h)
最后,关闭路径完成形状的绘制。
- path.close()
计算阶段已经完成。是时候用paint给它涂上颜色了!
针对Paint对象的处理,大家可以使用颜色、Alpha 通道和其他选项。Paint.Style枚举决定形状是否将被填充(默认)、空心有边框或两者兼而有之。在示例中,将绘制一个带有半透明灰色的填充矩形:
- paint.color = color
- paint.alpha = alpha.toAlphaPaint()
对于 alpha 属性,Paint需要Integer从 0 到 255。由于更习惯于Float从 0 到 1操作 a ,我创建了这个简单的转换器
- fun Float.toAlphaPaint(): Int = (this * 255).toInt()
上面已准备好呈现我们的第一个分段进度条。我们只需要将我们的Paint按照计算出的x和y方向绘制在canvas上。
- canvas.drawPath(path,paint)
下面是部分代码:
- class SegmentedProgressBar @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
- ) : View(context, attrs, defStyleAttr) {
- @get:ColorInt
- var segmentColor: Int = Color.WHITE
- var segmentAlpha: Float = 1f
- private val segmentPath: Path = Path()
- private val segmentPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
- override fun onDraw(canvas: Canvas) {
- val w = width.toFloat()
- val h = height.toFloat()
- segmentPath.run {
- moveTo(0f, 0f)
- lineTo(w, 0f)
- lineTo(w, h)
- lineTo(0f, h)
- close()
- }
- segmentPaint.color = segmentColor
- segmentPaint.alpha = alpha.toAlphaPaint()
- canvas.drawPath(segmentPath, segmentPaint)
- }
- }
使用多段进度条前进
是不是感觉已经差不多快完成了呢?对的!已经完成了大部分自定义动画的工作。我们将为每个段创建一个实例,而不是操作唯一的Path和Paint对象。
- var segmentCount: Int = 1 // Set wanted value here
- private val segmentPaths: MutableList<Path> = mutableListOf()
- private val segmentPaints: MutableList<Paint> = mutableListOf()
- init {
- (0 until segmentCount).forEach { _ ->
- segmentPaths.add(Path())
- segmentPaints.add(Paint(Paint.ANTI_ALIAS_FLAG))
- }
- }
我们一开始没有设置间距,如果需要绘制多段动画,则要相应地划分View宽度,但是比较省心的是不需要考虑高度。和之前一样,需要找到每段的四个坐标。我们已经知道 Y 坐标,因此找到计算 X 坐标的方程很重要。
下面是一个三段式进度条。我们通过引入线段宽度(sw)和间距(s)元素来注释新坐标。
从上述图中可以看到,X坐标取决于:
- 每段开始的位置(startX)
- 总段数(count)
- 段间距量(s)
有了这三个变量,我们就可以从这个进度条计算任何坐标:
每段的宽度:
- val sw = (w - s * (count - 1)) / count
从左坐标开始对于每个线段,X 坐标位于线段宽度sw加上间距处s,按上述关系可以得到:
- val topLeftX = (sw + s) * 位置
- val bottomLeftX = (sw + s) * 位置
同理右上角和右下角:
- val topRightX = sw * (position + 1) + s * position
- val bottomRightX = sw * (position + 1) + s * position
开始绘制
- class SegmentedProgressBar @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
- ) : View(context, attrs, defStyleAttr) {
- @get:ColorInt
- var segmentColor: Int = Color.WHITE
- var segmentAlpha: Float = 1f
- var segmentCount: Int = 1
- var spacing: Float = 0f
- private val segmentPaints: MutableList<Paint> = mutableListOf()
- private val segmentPaths: MutableList<Path> = mutableListOf()
- private val segmentCoordinatesComputer: SegmentCoordinatesComputer = SegmentCoordinatesComputer()
- init {
- initSegmentPaths()
- }
- override fun onDraw(canvas: Canvas) {
- val w = width.toFloat()
- val h = height.toFloat()
- (0 until segmentCount).forEach { position ->
- val path = segmentPaths[position]
- val paint = segmentPaints[position]
- val segmentCoordinates = segmentCoordinatesComputer.segmentCoordinates(position, segmentCount, w, spacing)
- drawSegment(canvas, path, paint, segmentCoordinates, segmentColor, segmentAlpha)
- }
- }
- private fun initSegmentPaths() {
- (0 until segmentCount).forEach { _ ->
- segmentPaths.add(Path())
- segmentPaints.add(Paint(Paint.ANTI_ALIAS_FLAG))
- }
- }
- private fun drawSegment(canvas: Canvas, path: Path, paint: Paint, coordinates: SegmentCoordinates, color: Int, alpha: Float) {
- path.run {
- reset()
- moveTo(coordinates.topLeftX, 0f)
- lineTo(coordinates.topRightX, 0f)
- lineTo(coordinates.bottomRightX, height.toFloat())
- lineTo(coordinates.bottomLeftX, height.toFloat())
- close()
- }
- paint.color = color
- paint.alpha = alpha.toAlphaPaint()
- canvas.drawPath(path, paint)
- }
- }
path.reset(): 绘制每个线段时,我们首先在移动到所需坐标之前重置路径。
绘制进度
我们已经绘制了组件的基础。然而目前我们不能称它为进度条。因为还没有显示进度的部分。我们应该加入下图的逻辑:
整体思路和之前绘制底部矩形形状时差不多:
- 左坐标将始终为 0。
- 右坐标包括一个max()条件,以防止在进度为 0 时添加负间距。
- val topLeftX = 0f
- val bottomLeftX = 0f
- val topRight = sw * progress + s * max (0, progress - 1)
- val bottomRight = sw * progress + s * max (0, progress - 1)
要绘制进度段,我们需要声明另一个Path和Paint对象,并存储这个对象的progress值。
- var progress: Int = 0
- private val progressPath: Path = Path()
- private val progressPaint: Paint = Paint( Paint.ANTI_ALIAS_FLAG )
然后,我们调用drawSegment()去根据Path,Paint和坐标绘制出图形。
添加动画效果
我们怎么能忍受一个没有动画的进度条?
到目前为止,我们已经知道了如何来计算我们的线段坐标包括起始点。我们将通过在整个动画持续时间内逐步绘制我们的片段来重复此模式。
我们可以分为三个阶段:
开始:我们得到给定当前progress值的段坐标。
正在进行中:我们通过计算新旧坐标之间的线性插值来更新坐标。
结束:我们得到给定新progress值的线段坐标。
我们使用 aValueAnimator将状态从 0(开始)更新到 1(结束)。它将处理正在进行的阶段之间的插值。
- class SegmentedProgressBar @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
- ) : View(context, attrs, defStyleAttr) {
- [...]
- var progressDuration: Long = 300L
- var progressInterpolator: Interpolator = LinearInterpolator()
- private var animatedProgressSegmentCoordinates: SegmentCoordinates? = null
- fun setProgress(progress: Int, animated: Boolean = false) {
- doOnLayout {
- val newProgressCoordinates =
- segmentCoordinatesComputer.progressCoordinates(progress, segmentCount, width.toFloat(), height.toFloat(), spacing, angle)
- if (animated) {
- val oldProgressCoordinates =
- segmentCoordinatesComputer.progressCoordinates(this.progress, segmentCount, width.toFloat(), height.toFloat(), spacing, angle)
- ValueAnimator.ofFloat(0f, 1f)
- .apply {
- duration = progressDuration
- interpolator = progressInterpolator
- addUpdateListener {
- val animationProgress = it.animatedValue as Float
- val topRightXDiff = oldProgressCoordinates.topRightX.lerp(newProgressCoordinates.topRightX, animationProgress)
- val bottomRightXDiff = oldProgressCoordinates.bottomRightX.lerp(newProgressCoordinates.bottomRightX, animationProgress)
- animatedProgressSegmentCoordinates = SegmentCoordinates(0f, topRightXDiff, 0f, bottomRightXDiff)
- invalidate()
- }
- start()
- }
- } else {
- animatedProgressSegmentCoordinates = SegmentCoordinates(0f, newProgressCoordinates.topRightX, 0f, newProgressCoordinates.bottomRightX)
- invalidate()
- }
- this.progress = progress.coerceIn(0, segmentCount)
- }
- }
- override fun onDraw(canvas: Canvas) {
- [...]
- animatedProgressSegmentCoordinates?.let { drawSegment(canvas, progressPath, progressPaint, it, progressColor, progressAlpha) }
- }
- }
为了得到线性插值(lerp),我们使用扩展方法将原始值(this)与end某个步骤上的值()进行比较amount。
- fun Float.lerp(
- end: Float,
- @FloatRange(from = 0.0, to = 1.0) amount: Float
- ): Float =
- this * (1 - amount.coerceIn (0f, 1f)) + end * amount。强制输入(0f,1f)
随着动画的进行,记录下当前坐标并计算给定动画位置的最新坐标 (amount)。
由于该invalidate()方法,然后发生渐进式绘图。使用它会强制View调用onDraw()回调。
现在有了这个动画,大家已经实现了一个组件来重现符合 UI 要求的原生 Android 进度条。
用斜角装饰你的组件
即使组件已经满足了我们对分段进度条的预期功能要求,但Eason想对它锦上添花。
为了打破立方体设计,可以使用斜角来塑造不同的线段。每个段之间保持空间,但我们以特定角度弯曲内部段。
是不是觉得无从下手?让我们放大局部:
我们控制高度和角度,需要计算虚线矩形和三角形之间的距离。
如果大家还记得一些三角形的切线。在上图中,我们在方程中引入了另一种化合物:线段切线 ( st )。
在 Android 中,该tan()方法需要一个以弧度为单位的角度。所以你必须先转换它:
- val segmentAngle = Math.toRadians(angle.toDouble())
- val segmentTangent = h * tan (segmentAngle).toFloat()
使用这个最新的元素,我们必须重新计算段宽度的值:
- val sw = (w - (s + st) * (count - 1)) / count
我们可以继续修改我们的方程。但首先,我们还需要重新考虑如何计算间距。
引入角度打破了我们对间距的感知,使得它不再在一个水平面上。大家自己看吧
我们想要的间距 ( s ) 不再与方程中使用的段间距 ( ss )匹配,所以调整计算这个间距的方式很重要。不过结合毕达哥拉斯定理应该可以解决问题:
- val ss = sqrt (s. pow (2) + (s * tan (segmentAngle).toFloat()). pow (2))
- val topLeft = (sw + st + s) * position
- val bottomLeft = (sw + s) * position + st * max (0, position - 1)
- val topRight = (sw + st) * (position + 1) + s *位置 - if (isLast) st else 0f
- val bottomRight = sw * (position + 1) + (st + s) * position
从这些等式中,可以得出两件点:
- 左下角坐标有一个max()条件,可以避免在第一段的边界之外绘制。
- 右上角的最后一段也有同样的问题,不应添加额外的段切线。
为了结束计算部分,我们还需要更新进度坐标:
- val topLeft = 0f
- val bottomLeft = 0f
- val topRight = (sw + st) * progress + s * max (0, progress - 1) - if (isLast) st else 0f
- val bottomRight = sw * progress + (st + s) *最大(0,进度 - 1)
完整代码:
- class SegmentedProgressBar @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
- ) : View(context, attrs, defStyleAttr) {
- @get:ColorInt
- var segmentColor: Int = Color.WHITE
- set(value) {
- if (field != value) {
- field = value
- invalidate()
- }
- }
- @get:ColorInt
- var progressColor: Int = Color.GREEN
- set(value) {
- if (field != value) {
- field = value
- invalidate()
- }
- }
- var spacing: Float = 0f
- set(value) {
- if (field != value) {
- field = value
- invalidate()
- }
- }
- // TODO : Voluntarily coerce value between those angle to avoid breaking quadrilateral shape
- @FloatRange(from = 0.0, to = 60.0)
- var angle: Float = 0f
- set(value) {
- if (field != value) {
- field = value.coerceIn(0f, 60f)
- invalidate()
- }
- }
- @FloatRange(from = 0.0, to = 1.0)
- var segmentAlpha: Float = 1f
- set(value) {
- if (field != value) {
- field = value.coerceIn(0f, 1f)
- invalidate()
- }
- }
- @FloatRange(from = 0.0, to = 1.0)
- var progressAlpha: Float = 1f
- set(value) {
- if (field != value) {
- field = value.coerceIn(0f, 1f)
- invalidate()
- }
- }
- var segmentCount: Int = 1
- set(value) {
- val newValue = max(1, value)
- if (field != newValue) {
- field = newValue
- initSegmentPaths()
- invalidate()
- }
- }
- var progressDuration: Long = 300L
- var progressInterpolator: Interpolator = LinearInterpolator()
- var progress: Int = 0
- private set
- private var animatedProgressSegmentCoordinates: SegmentCoordinates? = null
- private val progressPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
- private val progressPath: Path = Path()
- private val segmentPaints: MutableList<Paint> = mutableListOf()
- private val segmentPaths: MutableList<Path> = mutableListOf()
- private val segmentCoordinatesComputer: SegmentCoordinatesComputer = SegmentCoordinatesComputer()
- init {
- context.obtainStyledAttributes(attrs, R.styleable.SegmentedProgressBar, defStyleAttr, 0).run {
- segmentCount = getInteger(R.styleable.SegmentedProgressBar_spb_count, segmentCount)
- segmentAlpha = getFloat(R.styleable.SegmentedProgressBar_spb_segmentAlpha, segmentAlpha)
- progressAlpha = getFloat(R.styleable.SegmentedProgressBar_spb_progressAlpha, progressAlpha)
- segmentColor = getColor(R.styleable.SegmentedProgressBar_spb_segmentColor, segmentColor)
- progressColor = getColor(R.styleable.SegmentedProgressBar_spb_progressColor, progressColor)
- spacing = getDimension(R.styleable.SegmentedProgressBar_spb_spacing, spacing)
- angle = getFloat(R.styleable.SegmentedProgressBar_spb_angle, angle)
- progressDuration = getInteger(R.styleable.SegmentedProgressBar_spb_duration, progressDuration)
- recycle()
- }
- initSegmentPaths()
- }
- fun setProgress(progress: Int, animated: Boolean = false) {
- doOnLayout {
- val newProgressCoordinates =
- segmentCoordinatesComputer.progressCoordinates(progress, segmentCount, width.toFloat(), height.toFloat(), spacing, angle)
- if (animated) {
- val oldProgressCoordinates =
- segmentCoordinatesComputer.progressCoordinates(this.progress, segmentCount, width.toFloat(), height.toFloat(), spacing, angle)
- ValueAnimator.ofFloat(0f, 1f)
- .apply {
- duration = progressDuration
- interpolator = progressInterpolator
- addUpdateListener {
- val animationProgress = it.animatedValue as Float
- val topRightXDiff = oldProgressCoordinates.topRightX.lerp(newProgressCoordinates.topRightX, animationProgress)
- val bottomRightXDiff = oldProgressCoordinates.bottomRightX.lerp(newProgressCoordinates.bottomRightX, animationProgress)
- animatedProgressSegmentCoordinates = SegmentCoordinates(0f, topRightXDiff, 0f, bottomRightXDiff)
- invalidate()
- }
- start()
- }
- } else {
- animatedProgressSegmentCoordinates = SegmentCoordinates(0f, newProgressCoordinates.topRightX, 0f, newProgressCoordinates.bottomRightX)
- invalidate()
- }
- this.progress = progress.coerceIn(0, segmentCount)
- }
- }
- private fun initSegmentPaths() {
- segmentPaths.clear()
- segmentPaints.clear()
- (0 until segmentCount).forEach { _ ->
- segmentPaths.add(Path())
- segmentPaints.add(Paint(Paint.ANTI_ALIAS_FLAG))
- }
- }
- private fun drawSegment(canvas: Canvas, path: Path, paint: Paint, coordinates: SegmentCoordinates, color: Int, alpha: Float) {
- path.run {
- reset()
- moveTo(coordinates.topLeftX, 0f)
- lineTo(coordinates.topRightX, 0f)
- lineTo(coordinates.bottomRightX, height.toFloat())
- lineTo(coordinates.bottomLeftX, height.toFloat())
- close()
- }
- paint.color = color
- paint.alpha = alpha.toAlphaPaint()
- canvas.drawPath(path, paint)
- }
- override fun onDraw(canvas: Canvas) {
- val w = width.toFloat()
- val h = height.toFloat()
- (0 until segmentCount).forEach { position ->
- val path = segmentPaths[position]
- val paint = segmentPaints[position]
- val segmentCoordinates = segmentCoordinatesComputer.segmentCoordinates(position, segmentCount, w, h, spacing, angle)
- drawSegment(canvas, path, paint, segmentCoordinates, segmentColor, segmentAlpha)
- }
- animatedProgressSegmentCoordinates?.let { drawSegment(canvas, progressPath, progressPaint, it, progressColor, progressAlpha) }
- }
- }
希望本文对正在创建组件或者造轮子的大家有所启发。我们公众号团队正在努力将最好的知识带给大家,We’ll be back soon!