先来看看效果图
分析一波
1、视图宽高固定
2、有坐标轴的视图,第一件事,当然还是计算确认出0点的位置了,之后计算出XY轴各自的长度
3、计算完坐标轴,就可以通过一共有多少个刻度,计算出每个刻度之间的间距,就可以得到所有坐标轴点的位置了,同时坐标轴内的虚线坐标网格也能确定位置了
4、确定了所有坐标轴相关信息,基本数据点的位置也可以计算出来了:数据点的值除以单个刻度的值,就可以计算出数据点位于第几个刻度的位置
5、在视图右上角位置绘制出数据标题,用以区分每条数据线对应的类型
6、可以对视图进行缩放、移动,默认是所有数据能看到的情形,所以不能再进行缩小,放大则不能超过设置的最大倍数
7、可以点击数据标题,进行显示/隐藏对应的数据线
看看整体的属性
ini
// region 提供外部设置属性
// 屏幕方向是否为垂直方向
var isScreenVertical = false
set(value) {
if (field != value) {
field = value
xOffset = 0f
yOffset = 0f
preCurrX = 0
preCurrY = 0
xAxisLength = yAxisLength.also { yAxisLength = xAxisLength }
refresh()
}
}
// 整体坐标轴偏移量
var offset = 100f
set(value) {
if (field != value) {
xAxisLength = xAxisLength + 2 * field - 2 * value
yAxisLength = yAxisLength + 2 * field - 2 * value
field = value
refresh()
}
}
// 坐标轴箭头偏移量
var arrowOffset = 12f
set(value) {
if (field != value) {
field = value
refresh()
}
}
// 文本与坐标轴偏移量
var textOffset = 10f
set(value) {
if (field != value) {
field = value
refresh()
}
}
// 坐标轴字体大小
var axisTextSize = 15f
set(value) {
if (field != value) {
field = value
refresh()
}
}
// 线上数据文本字体大小
var lineTextSize = 10f
set(value) {
if (field != value) {
field = value
refresh()
}
}
// 线对应的类型文本字体大小
var lineTypeTextSize = 12f
set(value) {
if (field != value) {
field = value
refresh()
}
}
var lineTypeTextSpace = 30
set(value) {
if (field != value) {
field = value
refresh()
}
}
// endregion
private val axisPaint = Paint()
private val axisTextPaint = Paint()
private val linePaint = Paint()
private val guidelinePaint = Paint()
private var backgroundColor = Color.WHITE
private var minTextSpace = 100f
private val chartBeanList = ArrayList<ArrayList<ChartBean>>()
private val chartBeanTypeList = ArrayList<String>()
private var xAxisLength = 0f // x轴长度
private var yAxisLength = 0f // y轴长度
private var maxXPieceIndex = 0
private var maxXPieceCount = 0 //坐标x轴分片总数量
private var xPieceInterval = 0f //坐标x轴分片间距
private var maxYPieceCount = 0 //坐标y轴分片总数量
private var yPiece = 0 //坐标y轴分片单片大小
private var yPieceInterval = 0f //坐标y轴分片间距
private var minY = -1 //数据中y轴方向包含的最小值
private var maxY = 0 //数据中y轴方向包含的最大值
private var axisPath: Path? = null //坐标轴路径
private val pathMap = hashMapOf<String, Path?>() //数据线
private val pointsMap = hashMapOf<String, MutableList<Float>?>() //数据点
private val colorMap = hashMapOf<String, String>() //每条数据线的颜色
private val scaleGestureDetector: ScaleGestureDetector
private val gestureDetector: GestureDetector
private val scroller: Scroller
private val minScale = 1f
private val maxScale = 10f
private var scale = 1f
private var xOffset = 0f //x轴的偏移量
private var yOffset = 0f //y轴的偏移量
// 不进行绘制的类型
private val noDrawType = ArrayList<String>()
// 类型标题的坐标,用于点击判断
private val typeRectF = HashMap<String, RectF>()
属性相对较多,需要控制各种颜色大小偏移量等,操作起来相对复杂
其中x轴长度xAxisLength = width - offset * 2
y轴长度yAxisLength = height - offset * 2
通过数据进行计算相应坐标位置
kotlin
fun addChartBeanList(chartBeanType: String, chartBeanList: ArrayList<ChartBean>) {
try {
chartBeanTypeList.add(chartBeanType)
typeRectF[chartBeanType] = RectF()
this.chartBeanList.add(ArrayList(chartBeanList))
checkMaxValue(chartBeanList)
colorMap[chartBeanType] = getRandColor()
refresh()
} catch (e: Exception) {
e.printStackTrace()
}
}
data class ChartBean(val axisInfo: String, val num: Int)
通过addChartBeanList就可以进行添加一条数据线,其中chartBeanType对应的数据线的标题,chartBeanList则为该线对应的所有数据点,ChartBean仅包含对应的x轴位置标题及y轴值。
注意:由于X轴的刻度名称都是字符串,没办法通过数据点直接获取第一个和最后一个的名称,也没办法确认需要绘制多少个刻度,所以目前采用的是:chartBeanList里面包含了所有的X轴刻度,后续考虑通过单独的属性进行传递设置。 下面通过传递过来的数据点进行计算以下属性:
kotlin
private var axisPath: Path? = null //坐标轴路径
private val pathMap = hashMapOf<String, Path?>() //数据线
private val pointsMap = hashMapOf<String, MutableList<Float>?>() //数据点
private var yPiece = 0 //坐标y轴刻度值间隔大小
maxXPieceCount = max(maxXPieceCount, chartBeanList.size + 1) //坐标x轴刻度总数量
xPieceInterval = xAxisLength / maxXPieceCount //坐标x轴刻度间距
maxY = max(maxY, dailyInfo.num) //数据中y轴方向包含的最大值
minY = min(minY, dailyInfo.num) //数据中y轴方向包含的最小值
maxYPieceCount = ((maxY - minY) / yPiece + 1) //坐标y轴刻度总数量
yPieceInterval = yAxisLength / maxYPieceCount //坐标y轴刻度间距
// 计算yPiece y轴刻度值间隔大小,从1开始,以10为单位进行计算
var digits = 0 //位数
var tempMaxY = maxY - minY
while (tempMaxY > 1) {
digits++
tempMaxY /= 10
}
if (digits > 0) { // 位数大于0则表明maxY大于1
yPiece = 1
while (digits > 1) {
yPiece *= 10
digits--
}
}
同时可以得到坐标轴三个点确认坐标轴路径axisPath:
0点(offset, offset + yAxisLength + arrowOffset)
y顶点(offset, offset)
x顶点(offset + xAxisLength + arrowOffset, offset + yAxisLength + arrowOffset)
连接y-0,0-x即可得到坐标轴
坐标轴得到了,x轴刻度间距得到了,y轴刻度间距得到了,那中间的网格线位置也同时都能确定了。右上角的数据标题,直接获取到右上角的点,然后测量最长的标题长度,减去长度的一半,即可得到文本绘制的中心位置,然后依次往下绘制标题即可。剩下数据点和数据线的位置需要确认:
ini
// 创建数据线path及数据点point
private fun createPathAndPoints(chartBeanType: String, chartBeanList: List<ChartBean>) {
if (chartBeanList.isNotEmpty()) {
var path = pathMap[chartBeanType]
var points = pointsMap[chartBeanType]
val xOffset = (maxXPieceCount - 1 - chartBeanList.size) * xPieceInterval * scale
var x: Float
var y: Float
for (index in chartBeanList.indices) {
if (isScreenVertical) {
x = offset + (index + 1) * xPieceInterval * scale + xOffset + this.xOffset
y = getVerticalYAxis(chartBeanList[index]) + this.yOffset
} else {
x = getHorizontalXAxis(chartBeanList[index]) + this.yOffset
y = offset + (index + 1) * xPieceInterval * scale + xOffset + this.xOffset
}
if (path == null) {
path = Path()
pathMap[chartBeanType] = path
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
if (points == null) {
points = mutableListOf()
pointsMap[chartBeanType] = points
}
points.add(x)
points.add(y)
}
}
}
通过createPathAndPoints进行计算所有数据点points和数据线pathMap的坐标,用于后续绘制。
数据线就是直接连接所有数据点即可,所以主要在于计算数据点的位置:
数据点的x轴位置 = offset + (索引 + 1)* xPieceInterval
y轴位置计算相对复杂,需要计算出对应的y轴刻度位置:其中yAxisLength + offset + arrowOffset为0点y轴位置,(count * yPieceInterval + last * yPieceInterval / yPiece)则对应y轴刻度位置
kotlin
private fun getVerticalYAxis(chartBean: ChartBean): Float {
val count = (chartBean.num - minY) / yPiece
val last = (chartBean.num - minY) % yPiece
return yAxisLength + offset + arrowOffset - (count * yPieceInterval + last * yPieceInterval / yPiece) * scale
}
以上,所以需要计算的位置都计算完成了,后续只需要根据这些位置信息进行绘制即可。
着手绘制onDraw
scss
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (axisPath != null) {
drawDataLine(canvas)
drawBackground(canvas)
// 绘制数据先对应的标题
drawDataTitle(canvas)
// 绘制坐标轴
axisPaint.color = Color.BLACK
axisPaint.style = Paint.Style.STROKE
canvas.drawPath(axisPath!!, axisPaint)
drawXAxis(canvas)
drawYAxis(canvas)
}
}
绘制的时候需要注意的是:因为后续会有缩放及移动的操作,那么数据就可能会被移动到坐标轴外,此时需要用背景对坐标轴外的数据进行遮挡,避免影响视图层级,所以这里先进行drawDataLine绘制数据点和数据线,再进行drawBackground绘制坐标轴外的背景,进行遮挡操作,其它绘制顺序则可以不用强制要求。
前面一开始就已经得到坐标轴三个点的位置了,所以判断坐标轴外的操作也能相应得到:
scss
// 绘制遮挡背景
private fun drawBackground(canvas: Canvas) {
// 绘制一层背景遮挡住超出坐标轴的数据
axisPaint.color = backgroundColor
axisPaint.style = Paint.Style.FILL
canvas.drawRect(0f, 0f, offset - axisPaint.strokeWidth / 2, height.toFloat(), axisPaint)
canvas.drawRect(0f, 0f, width.toFloat(), offset, axisPaint)
canvas.drawRect(width.toFloat() - offset, 0f, width.toFloat(), height.toFloat(), axisPaint)
val bottomTop =
if (isScreenVertical) offset + yAxisLength + arrowOffset + axisPaint.strokeWidth / 2 else
offset + xAxisLength + arrowOffset + axisPaint.strokeWidth / 2
canvas.drawRect(
0f, bottomTop, width.toFloat(), height.toFloat(), axisPaint
)
}
其它绘制操作就不详细展开了,都是根据之前计算得到的信息进行相应绘制即可。
添加手势:缩放、移动
手势需要同时实现以下三个接口:
arduino
ScaleGestureDetector.OnScaleGestureListener // 缩放手势
GestureDetector.OnGestureListener // 移动手势
GestureDetector.OnDoubleTapListener // 双击缩放手势
其中移动的原理基本和之前写的日历视图差不多,就不展开了 自定义View,实现日历展示事件 - 掘金 (juejin.cn)
先看看简单的双击缩放:
kotlin
override fun onDoubleTap(e: MotionEvent): Boolean {
scale = when {
scale == maxScale -> {
xOffset = 0f
yOffset = 0f
1f
}
scale >= maxScale / 2 -> maxScale
else -> scale * 2
}
refreshData()
return true
}
双击的时候,判断当前缩放倍率值:为最大,则缩小回1倍,大于等于最大的一半,则放大到最大,其它直接按当前倍率 * 2
以下为缩放手势的逻辑:
kotlin
private var isScaleGesture = false
override fun onScale(detector: ScaleGestureDetector): Boolean {
isScaleGesture = true
val preScale = scale
scale *= detector.scaleFactor
scale = when {
scale <= minScale -> minScale
scale >= maxScale -> maxScale
else -> scale
}
if (preScale != scale) {
checkOffset()
refreshData()
}
return true
}
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = true
override fun onScaleEnd(detector: ScaleGestureDetector) {
isScaleGesture = false
}
获取缩放后的倍率:scale *= detector.scaleFactor,缩放后需要确保再[minScale,maxScale]之间,同时需要进行checkOffset()检测缩放后的位移,避免缩放后位置错乱。
单击手势:隐藏显示对应的数据点/线
单击的时候可以获取到点击的位置x/y,通过判断点击的哪个标题位置,获取到点击的标题,然后添加到noDrawType中,在之前的数据点/线绘制步骤,添加判断noDrawType的进行过滤绘制即可
kotlin
override fun onSingleTapUp(e: MotionEvent): Boolean {
// 仅有一条线,禁止点击隐藏
if (chartBeanTypeList.size <= 1) return true
for ((type, rectF) in typeRectF) {
if (rectF.contains(e.x, e.y)) {
if (noDrawType.contains(type))
noDrawType.remove(type)
else
noDrawType.add(type)
invalidate()
break
}
}
return true
}
private fun drawDataLine(canvas: Canvas) {
for ((offsetCount, chartBeanType) in chartBeanTypeList.withIndex()) {
if (noDrawType.contains(chartBeanType)) continue
...
}
}
完结撒花
大致的实现逻辑就这些了,刚开始写文章,不太会哈哈哈,有什么不懂可以提问哟~大家一起探讨探讨
项目源码:Calendar: 周日历、月日历 用于记录展示事件列表 (gitee.com),目前都是写在这个测试项目呢,点击右上角更多-查看折线图就可以看到了哟
项目里面的ncalendar模块,使用的是GitHub - yannecer/NCalendar: 一款安卓日历,仿miui,钉钉,华为的日历,万年历、365、周日历,月日历,月视图、周视图滑动切换,农历,节气,Andriod Calendar , MIUI Calendar,小米日历