Android自定义View-折线图(kotlin)

先来看看效果图

Screenshot_20231219_171807_com.example.myapplication.jpg
Screenshot_20231219_171812_com.example.myapplication.jpg

分析一波

1、视图宽高固定

2、有坐标轴的视图,第一件事,当然还是计算确认出0点的位置了,之后计算出XY轴各自的长度

3、计算完坐标轴,就可以通过一共有多少个刻度,计算出每个刻度之间的间距,就可以得到所有坐标轴点的位置了,同时坐标轴内的虚线坐标网格也能确定位置了

4、确定了所有坐标轴相关信息,基本数据点的位置也可以计算出来了:数据点的值除以单个刻度的值,就可以计算出数据点位于第几个刻度的位置

5、在视图右上角位置绘制出数据标题,用以区分每条数据线对应的类型

6、可以对视图进行缩放、移动,默认是所有数据能看到的情形,所以不能再进行缩小,放大则不能超过设置的最大倍数

7、可以点击数据标题,进行显示/隐藏对应的数据线

看看整体的属性

// region 提供外部设置属性
// 屏幕方向是否为垂直方向
var isScreenVertical = false
    set(value) {
        if (field != value) {
            field = value
            xOffset = 0f
            yOffset = 0f
            preCurrX = 0
            preCurrY = 0
            xAxisLength = yAxisLength.also { yAxisLength = xAxisLength }
            refresh()
        }
    }

// 整体坐标轴偏移量
var offset = 100f
    set(value) {
        if (field != value) {
            xAxisLength = xAxisLength + 2 * field - 2 * value
            yAxisLength = yAxisLength + 2 * field - 2 * value
            field = value
            refresh()
        }
    }

// 坐标轴箭头偏移量
var arrowOffset = 12f
    set(value) {
        if (field != value) {
            field = value
            refresh()
        }
    }

// 文本与坐标轴偏移量
var textOffset = 10f
    set(value) {
        if (field != value) {
            field = value
            refresh()
        }
    }

// 坐标轴字体大小
var axisTextSize = 15f
    set(value) {
        if (field != value) {
            field = value
            refresh()
        }
    }

// 线上数据文本字体大小
var lineTextSize = 10f
    set(value) {
        if (field != value) {
            field = value
            refresh()
        }
    }

// 线对应的类型文本字体大小
var lineTypeTextSize = 12f
    set(value) {
        if (field != value) {
            field = value
            refresh()
        }
    }

var lineTypeTextSpace = 30
    set(value) {
        if (field != value) {
            field = value
            refresh()
        }
    }
// endregion

private val axisPaint = Paint()
private val axisTextPaint = Paint()
private val linePaint = Paint()
private val guidelinePaint = Paint()

private var backgroundColor = Color.WHITE
private var minTextSpace = 100f
private val chartBeanList = ArrayList<ArrayList<ChartBean>>()
private val chartBeanTypeList = ArrayList<String>()
private var xAxisLength = 0f // x轴长度
private var yAxisLength = 0f // y轴长度
private var maxXPieceIndex = 0
private var maxXPieceCount = 0 //坐标x轴分片总数量
private var xPieceInterval = 0f //坐标x轴分片间距
private var maxYPieceCount = 0 //坐标y轴分片总数量
private var yPiece = 0 //坐标y轴分片单片大小
private var yPieceInterval = 0f //坐标y轴分片间距

private var minY = -1 //数据中y轴方向包含的最小值
private var maxY = 0 //数据中y轴方向包含的最大值
private var axisPath: Path? = null //坐标轴路径
private val pathMap = hashMapOf<String, Path?>() //数据线
private val pointsMap = hashMapOf<String, MutableList<Float>?>() //数据点
private val colorMap = hashMapOf<String, String>() //每条数据线的颜色

private val scaleGestureDetector: ScaleGestureDetector
private val gestureDetector: GestureDetector
private val scroller: Scroller
private val minScale = 1f
private val maxScale = 10f
private var scale = 1f
private var xOffset = 0f //x轴的偏移量
private var yOffset = 0f //y轴的偏移量

// 不进行绘制的类型
private val noDrawType = ArrayList<String>()

// 类型标题的坐标,用于点击判断
private val typeRectF = HashMap<String, RectF>()

属性相对较多,需要控制各种颜色大小偏移量等,操作起来相对复杂

其中x轴长度xAxisLength = width - offset * 2

y轴长度yAxisLength = height - offset * 2

通过数据进行计算相应坐标位置

