在Android开发中,图表是展示数据的重要工具。通过自定义图表,我们可以更好地满足应用的特定需求,提供丰富的交互和视觉效果。本文将探讨如何结合手势滑动、缩放、渐变填充等技术,实现曲线和折线图表的定制。

一、表格View实现
1.绘制XY轴
Kotlin
// 2. 绘制基础坐标轴线 (L型)
canvas.drawLine(paddingLeft, paddingTop, paddingLeft, height - paddingBottom, axisPaint)
canvas.drawLine(
paddingLeft,
height - paddingBottom,
width - paddingRight,
height - paddingBottom,
axisPaint
)
2.绘制坐标轴刻度
Kotlin
/**
* 绘制 Y 轴分段刻度和数值
*/
private fun drawYAxisScales(canvas: Canvas, chartHeight: Float) {
val intervalValue = (maxY - minY) / labelCount
val intervalHeight = chartHeight / labelCount
// 设置文字大小
axisPaint.textSize = DisplayUtils.sp2px(context, 10f)
// 设置对齐方式为右对齐 ---
axisPaint.textAlign = Paint.Align.RIGHT
for (i in 0..labelCount) {
val yValue = minY + i * intervalValue
val yPos = (height - paddingBottom) - (i * intervalHeight)
// 绘制水平辅助线 (除了最底下的一条与 X 轴重合)
if (i > 0) {
canvas.drawLine(paddingLeft, yPos, width - paddingRight, yPos, gridPaint)
}
// 格式化文字
val label = yValue.toInt().toString()
// 计算文字垂直居中偏移
val fontMetrics = axisPaint.fontMetrics
val baseline = yPos - (fontMetrics.ascent + fontMetrics.descent) / 2
canvas.drawText(label, paddingLeft - 10f, baseline, axisPaint)
}
// 重置对齐方式,以免影响 X 轴或其他文字的绘制
axisPaint.textAlign = Paint.Align.LEFT
}
3.绘制坐标轴上的折线,可绘制多条
Kotlin
// 3. 计算坐标并绘制折线
val xStep = chartWidth / (mDataList.size - 1).coerceAtLeast(1)
val points = mutableListOf<PointF>()
for (i in mDataList.indices) {
val x = paddingLeft + i * xStep
val ratio = (mDataList[i].value - minY) / (maxY - minY)
val y = (height - paddingBottom) - (ratio * chartHeight)
points.add(PointF(x, y))
// 绘制 X 轴日期文字
if (mDataList.size < 10 || i % (mDataList.size / 5 + 1) == 0) {
val yy = height - paddingBottom - 10
canvas.drawLine(x, yy, x, height - paddingBottom, linePaint)
axisPaint.textSize = DisplayUtils.sp2px(context, 10f)
val textTopWidth = axisPaint.measureText(mDataList[i].dateTop)
canvas.drawText(
mDataList[i].dateTop,
x - textTopWidth / 2,
height - paddingBottom + 40,
axisPaint
)
val textBottomWidth = axisPaint.measureText(mDataList[i].dateBottom)
canvas.drawText(
mDataList[i].dateBottom,
x - textBottomWidth / 2,
height - paddingBottom + 70,
axisPaint
)
}
}
// 绘制折线、数据点及数值
val pathList = mutableListOf<Path>()
mLineList.forEach {
val pathLine = Path()
for (i in it.indices) {
val x = paddingLeft + i * xStep
val ratio = (it[i].value - minY) / (maxY - minY)
val y = (height - paddingBottom) - (ratio * chartHeight)
if (i == 0) pathLine.moveTo(x, y) else pathLine.lineTo(x, y)
}
pathList.add(pathLine)
}
pathList.forEachIndexed { index, p ->
linePaint.color = colorList[index]
canvas.drawPath(p, linePaint)
}
4.绘制点击时候出现的弹窗
Kotlin
private fun drawSelectionHighlight(canvas: Canvas, xStep: Float, chartHeight: Float) {
val x = paddingLeft + selectedIndex * xStep
// --- 统一使用 dp 转换(假设 10dp 约等于 30px,建议用你的 SizeUtils) ---
val margin10 = DisplayUtils.sp2px(context, 10f)
val horizontalPadding = margin10
val verticalPadding = margin10
// 1. 准备数据
val timeStr = "${mDataList[selectedIndex].dateTop} ${mDataList[selectedIndex].dateBottom}"
val dataRows = mutableListOf<Pair<String, String>>()
mLineList.forEach { list ->
if (selectedIndex < list.size) {
val item = list[selectedIndex]
dataRows.add(item.dateItem.name to "${item.value}${item.dateItem.unit}")
}
}
if (dataRows.isEmpty()) return
// 2. 测量文字宽度
val titleTextSize = DisplayUtils.sp2px(context, 14f)
val contentTextSize = DisplayUtils.sp2px(context, 12f)
val iconSpace = 40f
axisPaint.textSize = titleTextSize
val timeWidth = axisPaint.measureText(timeStr)
axisPaint.textSize = contentTextSize
var maxRowWidth = 0f
dataRows.forEach { (name, value) ->
val w = axisPaint.measureText(name) + axisPaint.measureText(value)
if (w > maxRowWidth) maxRowWidth = w
}
// 3. 重新计算布局尺寸(解决重叠的关键)
val frameWidth = maxOf(timeWidth, maxRowWidth + iconSpace) + (horizontalPadding * 2)
val titleLineHeight = 50f // 时间文字占用的高度
val spacing = 20f // 时间和第一行数据之间的间距
val rowHeight = 45f // 每一行数据的高度
// 总高度 = 顶部边距 + 时间高度 + 间距 + 数据总高 + 底部边距
val frameHeight =
verticalPadding + titleLineHeight + spacing + (dataRows.size * rowHeight) + verticalPadding
// 4. 计算坐标
var frameLeft = x - frameWidth / 2
val minLeft = margin10
val maxRight = width - margin10
if (frameLeft < minLeft) frameLeft = minLeft
if (frameLeft + frameWidth > maxRight) frameLeft = maxRight - frameWidth
// 气泡位置:显示在点击位置上方,预留 20px 偏移
val frameTop = maxOf(margin10, paddingTop - frameHeight - 20f)
// 绘制背景框
val rect = RectF(frameLeft, frameTop, frameLeft + frameWidth, frameTop + frameHeight)
canvas.drawRoundRect(rect, 15f, 15f, textBgPaint)
//绘制顶部时间
axisPaint.color = Color.WHITE
axisPaint.textSize = titleTextSize
axisPaint.isFakeBoldText = true
// 时间的 Y 坐标 = 气泡顶部 + 内部边距 + 文字高度
val timeY = frameTop + verticalPadding + 30f
canvas.drawText(timeStr, frameLeft + (frameWidth - timeWidth) / 2, timeY, axisPaint)
//绘制数据行(起始 Y 坐标避开时间区域)
axisPaint.isFakeBoldText = false
axisPaint.textSize = contentTextSize
// 第一行数据的基准线 = 时间 Y 坐标 + 间距 + 行高偏移
val startDataY = timeY + spacing + 30f
dataRows.forEachIndexed { index, pair ->
val currentY = startDataY + (index * rowHeight)
// 绘制颜色小圆点(圆心对齐文字中间)
pointPaint.color = colorList[index % colorList.size]
canvas.drawCircle(frameLeft + horizontalPadding + 10f, currentY - 12f, 6f, pointPaint)
// 绘制名称
axisPaint.textAlign = Paint.Align.LEFT
canvas.drawText(
pair.first,
frameLeft + horizontalPadding + iconSpace,
currentY,
axisPaint
)
// 绘制数值
axisPaint.textAlign = Paint.Align.RIGHT
canvas.drawText(
pair.second,
frameLeft + frameWidth - horizontalPadding,
currentY,
axisPaint
)
}
axisPaint.textAlign = Paint.Align.LEFT
axisPaint.color = Color.GRAY
drawIndicators(canvas, x, chartHeight)
}
private fun drawIndicators(canvas: Canvas, x: Float, chartHeight: Float) {
selectionPaint.pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
canvas.drawLine(x, paddingTop, x, height - paddingBottom, selectionPaint)
mLineList.forEachIndexed { index, list ->
if (selectedIndex < list.size) {
val ratio = (list[selectedIndex].value - minY) / (maxY - minY)
val y = (height - paddingBottom) - (ratio * chartHeight)
pointPaint.color = colorList[index % colorList.size]
canvas.drawCircle(x, y, 10f, pointPaint)
val whitePaint = Paint().apply { color = Color.WHITE; isAntiAlias = true }
canvas.drawCircle(x, y, 5f, whitePaint)
}
}
}
5.我们点击的时候会出现当前点的数值,所以需要添加触摸并计算触摸坐标点
Kotlin
override fun onTouchEvent(event: MotionEvent): Boolean {
val chartWidth = width - paddingLeft - paddingRight
val xStep = chartWidth / (mDataList.size - 1).coerceAtLeast(1)
isShowBubble = true
when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
// 计算点击位置对应的索引
val touchX = event.x
// 限制在图表范围内
if (touchX >= paddingLeft && touchX <= width - paddingRight) {
val index = ((touchX - paddingLeft) / xStep + 0.5f).toInt()
if (index != selectedIndex && index in mDataList.indices) {
selectedIndex = index
invalidate()
}
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
}
}
return true
}
6.设置数据,我们可以绘制任意条数据线,绘制在同一坐标里面,所以需要计算最大值跟最小值
设置一条线的数据
Kotlin
fun setData(list: MutableList<PointData>, isSetDat: Boolean = true) {
if (isSetDat) {
this.mDataList = list
mLineList.clear()
mLineList.add(list)
maxY = maxY
minY = MIN_Y
}
if (list.isNotEmpty()) {
val maxValue = list.maxOf { it.value }
val minValue = list.minOf { it.value }
// 自动修正最大最小值,向上/向下取整到 5 的倍数
val max = (ceil(maxValue / 5.0) * 5.0).toFloat()
maxY = if (max > maxY) {
max
} else {
maxY
}
val min = (floor(minValue / 5.0) * 5.0).toFloat()
minY = if (min > maxY) {
min
} else {
minY
}
// 如果最大最小值一样,强制给个范围
if (maxY == minY) {
maxY += 10f
minY -= 10f
}
}
invalidate()
}
绘制多条线
Kotlin
fun setDataList(list: MutableList<MutableList<PointData>>) {
maxY = MAX_Y
minY = MIN_Y
if (list.isEmpty()) {
LogUtils.d(TAG, "数据为空")
return
}
this.mLineList = list
mDataList = list[0]
list.forEach {
setData(it, false)
}
invalidate()
}
7.数据类
Kotlin
data class PointData(
val dateTop: String,
val dateBottom: String,
val value: Float,
val dateItem: DataItem,
val allTime: String = ""
)
下面的滑块是另外的一个自定义View,滑动可以改变折线的显示,可以自己选取范围,同时表格会实时改变。具体可以去看代码:https://github.com/ningxingxing/table
8.最后图表其实还有写的比较好的第三库,比如MPAndroidChart,地址:https://github.com/PhilJay/MPAndroidChart