前言
之前我们使用了第一种滑动方案,虽然滑动的效果有了,但是和我们期望的还是有很大差距,例如滑动的时候,坐标轴跟着滑动图标一起滑动了,导致我们向右滑动一段距离之后,就看到不坐标轴了。
但是我们也找到了解决办法:
不移动View的scrollX,而是对画布Canvas进行偏移。
今天我们就来实现这种方案。
正文
整体改造的思路:
1、手动记录偏移值,相当于之前的ScrollX
2、computeScroll只用来计算新的偏移值,然后重绘
3、修改绘制onDraw方法,根据偏移值绘制图表
首先实现第一个修改点:
/**
* 记录手指划过的距离
* */
protected var offsetX: Float = 0f
/**
* 手指滑动距离的备份,用于判断是否手指移动了
* */
protected var offsetXTemp: Float = 0f</pre>
/**
* 重写手势
* */
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
// 如果不能滑动,不处理手势滑动
if (!canScroll) {
return false
}
// 计算滑动的速度
createVelocityTracker(event)
when (event.action) {
// 记录手指按下的坐标
MotionEvent.ACTION_DOWN -> {
xDown = event.rawX
}
// 手势滑动
MotionEvent.ACTION_MOVE -> {
// 更新xDown的坐标
if (xMove != -1f) {
xDown = xMove
}
// 备份偏移的位置
offsetXTemp = offsetX
// 记录当前的x坐标
xMove = event.rawX
// 计算移动的位置
offsetX += (xDown - xMove)
// 对移动的位置进行范围检查
// 如果小于0,那么等于0
if (offsetX < 0) {
offsetX = 0f
}
// 如果已经大于了最右边界
else if (offsetX > maxWidth - width) {
offsetX = maxWidth - width.toFloat()
}
// 检查偏移值是否发生了改变
if (offsetX != offsetXTemp) {
// 重绘
invalidate()
}
}
// 手势抬起
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
val dx = calculateFlingDistance()
// startScroll()方法来初始化滚动数据并刷新界面
scroller.startScroll(offsetX.toInt(), 0, dx, 0)
invalidate()
recycleVelocityTracker()
// 重置配置信息
reset()
}
}
return true
}
先定义了两个变量,offsetX记录X方向上的偏移值,offsetTemp主要是了为了不必要的重绘,如果偏移值没有发生变化,重绘也是没有必要的。
同样要滑动边界的检查,用offsetX替换掉之前的scrollX,这样就完成了我们的第一步。
第二步,修改computeScroll方法,也是非常的简单:
override fun computeScroll() {
// 第二步,重写computeScroll()方法,并在其内部完成平滑滚动的逻辑
if (scroller.computeScrollOffset()) {
offsetX = scroller.currX.toFloat()
Log.e("lzp", "currX is :${scroller.currX}")
invalidate()
}
}
我们把scroller计算的结果和offsetX进行计算,得到新的offsetX。
接下来是最后一步,也是修改中最复杂的一步:
我们希望图表可以滑动,就需要考虑绘制的效率,内存等等问题,例如RecyclerView,如果没有对item进行复用和缓存,无限制的创建View,内存肯定就要飞速的上涨,最终程序崩溃。
如果我们的图表有几千条几万条数据,把所有的数据都绘制出来,那内存肯定就起飞了,并且绘制的速度也会变慢,用户体验也会非常的差,所以首要任务,是找到绘制的范围:开始位置和结束位置。
/**
* 根据偏移值,计算绘制的数据的开始位置
* */
protected fun getDataStartIndex(): Int {
// 计算每一个刻度的宽度
val markWidth = width / xLineMarkCount
// 计算已经偏移了几个刻度
val index = (offsetX / markWidth).toInt()
// 为了绘制第一条能够和前一条有连线,所以我们要减1
return Math.max(0, index - 1)
}
/**
* 根据偏移值,计算绘制的数据的结束位置
* */
protected fun getDataEndIndex(startIndex: Int): Int {
// 如果绘制的是第一个,直接返回偏移值
return Math.min(startIndex + xLineMarkCount + 2, adapter!!.maxDataCount)
}
/**
* 计算canvas绘制的偏移值
*
* 偏移值 - 刻度值宽度 * 开始位置,相当于对刻度值宽度取模
* */
protected fun getCanvasOffset(): Float {
val markWidth = width / xLineMarkCount
// 计算已经偏移了几个刻度
val index = (offsetX / markWidth).toInt()
// 如果绘制的是第一个,直接返回偏移值
return if (index == 0) {
-offsetX % markWidth
}
// // 为了绘制第一条能够和前一条有连线,所以我们要减去刻度值的宽度
else {
-offsetX % markWidth - markWidth
}
}
首先计算绘制开始的位置,用offsetX与一个的刻度的宽度相除,得到了已经滑过的刻度个数,就是数据列表的索引,超出屏幕的数据就不需要绘制了,然后需要绘制当前位置与前一个位置的数据连线,所以这里就简单粗暴的对第一个应该绘制的数据的索引-1,如果是第一个,就直接返回0。
计算绘制结束的刻度,用开始的index加上需要绘制的刻度,因为要可能要绘制与之后的数据连线,并且开始位置已经-1,所以这里先简单的+2,不要忘了和数据的长度进行比较,否则就会出现越界异常。
最后计算canvas应该偏移的位置,滑动的刻度已经不再考虑范围之内,但是这里会有两种情况:
1、如果是第一条数据,他没有之前的连线,所以直接用偏移值就可以了
2、如果不是第一条,因为-1的缘故,所以还要-markWidth。
ok,最关键的三个值我们已经都准备好了,就可以放心的修改onDraw方法了:
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 保存一下canvas的状态
canvas.save()
// 绘制X轴和Y轴
drawXYLine(canvas)
// 从这里开始,我们要对canvas进行偏移
canvas.translate(getCanvasOffset(), 0f)
// 绘制每一条数据之间的间隔虚线
drawDashLine(canvas)
// 绘制数据
drawData(canvas)
// 恢复一下canvas的状态
canvas.restore()
}
/**
* 绘制数据之间
*
* 根据偏移值计算要绘制的区域
* */
private fun drawDashLine(canvas: Canvas) {
// 通过x轴的刻度间隔,计算x轴坐标
val xItemSpace = width / xLineMarkCount.toFloat()
// 设置画笔的效果
paint.color = dashLineColor
paint.strokeWidth = dashLineWidth
paint.pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 1f)
// 画条目之间的间隔虚线,从Data的开始位置绘制到结束位置
val startIndex = getDataStartIndex()
val endIndex = getDataEndIndex(startIndex)
var index = startIndex
while (index < endIndex) {
val startY = xItemSpace * (index - startIndex)
val path = Path()
path.moveTo(startY, 0f)
path.lineTo(startY, height.toFloat())
canvas.drawPath(path, paint)
index++
}
}
/**
* 绘制数据曲线
* */
private fun drawData(canvas: Canvas) {
// 设置画笔样式
paint.pathEffect = null
// 得到数据列表, 如果是null,取消绘制
val dataList = adapter?.getData() ?: return
// 绘制每一条数据列表
for (item in dataList) {
drawItemData(canvas, item)
}
}
/**
* 绘制一条数据曲线
* */
private fun drawItemData(canvas: Canvas, data: List<ChartBean>) {
// 通过x轴的刻度间隔,计算x轴坐标
val xItemSpace = width / xLineMarkCount
val path = Path()
val dotPath = Path()
// 绘制开始位置到结束位置的数据
val startIndex = getDataStartIndex()
val endIndex = getDataEndIndex(startIndex)
var index = startIndex
while (index < endIndex) {
// 因为数据的长度不统一,所以这里要做数据的场地检查
if (index >= data.size){
break
}
// 计算每一个点的位置
val item = data[index]
// 计算绘制的x坐标
val xPos = (xItemSpace / 2 + (index - startIndex) * xItemSpace).toFloat()
// 计算绘制的y坐标
val yPos = calculateYPosition(item)
// 设置Path路径
if (index == startIndex) {
path.moveTo(xPos, yPos)
} else {
path.lineTo(xPos, yPos)
}
dotPath.addCircle(xPos, yPos, dotWidth, Path.Direction.CW)
// 绘制文字
drawText(canvas, item, xPos, yPos)
index++
}
// 绘制曲线
paint.style = Paint.Style.STROKE
paint.color = chartLineColor
paint.strokeWidth = chartLineWidth
canvas.drawPath(path, paint)
// 绘制圆点
paint.color = dotColor
paint.style = Paint.Style.FILL
canvas.drawPath(dotPath, paint)
}
首先直接绘制XY坐标轴,然后对Canvas绘制的位置进行偏移,绘制虚线和连线,这里使用了刚刚计算的三个值,只绘制了展示的位置的图表,而不是全部画出来。
OK,运行程序欣赏一下我们的劳动成果:
image总结
终于看到了我们期望的效果,到现在为止图表绘制的核心功能我们已经攻破了,但是作为程序员还是要有追求的,我们的demo还是有很大的不足,例如:
- 滑动不够流畅
- 创建了很多的Path对象,内存浪费
- 一些其他的计算,例如文字的宽度,刻度宽度等等
下一篇的内容就是对刚刚完成的CanvasChartView进行优化。
github下载地址(本文的内容请看view2包中的类)
补充:最近工作比较忙,所以更新的慢了一点,现在优化后的CanvasChartView已经在代码中了:
view1:对应第一种方案的View
view2:对应第二种方案,也就是今天的Demo
view3:对view2优化后的View。
心急的小伙伴可以先直接看代码~
网友评论