fun addChartBeanList(chartBeanType: String, chartBeanList: ArrayList<ChartBean>) {
    try {
        chartBeanTypeList.add(chartBeanType)
        typeRectF[chartBeanType] = RectF()
        this.chartBeanList.add(ArrayList(chartBeanList))
        checkMaxValue(chartBeanList)
        colorMap[chartBeanType] = getRandColor()
        refresh()
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

data class ChartBean(val axisInfo: String, val num: Int)

通过addChartBeanList就可以进行添加一条数据线,其中chartBeanType对应的数据线的标题,chartBeanList则为该线对应的所有数据点,ChartBean仅包含对应的x轴位置标题及y轴值。

注意:由于X轴的刻度名称都是字符串,没办法通过数据点直接获取第一个和最后一个的名称,也没办法确认需要绘制多少个刻度,所以目前采用的是:chartBeanList里面包含了所有的X轴刻度,后续考虑通过单独的属性进行传递设置。

下面通过传递过来的数据点进行计算以下属性:

private var axisPath: Path? = null //坐标轴路径
private val pathMap = hashMapOf<String, Path?>() //数据线
private val pointsMap = hashMapOf<String, MutableList<Float>?>() //数据点
private var yPiece = 0 //坐标y轴刻度值间隔大小

maxXPieceCount = max(maxXPieceCount, chartBeanList.size + 1) //坐标x轴刻度总数量
xPieceInterval = xAxisLength / maxXPieceCount //坐标x轴刻度间距
maxY = max(maxY, dailyInfo.num) //数据中y轴方向包含的最大值
minY = min(minY, dailyInfo.num) //数据中y轴方向包含的最小值
maxYPieceCount = ((maxY - minY) / yPiece + 1) //坐标y轴刻度总数量
yPieceInterval = yAxisLength / maxYPieceCount //坐标y轴刻度间距

// 计算yPiece  y轴刻度值间隔大小,从1开始,以10为单位进行计算
var digits = 0 //位数
var tempMaxY = maxY - minY
while (tempMaxY > 1) {
    digits++
    tempMaxY /= 10
}
if (digits > 0) { // 位数大于0则表明maxY大于1
    yPiece = 1
    while (digits > 1) {
        yPiece *= 10
        digits--
    }
}

同时可以得到坐标轴三个点确认坐标轴路径axisPath:

0点(offset, offset + yAxisLength + arrowOffset)

y顶点(offset, offset)

x顶点(offset + xAxisLength + arrowOffset, offset + yAxisLength + arrowOffset)

连接y-0,0-x即可得到坐标轴

坐标轴得到了,x轴刻度间距得到了,y轴刻度间距得到了,那中间的网格线位置也同时都能确定了。右上角的数据标题,直接获取到右上角的点,然后测量最长的标题长度,减去长度的一半,即可得到文本绘制的中心位置,然后依次往下绘制标题即可。剩下数据点和数据线的位置需要确认:

// 创建数据线path及数据点point
private fun createPathAndPoints(chartBeanType: String, chartBeanList: List<ChartBean>) {
    if (chartBeanList.isNotEmpty()) {
        var path = pathMap[chartBeanType]
        var points = pointsMap[chartBeanType]
        val xOffset = (maxXPieceCount - 1 - chartBeanList.size) * xPieceInterval * scale
        var x: Float
        var y: Float
        for (index in chartBeanList.indices) {
            if (isScreenVertical) {
                x = offset + (index + 1) * xPieceInterval * scale + xOffset + this.xOffset
                y = getVerticalYAxis(chartBeanList[index]) + this.yOffset
            } else {
                x = getHorizontalXAxis(chartBeanList[index]) + this.yOffset
                y = offset + (index + 1) * xPieceInterval * scale + xOffset + this.xOffset
            }
            if (path == null) {
                path = Path()
                pathMap[chartBeanType] = path
                path.moveTo(x, y)
            } else {
                path.lineTo(x, y)
            }
            if (points == null) {
                points = mutableListOf()
                pointsMap[chartBeanType] = points
            }
            points.add(x)
            points.add(y)
        }
    }
}

通过createPathAndPoints进行计算所有数据点points和数据线pathMap的坐标,用于后续绘制。

数据线就是直接连接所有数据点即可,所以主要在于计算数据点的位置:

数据点的x轴位置 = offset + (索引 + 1)* xPieceInterval

y轴位置计算相对复杂,需要计算出对应的y轴刻度位置:其中yAxisLength + offset + arrowOffset为0点y轴位置,(count * yPieceInterval + last * yPieceInterval / yPiece)则对应y轴刻度位置

private fun getVerticalYAxis(chartBean: ChartBean): Float {
    val count = (chartBean.num - minY) / yPiece
    val last = (chartBean.num - minY) % yPiece
    return yAxisLength + offset + arrowOffset - (count * yPieceInterval + last * yPieceInterval / yPiece) * scale
}

以上,所以需要计算的位置都计算完成了,后续只需要根据这些位置信息进行绘制即可。

着手绘制onDraw

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    if (axisPath != null) {
        drawDataLine(canvas)
        drawBackground(canvas)
        // 绘制数据先对应的标题
        drawDataTitle(canvas)
        // 绘制坐标轴
        axisPaint.color = Color.BLACK
        axisPaint.style = Paint.Style.STROKE
        canvas.drawPath(axisPath!!, axisPaint)
        drawXAxis(canvas)
        drawYAxis(canvas)
    }
}

绘制的时候需要注意的是:因为后续会有缩放及移动的操作,那么数据就可能会被移动到坐标轴外,此时需要用背景对坐标轴外的数据进行遮挡,避免影响视图层级,所以这里先进行drawDataLine绘制数据点和数据线,再进行drawBackground绘制坐标轴外的背景,进行遮挡操作,其它绘制顺序则可以不用强制要求。

前面一开始就已经得到坐标轴三个点的位置了,所以判断坐标轴外的操作也能相应得到:

// 绘制遮挡背景
private fun drawBackground(canvas: Canvas) {
    // 绘制一层背景遮挡住超出坐标轴的数据
    axisPaint.color = backgroundColor
    axisPaint.style = Paint.Style.FILL
    canvas.drawRect(0f, 0f, offset - axisPaint.strokeWidth / 2, height.toFloat(), axisPaint)
    canvas.drawRect(0f, 0f, width.toFloat(), offset, axisPaint)
    canvas.drawRect(width.toFloat() - offset, 0f, width.toFloat(), height.toFloat(), axisPaint)
    val bottomTop =
        if (isScreenVertical) offset + yAxisLength + arrowOffset + axisPaint.strokeWidth / 2 else
            offset + xAxisLength + arrowOffset + axisPaint.strokeWidth / 2
    canvas.drawRect(
        0f, bottomTop, width.toFloat(), height.toFloat(), axisPaint
    )
}

其它绘制操作就不详细展开了,都是根据之前计算得到的信息进行相应绘制即可。

添加手势:缩放、移动

手势需要同时实现以下三个接口:

ScaleGestureDetector.OnScaleGestureListener // 缩放手势
GestureDetector.OnGestureListener // 移动手势
GestureDetector.OnDoubleTapListener // 双击缩放手势

其中移动的原理基本和之前写的日历视图差不多,就不展开了 自定义View,实现日历展示事件 - 掘金 (juejin.cn)

先看看简单的双击缩放:

override fun onDoubleTap(e: MotionEvent): Boolean {
    scale = when {
        scale == maxScale -> {
            xOffset = 0f
            yOffset = 0f
            1f
        }

        scale >= maxScale / 2 -> maxScale
        else -> scale * 2
    }
    refreshData()
    return true
}

双击的时候,判断当前缩放倍率值:为最大,则缩小回1倍,大于等于最大的一半,则放大到最大,其它直接按当前倍率 * 2

以下为缩放手势的逻辑:

private var isScaleGesture = false
override fun onScale(detector: ScaleGestureDetector): Boolean {
    isScaleGesture = true
    val preScale = scale
    scale *= detector.scaleFactor
    scale = when {
        scale <= minScale -> minScale
        scale >= maxScale -> maxScale
        else -> scale
    }
    if (preScale != scale) {
        checkOffset()
        refreshData()
    }
    return true
}

override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = true

override fun onScaleEnd(detector: ScaleGestureDetector) {
    isScaleGesture = false
}

获取缩放后的倍率:scale *= detector.scaleFactor,缩放后需要确保再[minScale,maxScale]之间,同时需要进行checkOffset()检测缩放后的位移,避免缩放后位置错乱。

单击手势:隐藏显示对应的数据点/线

单击的时候可以获取到点击的位置x/y,通过判断点击的哪个标题位置,获取到点击的标题,然后添加到noDrawType中,在之前的数据点/线绘制步骤,添加判断noDrawType的进行过滤绘制即可

override fun onSingleTapUp(e: MotionEvent): Boolean {
    // 仅有一条线,禁止点击隐藏
    if (chartBeanTypeList.size <= 1) return true
    for ((type, rectF) in typeRectF) {
        if (rectF.contains(e.x, e.y)) {
            if (noDrawType.contains(type))
                noDrawType.remove(type)
            else
                noDrawType.add(type)
            invalidate()
            break
        }
    }
    return true
}

private fun drawDataLine(canvas: Canvas) {
    for ((offsetCount, chartBeanType) in chartBeanTypeList.withIndex()) {
        if (noDrawType.contains(chartBeanType)) continue
        ...
    }
}

完结撒花

大致的实现逻辑就这些了,刚开始写文章,不太会哈哈哈,有什么不懂可以提问哟~大家一起探讨探讨

项目源码:Calendar: 周日历、月日历 用于记录展示事件列表 (gitee.com),目前都是写在这个测试项目呢,点击右上角更多-查看折线图就可以看到了哟

项目里面的ncalendar模块,使用的是GitHub - yannecer/NCalendar: 一款安卓日历,仿miui,钉钉,华为的日历,万年历、365、周日历,月日历,月视图、周视图滑动切换,农历,节气,Andriod Calendar , MIUI Calendar,小米日历

微信搜索“A查佣利小助手”,获取支付宝红包、TB/JD/PDD返利最新优惠资讯
请添加图片描述

  • 5
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

虾米~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值