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

相关推荐
有位神秘人2 小时前
Android中PopupWindow中如何弹出时让背景变暗
android
TheNextByte12 小时前
iPhone 与Android :有什么区别?
android·cocoa·iphone
_李小白3 小时前
【Android 美颜相机】第二十一天:GPUImageChromaKeyBlendFilter (颜色加深混合滤镜)
android·数码相机
yantaohk4 小时前
【2025亲测】中兴B860AV3.2M完美刷机包ATV版本安卓9-解决1G运存BUG,开ADB已ROOT
android·嵌入式硬件·adb·云计算
乐观勇敢坚强的老彭4 小时前
c++信奥寒假营集训01
android·java·c++
kdniao15 小时前
PHP 页面中如何实现根据快递单号查询物流轨迹?对接快递鸟在途监控 API 实操
android·开发语言·php
言之。5 小时前
MacBook M3 Pro:React Native 安卓开发
android·react native·react.js
感觉不怎么会5 小时前
Android 13 - 对讲app后台休眠后无法录音
android·linux
Minilinux20186 小时前
Android系列之 屏幕触控机制(一)
android·屏幕触控·andorid touch·viewroot