前言
笔者最近接触了一个自定义SeekBar的需求,由于比较久没有写自定义View了,思路比较生疏。想着记录一下设计思路,以供日后参考。
需求及界面设计
需求点
基本的需求点如下:
- 拖动方向垂直,从底向上,最小值在最底部
- 支持设置最大值、最小值、当前值以及步长
- 需要显示刻度,默认只展示11个刻度信息,从
[最小值,最大值]
均分为10段展示 - 当前值展示在最上方
界面设计
基于上述的需求点,大致的原型:
ps:这里笔者并没有修改原生SeekBar,而是完全用View来进行绘制和计算拖动。
需求和原型搞定之后,就可以开工了。
属性设置
基于xml的写法,针对最大值、最小值、当前值以及步长的设置。
<resources>
<declare-styleable name="Seekbar">
<attr name="min" format="integer" />
<attr name="max" format="integer" />
<attr name="step" format="integer" />
<attr name="current" format="integer" />
</declare-styleable>
</resources>
然后在控件构造时读取,这些都是基本操作了,就不多说了。
class Seekbar : View {
constructor(context: Context) : super(context) {
progressAttributes = ProgressAttributes(min = 0, max = 100, step = 1, current = 0)
}
constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet) {
val typeArray = context.obtainStyledAttributes(attributeSet, R.styleable.Seekbar)
val min = typeArray.getInteger(R.styleable.Seekbar_min, 0)
val max = typeArray.getInteger(R.styleable.Seekbar_max, 100)
val step = typeArray.getInteger(R.styleable.Seekbar_step, 1)
val current = typeArray.getInteger(R.styleable.Seekbar_current, 0)
progressAttributes =
ProgressAttributes(min = min, max = max, step = step, current = current)
typeArray.recycle()
}
ps:这里只是xml设置的方式,当然也可以通过代码设置。比如fun setMax(max: Int)
之类的。。。
这里笔者使用一个自定义类ProgressAttributes
来缓存设置项
data class ProgressAttributes(
val max: Int = 100,
val min: Int = 0,
val step: Int = 1,
var current: Int = 0
) {
fun length(): Int = max - min + 1
}
要绘制啥
如图所示,对于Bar来说这里需要绘制的是
- Bar的底色,也是0 ~ 100%的区域,这里就需要确定一个矩形,这里命名为
totalProgressRectF
- 由于需要绘制均分为10段的刻度信息,这里就需要得知每段的长度,又因为方便后续的拖动计算,所以这里需要计算出的是
totalProgressRectF
在[min, max]
区间内每一位的高度。这里命名为preValueHeight
- 后续根据当前值
current
以及min
、max
和preValueHeight
确定手指拖动的锚点和进度条,这里分别命名为thumb
和progressRectF
ps:在最上方其实还有一个当前值的绘制,这个不是本文的重点,就不多介绍了。
初始化位置
上述列举的一坨东西,要想绘制出来,首先要确定它们的初始位置。这个操作的时机就定义在View# onSizeChanged
,触发时机在onMeasure
之后。
确定totalProgressRectF
为了让Bar居中:
- 将整个View均分为3份(见图中虚线),Bar在中间的矩形区域
- Bar的起点在整个View高的10%,终点在95%位置
- 进一步缩小Bar的宽度,这里再取中间矩形的1/3
// onSizeChanged
val preWidth = w / 3
val rectF = RectF(
preWidth.toFloat(),
height.toFloat() * 0.1f,
2 * preWidth.toFloat(),
height.toFloat() * 0.95f
)
this.totalProgressRectF.left = rectF.left + rectF.width() / 3
this.totalProgressRectF.top = rectF.top
this.totalProgressRectF.right = rectF.right - rectF.width() / 3
this.totalProgressRectF.bottom = rectF.bottom
确定preValueHeight
确定好Bar的区域后,preValueHeight
就比较简单了。只需要除以[min, max]
的长度即可。
// onSizeChanged
this.preValueHeight = totalProgressRectF.height() / progressAttributes.length()
// ProgressAttributes
fun length(): Int = max - min + 1
ps:这里的+1是因为[min, max]
是左闭右闭区间。
确定thumb和progressRectF
// onSizeChanged
this.updateProgressRectF()
this.thumbWidth = this.totalProgressRectF.width() + 4.dp
this.thumbHeight = this.totalProgressRectF.height() / 20
this.updateThumb()
private fun updateProgressRectF() {
this.progressRectF.left = this.totalProgressRectF.left
this.progressRectF.top = totalProgressRectF.top + (totalProgressRectF.height()
- (progressAttributes.current - progressAttributes.min + 1) * preValueHeight)
this.progressRectF.right = this.totalProgressRectF.right
this.progressRectF.bottom = this.totalProgressRectF.bottom
}
private fun updateThumb() {
thumbRect.left = (totalProgressRectF.left - 4.dp).toInt()
thumbRect.top = (progressRectF.top - thumbWidth / 2).toInt()
thumbRect.right = (totalProgressRectF.right + 4.dp).toInt()
thumbRect.bottom = (progressRectF.top - thumbWidth / 2 + thumbHeight).toInt()
}
thumb
的宽高只是一个拍脑袋的值,无需太在意。thumb
的位置就是需要根据totalProgressRectF
、progressRectF
来确定了,主要是要看progressRectF.top
的位置。
初始化progressRectF.top
this.progressRectF.top = totalProgressRectF.top + (totalProgressRectF.height()
- (progressAttributes.current - progressAttributes.min + 1) * preValueHeight)
progressAttributes.current - progressAttributes.min + 1
:计算出current在[min, max]
的第几位。如:区间[1,134]
,2在该区间的第(2 - 1) + 1 = 2
位;区间[0,100]
;50在该区间的第(50 - 0) + 1
位。ps:这里的第n位,n从1开始。(progressAttributes.current - progressAttributes.min + 1) * preValueHeight
:截止到current位置占整个矩形的高度。totalProgressRectF.height() - (progressAttributes.current - progressAttributes.min + 1) * preValueHeight
:因为从底向上增大,需要进行高度修正。totalProgressRectF.top +
:由于Android坐标系是往下y轴增大,所以补全矩形上方到0的距离。
绘制刻度
初始位置确定之后,就到onDraw
了,这里重点说说刻度位置计算和绘制。
// onDraw
val preValue = progressAttributes.length() / 10f
var value = progressAttributes.min.toFloat()
val lineWidth = width.toFloat() / 9
for (i in 0..10) {
val y =
totalProgressRectF.top + (totalProgressRectF.height() - (value.toInt() - progressAttributes.min + 1) * this.preValueHeight)
canvas.drawLine(lineWidth * 2, y, lineWidth * 3, y,
paint.let {
it.style = Paint.Style.FILL
it.color = Color.WHITE
it
})
canvas.drawText("${value.toInt()}",
2 * width.toFloat() / 3,
y,
textPaint.let {
it.color = Color.WHITE
it.textSize = 12.sp.toFloat()
it.textAlign = Paint.Align.LEFT
it
})
value += preValue
if (value > progressAttributes.max) value = progressAttributes.max.toFloat()
}
- 根据需求,刻度要保持均分10份,也就是有
0..10
整数区间的循环。 value
用于计算当前刻度值y
为当前刻度值绘制的y轴位置,具体的计算思路与上述的progressRectF.top
计算相同,可参考上述看逻辑。- 关于
drawText
可参考:
触摸事件
ACTION_DOWN
// onTouchEvent
MotionEvent.ACTION_DOWN -> {
if (thumbRect.contains(event.x.toInt(), event.y.toInt())) {
lastUpdateY = event.y
return true
}
}
DOWN
事件需要告诉父View是否消费此次事件,所以需要**判断手指的落点x、y是否在thumbRect
**内。
ps:thumbRect
为计算thumb
的矩形。
ACTION_MOVE
// onTouchEvent
MotionEvent.ACTION_MOVE -> {
val moveH = this.preValueHeight * progressAttributes.step
val dy = event.y - lastUpdateY
if (abs(dy) > moveH) {
var top = progressRectF.top
var currentV = progressAttributes.current
when {
dy > 0 -> { // 向下移
top += moveH
currentV -= progressAttributes.step
}
dy < 0 -> { // 向上移
top -= moveH
currentV += progressAttributes.step
}
else -> {
return true
}
}
when {
top <= totalProgressRectF.top ||
currentV > progressAttributes.max -> {
progressRectF.top = totalProgressRectF.top
progressAttributes.current = progressAttributes.max
}
top >= totalProgressRectF.bottom ||
currentV < progressAttributes.min -> {
progressRectF.top = totalProgressRectF.bottom
progressAttributes.current = progressAttributes.min
}
else -> {
progressRectF.top = top
progressAttributes.current = currentV
}
}
lastUpdateY = event.y
updateThumb()
invalidate()
}
}
- 因为有步长的设计,这里每回调一次
ACTION_MOVE
就认为是移动一个步长的距离moveH
:计算一个步长的距离在实际视图中的长度dy
:计算这一次与上一次在y轴的差值- 若dy > 0认为是向上移,dy < 0是向下移
- 由于Android视图坐标系y轴向下为正,所以向上移需要
-moveH
,同时当前值需要增加一个步长
abs(dy) > moveH
:需要确定移动距离已经超过一个步长的长度,移动才有效
- 需要增加边界判断,当top比
totalProgressRectF.top
还小或新计算的当前值currentV
比最大值max还大,都视为已经达到最大值的状态,反之亦然。 - 最后就是更新相关的数据,调用
invalidate()
重新触发onDraw
绘制。
最后
来看看效果,笔者还加了一个数值更新的监听。本文记录了一个自定义SeekBar
的设计思路,如果您有更好的建议也可在下方评论,一起交流。