阅读 1054

小试牛刀-自定义一把刻度尺

前言

上篇文章给大家分析了Android事件传递机制,基本是纯理论内容。光说不练假把式,今来自定义一把可以滑动的刻度尺,通过这个小案例你能从中学到CanvasPaint触摸反馈ScrollerVelocityTracker的基本使用。

先上一波图

1. 绘制刻度

绘制刻度很简单,就是最基本的CanvasPaint的使用,直接上代码:

private fun drawScale(canvas: Canvas) {
        //maxScale是最大刻度(总刻度。好像名字起的有点问题...)
        for (index in 0..maxScale) {
            //每十个刻度有一个粗长刻度
            if (index % 10 == 0) {
                paint.strokeWidth = (scaleWidth * 2).toFloat()
                //当前线段x轴起点
                val drawX = (index * scaleInterval).toFloat()
                //高度是普通刻度两倍
                canvas.drawLine(
                    drawX, 0f,
                    drawX, scaleHeight.toFloat() * 2, paint
                )
                //绘制文字
                canvas.drawText("$index", drawX-scaleTextSize/2, scaleHeight.toFloat() * 3, paint)
            } else {
                paint.strokeWidth = scaleWidth.toFloat()
                //当前线段x轴起点
                val drawX = (index * scaleInterval).toFloat()
                canvas.drawLine(
                    drawX, 0f,
                    drawX, scaleHeight.toFloat(), paint
                )
            }
        }
    }
复制代码

每十个刻度有一个粗长的刻度,并且下方会有刻度信息。执行完这一步我们会得到下面的效果

文字好像有点歪,刚发现,后面再调整吧~~~

2. 拖动刻度尺

想要拖动刻度尺就需要用到我们上一节所说的触摸事件反馈,所以需要我们重写onTouchEvent方法,代码如下:

//1 记录上一次move时的坐标,用于计算每次move的差量
private var lastX = 0
override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                lastX = event.x.toInt()
                //按下时通知父View不要对事件进行拦截
                parent.requestDisallowInterceptTouchEvent(true)
            }
            //2
            MotionEvent.ACTION_MOVE -> {
                currentScrollX -= (event.x - lastX)
                //超出最大滑动范围
                if (currentScrollX > maxScrollX) {
                    currentScrollX = maxScrollX.toFloat()
                }
                //超出最小滑动范围
                if (currentScrollX < minScrollX) {
                    currentScrollX = minScrollX.toFloat()
                }
                scrollTo(currentScrollX.roundToInt(), 0)
                //记录当前坐标
                lastX = event.x.toInt()
                postInvalidate()
            }
        }
        return true
    }
复制代码

注意点:在该案例中我们是通过修改scrollX实现拖动效果,在非ViewGroupView中修改scrollX其实是调整的是画布的位置,而到ViewGroup中修改scrollX是会调整所有子View的位置。

第一步

定义一个变量lastX记录上一次move到的水平方向坐标,用于计算每次move后的偏移量,并且调用父View的requestDisallowInterceptTouchEvent通知其不要对事件进行拦截

第二步

通过lastX配合event.x计算本次事件的偏移量,以累加的方式记录到currentScrollX中,随后通过scrollTo做绝对偏移移动,同时我们要做一个滑动范围的限制。过程中一定要调用postInvalidate或者Invalidate刷新视图。

到这一步我们实现的效果是这样的:

问题很明显,没有惯性滑动。

3. 增加惯性滑动

什么是Scroller?

一般来讲View的惯性滑动都是通过Scroller配合来实现,Scroller本身和View无关,它只是提供了一套过度算法,比如从0..100,Scroller会通过计算会在规定的时间内给你返回一系列0-100之间的值,用于做平滑过度动画。如果还不明白也可以直接参考属性动画,它们实现的功能其实都差不多。

实行惯性滑动(fling效果)的方式有很多,我们通过Scroller配合VelocityTracker来实现,在手指抬起时通过velocityTracker采集到滑动速度,然后通过scrollerfing来实现惯性滑动,代码如下:

此段代码包含下一小节要说的刻度矫正,由于不想贴重复的代码所以在此一并贴出

