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!
❤️/ 感激反对 /
以上便是本次分享的全部内容,心愿对你有所帮忙 ^_^
喜爱的话别忘了 分享、点赞、珍藏 三连哦~
欢送关注公众号 程序员巴士 ,来自字节、虾皮、招银的三端兄弟,分享编程教训、技术干货与职业规划,助你少走弯路进大厂。