Eason最近遇到一个需要,须要去展现分段式的进度条,为了给这个进度条想要的外观和感觉,在构建用户界面 (UI) 时,大家通常会依赖 SDK 提供的可用工具并尝试通过调整SDK来适配以后这个UI需要;但悲伤的是,大多数状况下它根本不合乎咱们的预期。所以Eason决定本人绘制它。
创立自定义视图
在 Android 中要绘制自定义动图,大家须要应用Paint并依据Path对象疏导绘制到画布上。
咱们能够间接在画布Canvas中操作下面的所有对象View。更具体地说,所有图形的绘制都产生在onDraw()回调中。
<code class="java">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对象开始。
<code class="java">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)
上面是局部代码:
<code class="java"> 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
开始绘制
<code class="java"> 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(完结)。它将解决正在进行的阶段之间的插值。
<code class="java"> 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。
<code class="java"> 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)
残缺代码:
<code class="java"> 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!
❤️/ 感激反对 /
以上便是本次分享的全部内容,心愿对你有所帮忙^_^
喜爱的话别忘了 分享、点赞、珍藏 三连哦~
欢送关注公众号 程序员巴士,来自字节、虾皮、招银的三端兄弟,分享编程教训、技术干货与职业规划,助你少走弯路进大厂。