折线图演示视频
优点:链式设置 、支持双指缩放 和左右滑动 、动态Y轴数值 、x轴标签数量可变 ,折点选中效果,文本展示
1.自定义View的具体代码
Kotlin
package com.example.test.ui.widget
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.FrameLayout
import androidx.core.graphics.toColorInt
import com.example.lottery.ui.widget.LineChartView.LineChartBuilder
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.log10
import kotlin.math.pow
import kotlin.math.sqrt
class LineChartView @JvmOverloads constructor(
context: Context,
attr: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attr, defStyleAttr) {
private var viewHeight = 0 //视图高度
private var viewWidth = 0 //视图宽度
private var padding = 15f //所有元素距离父容器的间距
//折点相关
private val oldValueList = mutableListOf<String>("156", "100", "8505", "458", "100", "769", "562", "100", "855", "8506", "100", "769", "458", "100", "855", "458", "100", "769", "562", "100", "855") //原始数据
private val valueList = mutableListOf<String>() //处理后的折点数据
private var valueYList = mutableListOf<Float>() //折点Y坐标
private var dotRadius = 10f //折点半径
private var showDot = true //是否显示折点
private var selectValueIndex = -1 //当前点击的折点索引
private var showValue = false //是否显示点击的折点值
private var showValueRect = RectF() //显示值的矩形
private var showValueRectRadius = 10f //显示值的矩形的圆角半径
private var showValueRectHPadding = 10f //显示值的矩形水平内边距
private var showValueRectVPadding = 8f //显示值的矩形垂直内边距 文本画笔绘制文本时基线本身距离边界就有段距离
private var showValueRectOffsetY = 20f //显示值的矩形的Y轴偏移量
//轴通用属性
private val axisPaintWidth = 5f //轴线宽度
private val tagToAxisMargin = 15f //标签和轴之间的间距
private var axisStartX = 0f //坐标原点的X坐标
private var axisStartY = 0f //坐标原点的Y坐标
//x轴相关
private val oldXTagList = mutableListOf<String>("1", "2", "3", "4", "5", "6", "7", "8", "9", "10") //x轴标签
private val xTagList = mutableListOf<String>() //用来显示的X轴标签
private var xTagXList = mutableListOf<Float>() //x轴标签的中心点X坐标
private var xTagY = 0f //x轴标签文本的Y坐标
private var xTagMaxHeight = 0f //x轴标签文本的最大高度
private var xTagCount = 8 //可以展示的x轴标签数量
private var xAxisEndX = 0f //x轴结束的X坐标
private var xStep = 3 //x轴标签的步长
private var xGap = 0f //x轴标签之间的间距
private var offsetX = 0f //折点X坐标的偏移量 用来左右移动
private var showLeftX = 0f //左边界 即滑动时绘制显示内容(x轴标签和折线段等)距离Y轴的距离 默认为 axisStartX + xGap / 4
private var showRightX = 0f //右边界 即滑动时最后一个标签可以左滑到的极限位置(可以理解为右边的左边界) 默认为 xAxisEndX - xGap
//y轴相关 ,注意坐标轴的Y轴方向和绘制视图时用的Y轴方向是相反的
private var yTagList = mutableListOf<Int>(0, 1, 2, 3, 4, 5, 6, 7, 8, 9) //y轴标签
private var yTagYList = mutableListOf<Float>() //y轴标签的中心点Y坐标
private var yTagX = 0f //y轴标签文本的X坐标
private var yTagMaxWidth = 0f //y轴标签文本的最大宽度
private var yAxisEndY = 0f //y轴结束的Y坐标
private var yZeroTagFormXAxisDistance = 80f //y轴0点标签和x轴之间的偏移距离
private var yStep = 1.0 //y轴标签的步长
private var yGap = 0f //y轴标签之间的间距
private var zeroTagY = 0f //y轴0点标签的Y坐标
//手势控制相关
private var lastDistance = 0f //双指按下时的距离
private var updateStepMinD = 50f //更新步长需要的单位距离 阈值 想更快/慢缩放 的话就 减小/增大 这个值
private var pointerDownX = 0f // 单指按下时的X坐标
private var updateMoveMinD = 15f //单指移动时,重绘需要的阈值 左右滑动时如果有卡顿 可适量降低
private var isMove = false //当前手势是否包含滑动操作 用来区别点击和滑动
//标签文本画笔
private val tagPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.GRAY
textSize = 30f
textAlign = Paint.Align.CENTER
}
//轴画笔
private val axisPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
strokeWidth = axisPaintWidth
}
//虚线画笔
private val dashPaint = Paint().apply {
color = Color.GRAY
style = Paint.Style.STROKE
strokeWidth = axisPaintWidth
pathEffect = DashPathEffect(floatArrayOf(20f, 20f), 0f)
}
//折点相关画笔 为了支持更好的自定义效果所以用两个画笔绘制折点
private val dotBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.FILL
}
private val dotStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
style = Paint.Style.STROKE
strokeWidth = axisPaintWidth
}
private val showValueDotStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
style = Paint.Style.STROKE
strokeWidth = axisPaintWidth
}
//值相关画笔
private val valueTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
textSize = 48f
textAlign = Paint.Align.CENTER
}
private val valueBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = "#22000000".toColorInt()
style = Paint.Style.FILL
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
// 按下时,保存按下时的X坐标
pointerDownX = event.x
}
MotionEvent.ACTION_POINTER_DOWN -> {
// 双指按下时,保存按下时的距离
if (event.pointerCount == 2) {
lastDistance = getFingerDistance(event)
}
}
MotionEvent.ACTION_MOVE -> {
isMove = true
when (event.pointerCount) {
1 -> {
val deltaX = event.x - pointerDownX
if (abs(deltaX) > updateMoveMinD && offsetX <= 0) {
val oldOffsetX = offsetX
//如果首尾数据都在范围内, 则不允许滑动
if (xTagXList.first() + offsetX > showLeftX && xTagXList.last() + offsetX <= showRightX) return true
val offsetResult = offsetX + deltaX
if (deltaX >= 0) {
offsetX = if (offsetResult >= 0) 0f else offsetResult
} else {
val lastTagOffsetResult = xTagXList.last() + offsetResult
offsetX =
if (lastTagOffsetResult >= showRightX) offsetResult else offsetX
}
pointerDownX = event.x
showValueRect.left += (offsetX - oldOffsetX)
showValueRect.right += (offsetX - oldOffsetX)
invalidate()
}
}
2 -> {
// 双指移动时,计算距离变化,超过距离则更新步长
val currentDistance = getFingerDistance(event)
val distanceDelta = currentDistance - lastDistance
if (abs(distanceDelta) > updateStepMinD) {
// 判断缩放方向
if (distanceDelta > 0 && xStep >= 2) {
xStep -= 1
draw()
} else if (distanceDelta < 0 && xTagXList.last() > xAxisEndX) {
xStep += 1
draw()
}
lastDistance = currentDistance
}
}
}
}
MotionEvent.ACTION_UP -> {
if (!isMove && xTagXList.isNotEmpty()) {
//说明是点击操作 显示/隐藏选中的值 和 高亮/正常渲染选中折点
//根据点击位置获取距离最近的索引
val index = xTagXList.indices.step(xStep).toMutableList().apply {
if (xStep > 1 && last() != xTagXList.lastIndex) {
add(xTagXList.lastIndex) // 强制添加最后一个索引
}
}.minByOrNull { abs(xTagXList[it]+offsetX - pointerDownX) } ?: -1
if (index != -1) {
showValue = if (selectValueIndex == index) !showValue else true
selectValueIndex = index
val showValueText = valueList[index]
val (x, y) = Pair(xTagXList[index] + offsetX, valueYList[index])
val textWidth = valueTextPaint.measureText(showValueText)
val textHeight = valueTextPaint.fontMetrics.let { it.bottom - it.top }
val bgLeft = x - textWidth / 2 - showValueRectHPadding
val bgRight = x + textWidth / 2 + showValueRectHPadding
var bgBottom = y - showValueRectOffsetY
var bgTop = bgBottom - textHeight - showValueRectVPadding * 2
//如果文本矩形高度 大于 点到View顶部的距离 会显示不全 所以将矩形显示到点的下方
if (bgTop < 0) {
bgTop = y + showValueRectVPadding
bgBottom = bgTop + textHeight + showValueRectVPadding * 2
}
showValueRect = RectF(bgLeft, bgTop, bgRight, bgBottom)
invalidate()
}
}
isMove = false
}
}
return true
}
// 计算双指间距(欧几里得距离)
private fun getFingerDistance(event: MotionEvent): Float {
val x1 = event.getX(0)
val y1 = event.getY(0)
val x2 = event.getX(1)
val y2 = event.getY(1)
return sqrt((x2 - x1).pow(2) + (y2 - y1).pow(2))
}
//开始绘制方法
fun draw() {
initList()
initAxis()
initTag()
initValueY()
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
//绘制坐标轴
canvas.drawLine(axisStartX, axisStartY, xAxisEndX, axisStartY, axisPaint) //x轴
canvas.drawLine(axisStartX, axisStartY, axisStartX, yAxisEndY, axisPaint) //y轴
//绘制Y轴相关
for (i in yTagYList.indices) {
canvas.drawText(yTagList[i].toString(), yTagX, yTagYList[i], tagPaint) //y轴标签
canvas.drawLine(
axisStartX,
yTagYList[i],
xAxisEndX,
yTagYList[i],
dashPaint
) //y轴标签对应的虚线
}
//绘制折线相关
for (i in 0 until valueList.size - xStep step xStep) {
if (xTagXList[i] + offsetX < showLeftX && xTagXList[i + xStep] + offsetX >= showLeftX) {
//绘制被截的线段
canvas.drawLine(
showLeftX,
valueYList[i] + (showLeftX - xTagXList[i] - offsetX) * (valueYList[i + xStep] - valueYList[i]) / (xTagXList[i + xStep] - xTagXList[i]),
xTagXList[i + xStep] + offsetX,
valueYList[i + xStep],
axisPaint
)
}else if (xTagXList[i] + offsetX in showLeftX..xAxisEndX) {
canvas.drawLine(
xTagXList[i] + offsetX,
valueYList[i],
xTagXList[i + xStep] + offsetX,
valueYList[i + xStep],
axisPaint
)
if (xStep != 1 && i + 2 * xStep >= valueList.size && xTagXList.last() + offsetX in showLeftX..xAxisEndX) {
//步长不为1时,最后一个数据可能会取不到,单独处理
canvas.drawLine(
xTagXList[i + xStep] + offsetX,
valueYList[i + xStep],
xTagXList.last() + offsetX,
valueYList.last(),
axisPaint
)
}
}
}
//绘制X轴相关 由于还要绘制折点所以要放到折线绘制之后
for (i in xTagList.indices step xStep) {
//不绘制超出边界的数据
if (xTagXList[i] + offsetX in showLeftX..xAxisEndX) {
canvas.drawText(xTagList[i], xTagXList[i] + offsetX, xTagY, tagPaint)
//绘制折点
if (showDot) {
canvas.drawCircle(xTagXList[i] + offsetX, valueYList[i], dotRadius, dotBgPaint)
canvas.drawCircle(
xTagXList[i] + offsetX,
valueYList[i],
dotRadius,
if (!showValue || i != selectValueIndex )dotStrokePaint else showValueDotStrokePaint
)
}
}
if (xStep != 1 && i + xStep >= xTagList.size && xTagXList.last() + offsetX in showLeftX..xAxisEndX) {
//步长不为1时,最后一个部分数据可能会取不到,单独处理,使最后一个数据不按照步长也能显示
canvas.drawText(xTagList.last(), xTagXList.last() + offsetX, xTagY, tagPaint)
//绘制折点
if (showDot) {
canvas.drawCircle(
xTagXList.last() + offsetX,
valueYList.last(),
dotRadius,
dotBgPaint
)
canvas.drawCircle(
xTagXList.last() + offsetX,
valueYList.last(),
dotRadius,
if (!showValue || xTagXList.lastIndex != selectValueIndex )dotStrokePaint else showValueDotStrokePaint
)
}
}
}
//绘制选中值
if (showValue && selectValueIndex != -1 && xTagXList[selectValueIndex] + offsetX in showLeftX..xAxisEndX) {
canvas.drawText(
valueList[selectValueIndex],
xTagXList[selectValueIndex] + offsetX,
showValueRect.top + showValueRectVPadding - valueTextPaint.fontMetrics.ascent,
valueTextPaint
)
canvas.drawRoundRect(
showValueRect,
showValueRectRadius,
showValueRectRadius,
valueBgPaint
)
}
}
private fun initList() {
xTagXList.clear()
yTagYList.clear()
xTagList.clear()
xTagList.addAll(oldXTagList)
valueList.clear()
valueList.addAll(oldValueList)
}
//获取每条数据的纵坐标
private fun initValueY() {
valueYList.clear() // 先清空列表
for (value in valueList.map { it.toDouble() }) {
val yPosition = zeroTagY - value / yStep * yGap
valueYList.add(yPosition.toFloat())
}
}
//初始化坐标轴标签
private fun initTag() {
//初始化x轴标签
xGap = (xAxisEndX - axisStartX - axisPaintWidth / 2) / xTagCount //x轴标签之间的间距
showLeftX = axisStartX + xGap / 4
showRightX = xAxisEndX - xGap / 2
var tagX = axisStartX + axisPaintWidth / 2 + xGap / 2 //x轴第一个标签的x坐标
//标签值数量与折点数量对齐
if (valueList.size > xTagList.size) {
val needed = valueList.size - xTagList.size
val available = xTagList.size
if (available == 0) {
// 如果 xTagList 是空的,填充默认值
repeat(valueList.size) { i ->
xTagList.add(i.toString())
}
} else {
// 循环填充已有标签
repeat(needed) { i ->
xTagList.add("tag ${available + i + 1}")
}
}
}
for (i in xTagList.indices) {
xTagXList.add(tagX)
// 每隔 step 个元素才增加 xGap
if (i % xStep == 0) {
tagX += xGap
}
}
if (xTagXList.last() + offsetX < showLeftX) {
//说明是滑动到最后一位后进行了双指扩大操作,导致步长缩小,但偏移量没变,会导致可见范围里没有数据
offsetX = 0f
}
xTagY = viewHeight - padding
//初始化y轴标签
yGap =
(axisStartY - yAxisEndY - axisPaintWidth / 2 - yZeroTagFormXAxisDistance) / yTagList.size //y轴标签之间的间距
zeroTagY = axisStartY - yZeroTagFormXAxisDistance - axisPaintWidth / 2 //y轴第一个标签的y坐标
var tagY = zeroTagY
for (i in yTagList.indices) {
yTagYList.add(tagY)
tagY -= yGap
}
yTagX = padding + yTagMaxWidth / 2
}
//初始化坐标轴属性
private fun initAxis() {
// 将字符串列表转换为Double列表并找出最大值
val maxValue = valueList.mapNotNull { it.toDoubleOrNull() }.maxOrNull() ?: 0.0
if (maxValue != 0.0) {
// 确定步长(等差值)
yStep = 10.0.pow(floor(log10(maxValue)).toInt())
// 计算序列的最大值(比原最大值大的第一个步长整数倍)
val sequenceMax = ceil(maxValue / yStep) * yStep
if (sequenceMax <= 0) return
// 生成从0开始的等差序列
yTagList = generateSequence(0.0) { it + yStep }
.takeWhile { it <= sequenceMax }
.map { it.toInt() }
.toMutableList()
}
yTagMaxWidth = tagPaint.measureText(yTagList.last().toString())
xTagMaxHeight = xTagList.maxOf { text ->
val rect = Rect()
tagPaint.getTextBounds(text, 0, text.length, rect)
rect.height().toFloat()
}
//下面公式中减去一半轴线宽度,是因为绘制线的时候,给定的坐标是轴线的中心点 而不是轴线的左上角端点
axisStartY = viewHeight - padding - xTagMaxHeight - tagToAxisMargin - axisPaintWidth / 2
axisStartX = padding + yTagMaxWidth + tagToAxisMargin + axisPaintWidth / 2
xAxisEndX = viewWidth - padding
yAxisEndY = padding
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
viewWidth = w
viewHeight = h
draw()
}
//视图初始化
class LineChartBuilder(private val chart: LineChartView) {
// 数据设置
fun values(values: List<String>) = apply { chart.setValueList(values) }
fun xTags(tags: List<String>) = apply { chart.setXTagList(tags) }
// 显示配置
fun xTagCount(count: Int) = apply { chart.xTagCount = count }
fun xStep(step: Int) = apply { chart.xStep = step }
fun padding(padding: Float) = apply { chart.padding = padding }
fun yZeroOffset(offset: Float) = apply { chart.yZeroTagFormXAxisDistance = offset }
// 样式配置
fun axisStyle(
width: Float = chart.axisPaint.strokeWidth,
color: Int = chart.axisPaint.color
) = apply {
chart.axisPaint.strokeWidth = width
chart.axisPaint.color = color
}
fun tagStyle(
size: Float = chart.tagPaint.textSize,
color: Int = chart.tagPaint.color
) = apply {
chart.tagPaint.textSize = size
chart.tagPaint.color = color
}
fun dashStyle(
array: FloatArray = floatArrayOf(20f, 20f),
phase: Float = 0f,
color: Int = chart.dashPaint.color,
width: Float = chart.dashPaint.strokeWidth
) = apply {
chart.dashPaint.pathEffect = DashPathEffect(array, phase)
chart.dashPaint.color = color
chart.dashPaint.strokeWidth = width
}
// 构建完成
fun build() = chart.draw()
}
fun setValueList(list: List<String>) {
oldValueList.clear()
oldValueList.addAll(list)
}
fun setXTagList(list: List<String>) {
oldXTagList.clear()
oldXTagList.addAll(list)
}
}
// 调用的扩展函数
fun LineChartView.with(block: LineChartBuilder.() -> Unit): LineChartView {
LineChartBuilder(this).apply(block)
return this
}
2. 使用:
XML
<com.example.test.ui.widget.LineChartView
android:id="@+id/lineView"
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="@color/white"/>
Kotlin
binding.lineView.with {
values(listOf("100", "200", "150", "300", "250")) // 设置折线数据
xTags(listOf("Jan", "Feb", "Mar", "Apr", "May")) // 设置X轴标签
padding(15f) // 设置折线图内边距
xTagCount(5) // 设置X轴标签数量
xStep(1) // 设置数据展示步长
tagStyle(size = 32f, color = Color.BLUE) // 设置标签样式
axisStyle(width = 5f, color = Color.RED) // 设置坐标轴样式
dashStyle(array = floatArrayOf(15f, 10f), color = Color.GRAY) // 设置虚线样式
yZeroOffset(80f) // 设置Y轴零点位置相对于x轴的偏移 让0点线不和x轴重合
}
只支持了部分参数的链式设置,而且对于设置进来的数据没有进行正确性判断 ,需要的可以自己继续添加!