android 图表实现

在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

相关推荐
代码s贝多芬的音符21 小时前
android 两个人脸对比 mlkit
android
darkb1rd1 天前
五、PHP类型转换与类型安全
android·安全·php
gjxDaniel1 天前
Kotlin编程语言入门与常见问题
android·开发语言·kotlin
csj501 天前
安卓基础之《(22)—高级控件(4)碎片Fragment》
android
峥嵘life1 天前
Android16 【CTS】CtsMediaCodecTestCases等一些列Media测试存在Failed项
android·linux·学习
stevenzqzq1 天前
Compose 中的状态可变性体系
android·compose
似霰1 天前
Linux timerfd 的基本使用
android·linux·c++
darling3311 天前
mysql 自动备份以及远程传输脚本,异地备份
android·数据库·mysql·adb
你刷碗1 天前
基于S32K144 CESc生成随机数
android·java·数据库
TheNextByte11 天前
Android上的蓝牙文件传输:跨设备无缝共享
android