难点主要是贝塞尔曲线的控制点计算,其他没什么复杂的。

代码仅供参考,直接拿去是肯定用不了的

Android 自定义View 曲线

View的代码

package cn.net.aicare.moudleAnemometer.view

import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
import androidx.core.content.ContextCompat
import cn.net.aicare.moudleAnemometer.R
import java.math.RoundingMode
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.ArrayList
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin

/**
 * 风速计曲线View
 * @author cym
 */
class AneLineView @JvmOverloads constructor(
    mContext: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(mContext, attrs, defStyleAttr) {

    // 画笔
    private val mPaint: Paint = Paint()

    // 路径
    private val mPath1: Path = Path()
    private val mPath2: Path = Path()

    // 时间格式化
    private val mSDF = SimpleDateFormat("HH:mm:ss", Locale.US)

    // 风的渐变色
    private lateinit var mShaderWind: Shader

    // 温度渐变色
    private lateinit var mShaderTemp: Shader

    // 主要的框架矩形
    private lateinit var mRect: RectF

    // 颜色
    private val mColorBlack = ContextCompat.getColor(mContext, R.color.colorBlack)
    private val mColorRed = ContextCompat.getColor(mContext, R.color.colorRed)
    private val mColorOrange = ContextCompat.getColor(mContext, R.color.colorOrange)
    private val mColorGreen = ContextCompat.getColor(mContext, R.color.colorGreen)
    private val mColorPurple = ContextCompat.getColor(mContext, R.color.colorPurple)
    private val mColorBlue = ContextCompat.getColor(mContext, R.color.colorBlue)
    private val mColorAqua = ContextCompat.getColor(mContext, R.color.colorAqua)
    private val mColorTransFont = ContextCompat.getColor(mContext, R.color.colorTransFont)
    private val mColorTransFont2 = ContextCompat.getColor(mContext, R.color.colorTransFont2)

    // 风速单位
    var mUnitWind = "m/s"

    // 温度单位
    var mUnitTemp = "℃"

    // 风速最大最小值
    var mMaxWind = 40
    var mMinWind = 0

    // 温度最大最小值
    var mMaxTemp = 50
    var mMinTemp = -10

    // 开始测量时的时间
    var mStartStamp = 0L

    // 存放的数据列表
    val mList = ArrayList<AneDataBean>()

    // 初始化
    init {
        mPaint.isAntiAlias = true

        // 测试随机添加数据
        startMeasure()
        val startStamp = System.currentTimeMillis()
        var i = 0
        Thread {
            while (true) {
                addData(
                    startStamp + i * 5000,
                    (5 * 10 until 35 * 10).random() / 10f,
                    (0 * 10 until 45 * 10).random() / 10f

                )
                i++
                Thread.sleep(1000)
            }
        }.start()
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        var width = dp2px(50f).toInt()
        var height = dp2px(50f).toInt()

        // 不是wrap
        if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.AT_MOST) {
            width = MeasureSpec.getSize(widthMeasureSpec)
        }
        if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.AT_MOST) {
            height = MeasureSpec.getSize(heightMeasureSpec)
        }
        setMeasuredDimension(width, height)
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        // 主要的框架矩形
        mRect = RectF(dp2px(40f), dp2px(40f), w - dp2px(40f), h - dp2px(30f))
        // 渐变色
        val arrayPosition = floatArrayOf(0f, 0.5f, 1f)
        val arrayColorWind = intArrayOf(mColorRed, mColorOrange, mColorGreen)
        val arrayColorTemp = intArrayOf(mColorPurple, mColorBlue, mColorAqua)
        mShaderWind = LinearGradient(
            0f,
            mRect.top,
            0f,
            mRect.bottom,
            arrayColorWind,
            arrayPosition,
            Shader.TileMode.CLAMP
        )
        mShaderTemp = LinearGradient(
            0f,
            mRect.top,
            0f,
            mRect.bottom,
            arrayColorTemp,
            arrayPosition,
            Shader.TileMode.CLAMP
        )
    }

    override fun onDraw(canvas: Canvas) {
        var x: Float
        var y: Float

        // 画底部横线
        mPaint.style = Paint.Style.FILL
        mPaint.color = mColorBlack
        mPaint.strokeWidth = dp2px(0.5f)
        canvas.drawLine(mRect.left, mRect.bottom, mRect.right, mRect.bottom, mPaint)

        // 画风速文字
        mPaint.textAlign = Paint.Align.CENTER
        mPaint.textSize = dp2px(14f)
        mPaint.color = mColorTransFont
        x = mRect.left + dp2px(15f)
        y = mRect.top - dp2px(25f)
        canvas.drawText("风速(${mUnitWind})", x, getBaseline(y), mPaint)
        // 画温度文字
        x = mRect.right - dp2px(10f)
        canvas.drawText("温度(${mUnitTemp})", x, getBaseline(y), mPaint)
        // 画风速最大值
        mPaint.textSize = dp2px(12f)
        mPaint.textAlign = Paint.Align.RIGHT
        x = mRect.left - dp2px(8f)
        y = mRect.top
        canvas.drawText(mMaxWind.toString(), x, getBaseline(y), mPaint)
        // 画风速最小值
        y = mRect.bottom
        canvas.drawText(mMinWind.toString(), x, getBaseline(y), mPaint)
        // 画温度最大值
        mPaint.textAlign = Paint.Align.LEFT
        x = mRect.right + dp2px(8f)
        y = mRect.top
        canvas.drawText(mMaxTemp.toString(), x, getBaseline(y), mPaint)
        // 画风速最小值
        y = mRect.bottom
        canvas.drawText(mMinTemp.toString(), x, getBaseline(y), mPaint)

        // 算曲线部分
        // 判断x轴起始时间和结束时间
        val eStamp = getMaxDataStamp()
        // 最低显示2分钟的长度
        val sStamp = if (mStartStamp + 2 * 60 * 1000 > eStamp) {
            eStamp - 120000
        } else {
            mStartStamp
        }
        // 画风速和温度曲线
        mPaint.color = mColorBlack
        if (mList.size >= 2) {
            // 至少要两条数据才能画线
            var y1: Float
            var y2: Float
            // 保存上一个点的XY轴
            var prevX = 0f
            var prevY1 = 0f
            var prevY2 = 0f
            for (i in 0 until mList.size) {
                val bean = mList[i]
                // 计算x
                x = mRect.left + (bean.stamp - sStamp) * 1.0f / (eStamp - sStamp) * mRect.width()
                // 计算风速y
                y1 =
                    mRect.bottom - (bean.wind - mMinWind) * 1.0f / (mMaxWind - mMinWind) * mRect.height()
                // 计算温度y
                y2 =
                    mRect.bottom - (bean.temp - mMinTemp) * 1.0f / (mMaxTemp - mMinTemp) * mRect.height()

                // 计算路径
                if (i == 0) {
                    mPath1.reset()
                    mPath2.reset()
                    mPath1.moveTo(x, y1)
                    mPath2.moveTo(x, y2)
                } else {
                    mPath1.cubicTo(x + (prevX - x) / 2f, prevY1, x + (prevX - x) / 2f, y1, x, y1)
                    mPath2.cubicTo(x + (prevX - x) / 2f, prevY2, x + (prevX - x) / 2f, y2, x, y2)
                }

                prevX = x
                prevY1 = y1
                prevY2 = y2
            }
            // 画风速线
            mPaint.style = Paint.Style.STROKE
            mPaint.strokeWidth = dp2px(0.75f)
            mPaint.shader = mShaderWind
            canvas.drawPath(mPath1, mPaint)
            // 画温度线
            mPaint.shader = mShaderTemp
            canvas.drawPath(mPath2, mPaint)
            mPaint.shader = null
        }

        // 画左右两根柱子
        // 画左侧渐变
        mPaint.style = Paint.Style.FILL
        mPaint.strokeWidth = dp2px(4f)
        mPaint.shader = mShaderWind
        canvas.drawLine(mRect.left, mRect.top, mRect.left, mRect.bottom + 1, mPaint)
        // 画右侧渐变
        mPaint.shader = mShaderTemp
        canvas.drawLine(mRect.right, mRect.top, mRect.right, mRect.bottom + 1, mPaint)
        mPaint.shader = null

        // 画底部时间轴
        // 计算底部步长,每格多少ms
        val step = when {
            // 2分钟内,步长30秒
            eStamp - sStamp <= 2 * 60 * 1000 -> 30 * 1000
            // 10分钟内,步长150秒
            eStamp - sStamp <= 10 * 60 * 1000 -> 150 * 1000
            // 一小时内,步长15分钟
            eStamp - sStamp <= 60 * 60 * 1000 -> 15 * 60 * 1000
            // 六小时内,步长90分钟
            eStamp - sStamp <= 6 * 60 * 60 * 1000 -> 90 * 60 * 1000
            // 十二小时内,步长180分钟
            eStamp - sStamp <= 12 * 60 * 60 * 1000 -> 180 * 60 * 1000
            // 再长就都按360分钟算
            else -> 360 * 60 * 1000
        }
        // 按开始时间取整
        val ssStamp: Long = sStamp / step * step
        y = mRect.bottom + dp2px(15f)
        mPaint.color = mColorTransFont2
        mPaint.textAlign = Paint.Align.CENTER
        mPaint.textSize = dp2px(9f)
        // 循环绘制
        for (i in 0..5) {
            x = mRect.left + (ssStamp + i * step - sStamp) * 1.0f / (eStamp - sStamp) * mRect.width()
            canvas.drawText(mSDF.format(ssStamp + i * step), x, getBaseline(y), mPaint)
        }
    }

    /**
     * 开始测量
     */
    fun startMeasure() {
        mStartStamp = System.currentTimeMillis()
        mList.clear()
    }

    /**
     * 添加一条数据
     * @param stamp 数据产生的时间
     * @param wind 风速值
     * @param temp 温度值
     */
    fun addData(stamp: Long, wind: Float, temp: Float) {
        mList.add(AneDataBean(stamp, wind, temp))
        invalidate()
    }

    /**
     * 获取最新一条数据的时间戳
     */
    private fun getMaxDataStamp(): Long {
//        var max = 0L
//        for (bean in mList) {
//            if (bean.stamp > max) {
//                max = bean.stamp
//            }
//        }
//        return max
        // 理论上说后加的数据就是最新的,所以直接取最后一条即可
        if (mList.size > 0) {
            return mList[mList.size - 1].stamp
        }
        return 0
    }

    /**
     * dp转px
     * @param dpValue dp
     */
    private fun dp2px(dpValue: Float): Float {
        val scale = resources.displayMetrics.density
        return dpValue * scale + 0.5f
    }

    /**
     * 根据y计算baseline
     * @param y 文本的y
     */
    private fun getBaseline(y: Float): Float {
        return y + (mPaint.fontMetrics.bottom - mPaint.fontMetrics.top) / 2 - mPaint.fontMetrics.bottom
    }

    /**
     * 风速计数据Bean
     */
    data class AneDataBean(
        var stamp: Long = 0L,
        var wind: Float = 0f,
        var temp: Float = 0f
    )
}

这些颜色什么的,自己定一下就行

布局xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <cn.net.aicare.moudleAnemometer.view.AneLineView
        android:id="@+id/ane_line_view"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintDimensionRatio="w, 507:811"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>