private var lastX = 0
override fun onTouchEvent(event: MotionEvent): Boolean {
        //开始速度检测,每个事件序列创建一个
        if (velocityTracker == null) {
            velocityTracker = VelocityTracker.obtain()
        }
        velocityTracker?.addMovement(event)
        when (event.actionMasked) {
			...
           	...
            MotionEvent.ACTION_UP -> {
                velocityTracker?.computeCurrentVelocity(1000, maxVelocity.toFloat())
                //计算水平方向速度
                val velocityX = velocityTracker!!.xVelocity.toInt()
                //大于可滑动速度
                if (abs(velocityX) > minVelocity) {
                    fling(-velocityX)
                }
                //没有触发惯性滑动,矫正刻度
                else {
                    correctScale()
                }
                //VelocityTracker回收
                velocityTracker?.recycle()
                velocityTracker = null
            }
        }
        return true
    }
    
    /**
     * 惯性滑动
     * @param vX 单位时间内x轴位移
     */
    private fun fling(vX: Int) {
        scroller.fling(
            currentScrollX.toInt(), 0,
            vX, 0,
            minScrollX, maxScrollX,
            0, 0
        )
        invalidate()
    }
    
    /**
     * draw内部会调用,专门用户处理滑动。
     */
    override fun computeScroll() {
        //滚动未完成完成,已完成就停止刷新界面
        if (scroller.computeScrollOffset()) {
            currentScrollX = scroller.currX.toFloat()
            //滚动view
            scrollTo(currentScrollX.toInt(), 0)
            //刷新界面
            postInvalidate()
            //最后一次惯性滑动,进行矫正刻度
            if (!scroller.computeScrollOffset()) {
                correctScale()
            }
            //改变当前刻度
            changeScale()
        }
        super.computeScroll()
    }
复制代码

提示

在View的draw方法中会提供一个钩子方法computeScroll,每次触发draw的时候都会调用一次这个方法。本案例中会通过postInvalidate触发重绘间接回调computeScroll

在移动过程中不断的将事件添加到velocityTracker中,它内部自己会计算当前的滑动速度。在手指抬起的一瞬间,判断此时的速度是否达到触发惯性滑动的最小速度(速度过慢是不会触发惯性滑动的),如果达到调用scrollerfling,这个过程中配合postInvalidate间接调用computeScroll方法,在内部通过scroller获取到当前应该到达的位置随后通过scrollTo做位置设置。如果scroller结束需要停止调用postInvalidate否则会无限递归。

4. 计算指示器位置和刻度矫正

首先计算指示器的位置

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)

        //中心坐标,最多可见刻度 / 2 * 刻度间隔,得到指示器位置,即scrollX的最小值
        indicatorPointX = (width / scaleInterval) / 2 * scaleInterval
        //最小滚动距离,-指示器x轴偏移量
        minScrollX = -indicatorPointX
        //最大滚动距离,刻度数 * 间隔 - width + width - indicatorPointX。换算后结果如下
        maxScrollX = maxScale * scaleInterval - indicatorPointX
        //给一个初始值
        changeScale()
    }
复制代码

刻度尺初始位置是从0开始,我们的指示器会在中间固定显示。首先计算当前宽度最多显示刻度的个数,随后除2乘刻度间隔 就可以得到中间刻度的位置。由于中间刻度跟宽度有关,所以计算过程我是在onSizeChanged中进行。

关于指示器的绘制 由于指示器需要在中间固定显示,而调整ScrollX会移动画布,即便做矫正处理在快滑时指示器也会左右漂浮不定,所以我在刻度View外面套了一层ViewGroup,指示器在这个ViewGroup中绘制。关于这部分代码非常简单,就不贴了,文章底部会贴出源码地址,有兴趣的可以下载下来阅读。

4.1 刻度矫正

还剩下最后一个问题,当滑动停止后如何将最近的刻度矫正至与指示器对齐?如果你懂了上面的惯性滑动相信你很快就有灵感了,没错也是通过Scroller来实现,上代码:

关于矫正时机的代码在上一小结统一贴出,请配合阅读

private fun correctScale() {
        //x轴偏移量与间隔区域
        val remainder = (currentScrollX % scaleInterval).toInt()
        //如果跟指示器未对其
        if (remainder != 0) {
            //矫正目标刻度
            val correctScrollX =
                if (remainder > (scaleInterval / 2)) {
                    scaleInterval - remainder
                } else {
                    -remainder
                }
            scroller.startScroll(currentScrollX.toInt(), 0, correctScrollX, 0)
        }
    }
复制代码

获取指示器最近的刻度,计算出差值随后开启Scroller进行矫正。

到这一步就能实现我们最初给的效果图了。由于零碎的代码比较多,所以就没在文章一一贴出,感兴趣的可至 github.com/zskingking/… rulermodule中查看完整代码。

上面这个仓库是一个自定义View集合库,包括自定义View、Layout、LayoutManager等等,几乎涵盖所有自定义View知识点。如果你对自定义View一看就懂一写就不会,来我这就对了,我会秉承我一贯的作风,不写晦涩难懂的代码,尽量标清每一行注释。仓库会持续更新,欢迎关注。