前言
之前我们使用了第一种滑动方案,虽然滑动的效果有了,但是和我们期望的还是有很大差距,例如滑动的时候,坐标轴跟着滑动图标一起滑动了,导致我们向右滑动一段距离之后,就看到不坐标轴了。
但是我们也找到了解决办法:
不移动View的scrollX,而是对画布Canvas进行偏移。
今天我们就来实现这种方案。
正文
整体改造的思路:
1、手动记录偏移值,相当于之前的ScrollX
2、computeScroll只用来计算新的偏移值,然后重绘
3、修改绘制onDraw方法,根据偏移值绘制图表
首先实现第一个修改点:
/** * 记录手指划过的距离 * */ protected var offsetX: Float = 0f /** * 手指滑动距离的备份,用于判断是否手指移动了 * */ protected var offsetXTemp: Float = 0f
/** * 重写手势 * */ @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,运行程序欣赏一下我们的劳动成果:
总结
终于看到了我们期望的效果,到现在为止图表绘制的核心功能我们已经攻破了,但是作为程序员还是要有追求的,我们的demo还是有很大的不足,例如:
- 滑动不够流畅
- 创建了很多的Path对象,内存浪费
- 一些其他的计算,例如文字的宽度,刻度宽度等等
下一篇的内容就是对刚刚完成的CanvasChartView进行优化。
github下载地址(本文的内容请看view2包中的类)
补充:最近工作比较忙,所以更新的慢了一点,现在优化后的CanvasChartView已经在代码中了:
view1:对应第一种方案的View
view2:对应第二种方案,也就是今天的Demo
view3:对view2优化后的View。
心急的小伙伴可以先直接看代码~