前语

笔者最近接触了一个自定义SeekBar的需求,因为比较久没有写自定义View了,思路比较生疏。想着记录一下规划思路,以供日后参阅。

需求及界面规划

需求点

根本的需求点如下:

  • 拖动方向垂直从底向上,最小值在最底部
  • 支持设置最大值、最小值、当时值以及步长
  • 需求显现刻度,默许只展示11个刻度信息,从[最小值,最大值]均分为10段展示
  • 当时值展示在最上方

界面规划

依据上述的需求点,大致的原型:

来自定义一个SeekBar~

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
}

要制作啥

来自定义一个SeekBar~
如图所示,关于Bar来说这儿需求制作的是

  1. Bar的底色,也是0 ~ 100%的区域,这儿就需求确认一个矩形,这儿命名为totalProgressRectF
  2. 因为需求制作均分为10段的刻度信息,这儿就需求得知每段的长度,又因为便利后续的拖动核算,所以这儿需求核算出的是totalProgressRectF[min, max]区间内每一位的高度。这儿命名为preValueHeight
  3. 后续依据当时值current以及minmaxpreValueHeight确认手指拖动的锚点和进度条,这儿分别命名为thumbprogressRectF

ps:在最上方其实还有一个当时值的制作,这个不是本文的重点,就不多介绍了。

初始化方位

上述列举的一坨东西,要想制作出来,首先要确认它们的初始方位。这个操作的机遇就定义在View# onSizeChanged,触发机遇在onMeasure之后。

确认totalProgressRectF

来自定义一个SeekBar~

为了让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的方位就是需求依据totalProgressRectFprogressRectF来确认了,主要是要看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~

来看看作用,笔者还加了一个数值更新的监听。本文记录了一个自定义SeekBar的规划思路,假如您有更好的主张也可在下方谈论,一同交流。

代码:CustomView/seekbar