当前位置: 首页 > news >正文

自定义View学习记录之 折线图View

折线图演示视频


优点:链式设置 、支持双指缩放 和左右滑动 、动态Y轴数值 、x轴标签数量可变 ,折点选中效果,文本展示 



1.自定义View的具体代码

package com.example.test.ui.widgetimport 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.sqrtclass 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 / 4private 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.GRAYtextSize = 30ftextAlign = Paint.Align.CENTER}//轴画笔private val axisPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {color = Color.BLACKstrokeWidth = axisPaintWidth}//虚线画笔private val dashPaint = Paint().apply {color = Color.GRAYstyle = Paint.Style.STROKEstrokeWidth = axisPaintWidthpathEffect = DashPathEffect(floatArrayOf(20f, 20f), 0f)}//折点相关画笔 为了支持更好的自定义效果所以用两个画笔绘制折点private val dotBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {color = Color.WHITEstyle = Paint.Style.FILL}private val dotStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {color = Color.BLACKstyle = Paint.Style.STROKEstrokeWidth = axisPaintWidth}private val showValueDotStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {color = Color.REDstyle = Paint.Style.STROKEstrokeWidth = axisPaintWidth}//值相关画笔private val valueTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {color = Color.BLACKtextSize = 48ftextAlign = 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 = truewhen (event.pointerCount) {1 -> {val deltaX = event.x - pointerDownXif (abs(deltaX) > updateMoveMinD && offsetX <= 0) {val oldOffsetX = offsetX//如果首尾数据都在范围内, 则不允许滑动if (xTagXList.first() + offsetX > showLeftX && xTagXList.last() + offsetX <= showRightX) return trueval offsetResult = offsetX + deltaXif (deltaX >= 0) {offsetX = if (offsetResult >= 0) 0f else offsetResult} else {val lastTagOffsetResult = xTagXList.last() + offsetResultoffsetX =if (lastTagOffsetResult >= showRightX) offsetResult else offsetX}pointerDownX = event.xshowValueRect.left += (offsetX - oldOffsetX)showValueRect.right += (offsetX - oldOffsetX)invalidate()}}2 -> {// 双指移动时,计算距离变化,超过距离则更新步长val currentDistance = getFingerDistance(event)val distanceDelta = currentDistance - lastDistanceif (abs(distanceDelta) > updateStepMinD) {// 判断缩放方向if (distanceDelta > 0 && xStep >= 2) {xStep -= 1draw()} else if (distanceDelta < 0 && xTagXList.last() > xAxisEndX) {xStep += 1draw()}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) } ?: -1if (index != -1) {showValue = if (selectValueIndex == index) !showValue else trueselectValueIndex = indexval 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 - showValueRectHPaddingval bgRight = x + textWidth / 2 + showValueRectHPaddingvar bgBottom = y - showValueRectOffsetYvar bgTop = bgBottom - textHeight - showValueRectVPadding * 2//如果文本矩形高度 大于 点到View顶部的距离 会显示不全 所以将矩形显示到点的下方if (bgTop < 0) {bgTop = y + showValueRectVPaddingbgBottom = 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 * yGapvalueYList.add(yPosition.toFloat())}}//初始化坐标轴标签private fun initTag() {//初始化x轴标签xGap = (xAxisEndX - axisStartX - axisPaintWidth / 2) / xTagCount //x轴标签之间的间距showLeftX = axisStartX + xGap / 4showRightX = xAxisEndX - xGap / 2var tagX = axisStartX + axisPaintWidth / 2 + xGap / 2  //x轴第一个标签的x坐标//标签值数量与折点数量对齐if (valueList.size > xTagList.size) {val needed = valueList.size - xTagList.sizeval available = xTagList.sizeif (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 个元素才增加 xGapif (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 = zeroTagYfor (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.0if (maxValue != 0.0) {// 确定步长(等差值)yStep = 10.0.pow(floor(log10(maxValue)).toInt())// 计算序列的最大值(比原最大值大的第一个步长整数倍)val sequenceMax = ceil(maxValue / yStep) * yStepif (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 / 2axisStartX = padding + yTagMaxWidth + tagToAxisMargin + axisPaintWidth / 2xAxisEndX = viewWidth - paddingyAxisEndY = padding}override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {super.onSizeChanged(w, h, oldw, oldh)viewWidth = wviewHeight = hdraw()}//视图初始化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 = widthchart.axisPaint.color = color}fun tagStyle(size: Float = chart.tagPaint.textSize,color: Int = chart.tagPaint.color) = apply {chart.tagPaint.textSize = sizechart.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 = colorchart.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. 使用:

    <com.example.test.ui.widget.LineChartViewandroid:id="@+id/lineView"android:layout_width="match_parent"android:layout_height="300dp"android:background="@color/white"/>
        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轴重合}

只支持了部分参数的链式设置,而且对于设置进来的数据没有进行正确性判断 ,需要的可以自己继续添加! 

http://www.dtcms.com/a/310635.html

相关文章:

  • 栈与队列的泛型实现
  • gcc g++ makefile CMakeLists.txt cmake make 的关系
  • [lvgl_player] 用户界面(LVGL) | 播放器核心设计
  • 桌面端界面设计 |货物 TMS 系统 - SaaS UI UX 设计:审美积累之境
  • 图像处理拉普拉斯算子
  • 进阶08:Winform编写与SQL Server通信范例
  • 【OD机试题解法笔记】考古学家考古问题
  • SOLIDWORKS材料明细表设置,属于自己的BOM表模板
  • 【数据结构】-----排序的艺术画卷
  • 上海月赛kk
  • 1.2.6 装配式混凝土建筑设计构造要求
  • LOVON——面向足式Open-Vocabulary的物体导航:LLM做任务分解、YOLO11做目标检测,最后L2MM将指令和视觉映射为动作(且解决动态模糊)
  • RAGFLOW~knowledge graph
  • JavaScript 中的对象继承:从浅入深
  • 2025牛客多校第六场D题解
  • Object对象中的常用方法
  • 当10米精度遇上64维AI大脑——Google全球卫星嵌入数据集(Satellite Embedding V1)全解析
  • 【华为机试】34. 在排序数组中查找元素的第一个和最后一个位置
  • 移动端 WebView 内存泄漏与性能退化问题如何排查 实战调试方法汇总
  • 文章发布Typecho网站技巧
  • Squid服务配置代理
  • SystemVerilog的系统函数和任务
  • Python 项目路径配置完全指南
  • C语言-字符串(定义)、字符串函数(strlen、strcat、strcpy、strcmp、strlwr、strupr)
  • 航天器VHF/UHF/L频段弱电磁信号兼容性设计
  • 【3】交互式图表制作及应用方法
  • Spring Cloud 和服务拆分:微服务落地的第一步
  • Java抽象类与接口深度解析:核心区别与应用场景全指南
  • C++ - 仿 RabbitMQ 实现消息队列--服务端核心模块实现(五)
  • 流式输出:概念、技巧与常见问题