前语
笔者最近接触了一个自定义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
可参阅:- HenCoder Android 开发进阶:自定义 View 1-3 drawText() 文字的制作 (rengwuxian.com)
- Android Canvas.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
的规划思路,假如您有更好的主张也可在下方谈论,一同交流。
代码:CustomView/seekbar