难点主要是贝塞尔曲线的控制点计算,其他没什么复杂的。
代码仅供参考,直接拿去是肯定用不了的
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>