当前位置 : 主页 > 手机开发 > android >

Android 实现自定义折线图控件

来源:互联网 收集:自由互联 发布时间:2023-02-01
目录 前言 概述 原点 计算Y轴宽度 计算X轴高度 X轴 绘制轴线 X轴刻度间隔 网格线、文本 Y轴 计算Y轴分布 刻度间隔、网格线、文本 折线 代码 前言 日前,有一个折现图的需求,如下图所
目录
  • 前言
  • 概述
  • 原点
    • 计算Y轴宽度
    • 计算X轴高度
  • X轴
    • 绘制轴线
    • X轴刻度间隔
    • 网格线、文本
  • Y轴
    • 计算Y轴分布
    • 刻度间隔、网格线、文本
  • 折线
    • 代码

      前言

      日前,有一个“折现图”的需求,如下图所示:

      概述

      如何自定义折线图?首先将折线图的绘制部分拆分成三部分:

      • 原点
      • X轴
      • Y轴
      • 折线

      原点

      第一步,需要定义出“折线图”原点的位置,由图得:

      可以发现,原点的位置由X轴、Y轴所占空间决定:

      OriginX:Y轴宽度
      OriginY:View高度 - X轴高度

      计算Y轴宽度

      思路:遍历Y轴的绘制文字,用画笔测量其最大宽度,在加上其左右Margin间距即Y轴宽度

      Y轴宽度 = Y轴MarginLeft + Y轴最大文字宽度 + Y轴MariginRight

      计算X轴高度

      思路:获取X轴画笔FontMetrics,根据其top、bottom计算出X轴文字高度,在加上其上下Margin间距即X轴高度

      val fontMetrics = xAxisTextPaint.fontMetrics
      val lineHeight = fontMetrics.bottom - fontMetrics.top
      xAxisHeight = lineHeight + xAxisOptions.textMarginTop + xAxisOptions.textMarginBottom

      X轴

      第二步,根据原点位置,绘制X轴轴线、网格线、文本

      绘制轴线

      绘制轴线比较简单,沿原点向控件右侧画一条直线即可

      if (xAxisOptions.isEnableLine) {
          xAxisLinePaint.strokeWidth = xAxisOptions.lineWidth
          xAxisLinePaint.color = xAxisOptions.lineColor
          xAxisLinePaint.pathEffect = xAxisOptions.linePathEffect
          canvas.drawLine(originX, originY, width.toFloat(), originY, xAxisLinePaint)
      }

      X轴刻度间隔

      在绘制网格线、文本之前需要先计算X轴的刻度间隔:

      这里处理的方式比较随意,直接将X轴等分7份即可(因为需要显示近7天的数据)

      xGap = (width - originX) / 7

      网格线、文本

      网格线:只需要根据X轴的刻度,沿Y轴方向依次向控件顶部,画直线即可

      文本:文本需要通过画笔,提前测量出待绘制文本的区域,然后计算出居中位置绘制即可

      xAxisTexts.forEachIndexed { index, text ->
          val pointX = originX + index * xGap
          //刻度线
          if (xAxisOptions.isEnableRuler) {
              xAxisLinePaint.strokeWidth = xAxisOptions.rulerWidth
              xAxisLinePaint.color = xAxisOptions.rulerColor
              canvas.drawLine(
                  pointX, originY,
                  pointX, originY - xAxisOptions.rulerHeight,
                  xAxisLinePaint
              )
          }
          //网格线
          if (xAxisOptions.isEnableGrid) {
              xAxisLinePaint.strokeWidth = xAxisOptions.gridWidth
              xAxisLinePaint.color = xAxisOptions.gridColor
              xAxisLinePaint.pathEffect = xAxisOptions.gridPathEffect
              canvas.drawLine(pointX, originY, pointX, 0f, xAxisLinePaint)
          }
          //文本
          bounds.setEmpty()
          xAxisTextPaint.textSize = xAxisOptions.textSize
          xAxisTextPaint.color = xAxisOptions.textColor
          xAxisTextPaint.getTextBounds(text, 0, text.length, bounds)
          val fm = xAxisTextPaint.fontMetrics
          val fontHeight = fm.bottom - fm.top
          val fontX = originX + index * xGap + (xGap - bounds.width()) / 2f
          val fontBaseline = originY + (xAxisHeight - fontHeight) / 2f - fm.top
          canvas.drawText(text, fontX, fontBaseline, xAxisTextPaint)
      }

      Y轴

      第三步:根据原点位置,绘制Y轴轴线、网格线、文本

      计算Y轴分布

      个人认为,这里是自定义折线图的一个难点,这里经过查阅资料,使用该文章中的算法:

      基于JavaScript实现数值型坐标轴刻度计算算法(echarts的y轴刻度计算)

      /**
       * 根据Y轴最大值、数量获取Y轴的标准间隔
       */
      private fun getYInterval(maxY: Int): Int {
          val yIntervalCount = yAxisCount - 1
          val rawInterval = maxY / yIntervalCount.toFloat()
          val magicPower = floor(log10(rawInterval.toDouble()))
          var magic = 10.0.pow(magicPower).toFloat()
          if (magic == rawInterval) {
              magic = rawInterval
          } else {
              magic *= 10
          }
          val rawStandardInterval = rawInterval / magic
          val standardInterval = getStandardInterval(rawStandardInterval) * magic
          return standardInterval.roundToInt()
      }
      
      /**
       * 根据初始的归一化后的间隔,转化为目标的间隔
       */
      private fun getStandardInterval(x: Float): Float {
          return when {
              x <= 0.1f -> 0.1f
              x <= 0.2f -> 0.2f
              x <= 0.25f -> 0.25f
              x <= 0.5f -> 0.5f
              x <= 1f -> 1f
              else -> getStandardInterval(x / 10) * 10
          }
      }

      刻度间隔、网格线、文本

      Y轴的轴线、网格线、文本剩下的内容与X轴的处理方式几乎一致

      //绘制Y轴
      //轴线
      if (yAxisOptions.isEnableLine) {
          yAxisLinePaint.strokeWidth = yAxisOptions.lineWidth
          yAxisLinePaint.color = yAxisOptions.lineColor
          yAxisLinePaint.pathEffect = yAxisOptions.linePathEffect
          canvas.drawLine(originX, 0f, originX, originY, yAxisLinePaint)
      }
      yAxisTexts.forEachIndexed { index, text ->
          //刻度线
          val pointY = originY - index * yGap
          if (yAxisOptions.isEnableRuler) {
              yAxisLinePaint.strokeWidth = yAxisOptions.rulerWidth
              yAxisLinePaint.color = yAxisOptions.rulerColor
              canvas.drawLine(
                  originX,
                  pointY,
                  originX + yAxisOptions.rulerHeight,
                  pointY,
                  yAxisLinePaint
              )
          }
          //网格线
          if (yAxisOptions.isEnableGrid) {
              yAxisLinePaint.strokeWidth = yAxisOptions.gridWidth
              yAxisLinePaint.color = yAxisOptions.gridColor
              yAxisLinePaint.pathEffect = yAxisOptions.gridPathEffect
              canvas.drawLine(originX, pointY, width.toFloat(), pointY, yAxisLinePaint)
          }
          //文本
          bounds.setEmpty()
          yAxisTextPaint.textSize = yAxisOptions.textSize
          yAxisTextPaint.color = yAxisOptions.textColor
          yAxisTextPaint.getTextBounds(text, 0, text.length, bounds)
          val fm = yAxisTextPaint.fontMetrics
          val x = (yAxisWidth - bounds.width()) / 2f
          val fontHeight = fm.bottom - fm.top
          val y = originY - index * yGap - fontHeight / 2f - fm.top
          canvas.drawText(text, x, y, yAxisTextPaint)
      }

      折线

      折线的连接,这里使用的是Path,将一个一个坐标点连接,最后将Path绘制,就形成了图中的折线图

      //绘制数据
      path.reset()
      points.forEachIndexed { index, point ->
          val x = originX + index * xGap + xGap / 2f
          val y = originY - (point.yAxis.toFloat() / yAxisMaxValue) * (yGap * (yAxisCount - 1))
          if (index == 0) {
              path.moveTo(x, y)
          } else {
              path.lineTo(x, y)
          }
          //圆点
          circlePaint.color = dataOptions.circleColor
          canvas.drawCircle(x, y, dataOptions.circleRadius, circlePaint)
      }
      pathPaint.strokeWidth = dataOptions.pathWidth
      pathPaint.color = dataOptions.pathColor
      canvas.drawPath(path, pathPaint)

      值得注意的是:坐标点X根据间隔是相对确定的,而坐标点Y则需要进行百分比换算

      代码

      折线图LineChart

      package com.vander.pool.widget.linechart
      import android.content.Context
      import android.graphics.*
      import android.text.TextPaint
      import android.util.AttributeSet
      import android.view.View
      import java.text.DecimalFormat
      import kotlin.math.floor
      import kotlin.math.log10
      import kotlin.math.pow
      import kotlin.math.roundToInt
      class LineChart : View {
          private var options = ChartOptions()
          /**
           * X轴相关
           */
          private val xAxisTextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
          private val xAxisLinePaint = Paint(Paint.ANTI_ALIAS_FLAG)
          private val xAxisTexts = mutableListOf<String>()
          private var xAxisHeight = 0f
          /**
           * Y轴相关
           */
          private val yAxisTextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
          private val yAxisLinePaint = Paint(Paint.ANTI_ALIAS_FLAG)
          private val yAxisTexts = mutableListOf<String>()
          private var yAxisWidth = 0f
          private val yAxisCount = 5
          private var yAxisMaxValue: Int = 0
          /**
           * 原点
           */
          private var originX = 0f
          private var originY = 0f
          private var xGap = 0f
          private var yGap = 0f
          /**
           * 数据相关
           */
          private val pathPaint = Paint(Paint.ANTI_ALIAS_FLAG).also {
              it.style = Paint.Style.STROKE
          }
          private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).also {
              it.color = Color.parseColor("#79EBCF")
              it.style = Paint.Style.FILL
          }
          private val points = mutableListOf<ChartBean>()
          private val bounds = Rect()
          private val path = Path()
          constructor(context: Context)
                  : this(context, null)
          constructor(context: Context, attrs: AttributeSet?)
                  : this(context, attrs, 0)
          constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
                  super(context, attrs, defStyleAttr)
          override fun onDraw(canvas: Canvas) {
              super.onDraw(canvas)
              if (points.isEmpty()) return
              val xAxisOptions = options.xAxisOptions
              val yAxisOptions = options.yAxisOptions
              val dataOptions = options.dataOptions
              //设置原点
              originX = yAxisWidth
              originY = height - xAxisHeight
              //设置X轴Y轴间隔
              xGap = (width - originX) / points.size
              //Y轴默认顶部会留出一半空间
              yGap = originY / (yAxisCount - 1 + 0.5f)
              //绘制X轴
              //轴线
              if (xAxisOptions.isEnableLine) {
                  xAxisLinePaint.strokeWidth = xAxisOptions.lineWidth
                  xAxisLinePaint.color = xAxisOptions.lineColor
                  xAxisLinePaint.pathEffect = xAxisOptions.linePathEffect
                  canvas.drawLine(originX, originY, width.toFloat(), originY, xAxisLinePaint)
              }
              xAxisTexts.forEachIndexed { index, text ->
                  val pointX = originX + index * xGap
                  //刻度线
                  if (xAxisOptions.isEnableRuler) {
                      xAxisLinePaint.strokeWidth = xAxisOptions.rulerWidth
                      xAxisLinePaint.color = xAxisOptions.rulerColor
                      canvas.drawLine(
                          pointX, originY,
                          pointX, originY - xAxisOptions.rulerHeight,
                          xAxisLinePaint
                      )
                  }
                  //网格线
                  if (xAxisOptions.isEnableGrid) {
                      xAxisLinePaint.strokeWidth = xAxisOptions.gridWidth
                      xAxisLinePaint.color = xAxisOptions.gridColor
                      xAxisLinePaint.pathEffect = xAxisOptions.gridPathEffect
                      canvas.drawLine(pointX, originY, pointX, 0f, xAxisLinePaint)
                  }
                  //文本
                  bounds.setEmpty()
                  xAxisTextPaint.textSize = xAxisOptions.textSize
                  xAxisTextPaint.color = xAxisOptions.textColor
                  xAxisTextPaint.getTextBounds(text, 0, text.length, bounds)
                  val fm = xAxisTextPaint.fontMetrics
                  val fontHeight = fm.bottom - fm.top
                  val fontX = originX + index * xGap + (xGap - bounds.width()) / 2f
                  val fontBaseline = originY + (xAxisHeight - fontHeight) / 2f - fm.top
                  canvas.drawText(text, fontX, fontBaseline, xAxisTextPaint)
              }
              //绘制Y轴
              //轴线
              if (yAxisOptions.isEnableLine) {
                  yAxisLinePaint.strokeWidth = yAxisOptions.lineWidth
                  yAxisLinePaint.color = yAxisOptions.lineColor
                  yAxisLinePaint.pathEffect = yAxisOptions.linePathEffect
                  canvas.drawLine(originX, 0f, originX, originY, yAxisLinePaint)
              }
              yAxisTexts.forEachIndexed { index, text ->
                  //刻度线
                  val pointY = originY - index * yGap
                  if (yAxisOptions.isEnableRuler) {
                      yAxisLinePaint.strokeWidth = yAxisOptions.rulerWidth
                      yAxisLinePaint.color = yAxisOptions.rulerColor
                      canvas.drawLine(
                          originX,
                          pointY,
                          originX + yAxisOptions.rulerHeight,
                          pointY,
                          yAxisLinePaint
                      )
                  }
                  //网格线
                  if (yAxisOptions.isEnableGrid) {
                      yAxisLinePaint.strokeWidth = yAxisOptions.gridWidth
                      yAxisLinePaint.color = yAxisOptions.gridColor
                      yAxisLinePaint.pathEffect = yAxisOptions.gridPathEffect
                      canvas.drawLine(originX, pointY, width.toFloat(), pointY, yAxisLinePaint)
                  }
                  //文本
                  bounds.setEmpty()
                  yAxisTextPaint.textSize = yAxisOptions.textSize
                  yAxisTextPaint.color = yAxisOptions.textColor
                  yAxisTextPaint.getTextBounds(text, 0, text.length, bounds)
                  val fm = yAxisTextPaint.fontMetrics
                  val x = (yAxisWidth - bounds.width()) / 2f
                  val fontHeight = fm.bottom - fm.top
                  val y = originY - index * yGap - fontHeight / 2f - fm.top
                  canvas.drawText(text, x, y, yAxisTextPaint)
              }
              //绘制数据
              path.reset()
              points.forEachIndexed { index, point ->
                  val x = originX + index * xGap + xGap / 2f
                  val y = originY - (point.yAxis.toFloat() / yAxisMaxValue) * (yGap * (yAxisCount - 1))
                  if (index == 0) {
                      path.moveTo(x, y)
                  } else {
                      path.lineTo(x, y)
                  }
                  //圆点
                  circlePaint.color = dataOptions.circleColor
                  canvas.drawCircle(x, y, dataOptions.circleRadius, circlePaint)
              }
              pathPaint.strokeWidth = dataOptions.pathWidth
              pathPaint.color = dataOptions.pathColor
              canvas.drawPath(path, pathPaint)
          }
          /**
           * 设置数据
           */
          fun setData(list: List<ChartBean>) {
              points.clear()
              points.addAll(list)
              //设置X轴、Y轴数据
              setXAxisData(list)
              setYAxisData(list)
              invalidate()
          }
          /**
           * 设置X轴数据
           */
          private fun setXAxisData(list: List<ChartBean>) {
              val xAxisOptions = options.xAxisOptions
              val values = list.map { it.xAxis }
              //X轴文本
              xAxisTexts.clear()
              xAxisTexts.addAll(values)
              //X轴高度
              val fontMetrics = xAxisTextPaint.fontMetrics
              val lineHeight = fontMetrics.bottom - fontMetrics.top
              xAxisHeight = lineHeight + xAxisOptions.textMarginTop + xAxisOptions.textMarginBottom
          }
          /**
           * 设置Y轴数据
           */
          private fun setYAxisData(list: List<ChartBean>) {
              val yAxisOptions = options.yAxisOptions
              yAxisTextPaint.textSize = yAxisOptions.textSize
              yAxisTextPaint.color = yAxisOptions.textColor
              val texts = list.map { it.yAxis.toString() }
              yAxisTexts.clear()
              yAxisTexts.addAll(texts)
              //Y轴高度
              val maxTextWidth = yAxisTexts.maxOf { yAxisTextPaint.measureText(it) }
              yAxisWidth = maxTextWidth + yAxisOptions.textMarginLeft + yAxisOptions.textMarginRight
              //Y轴间隔
              val maxY = list.maxOf { it.yAxis }
              val interval = when {
                  maxY <= 10 -> getYInterval(10)
                  else -> getYInterval(maxY)
              }
              //Y轴文字
              yAxisTexts.clear()
              for (index in 0..yAxisCount) {
                  val value = index * interval
                  yAxisTexts.add(formatNum(value))
              }
              yAxisMaxValue = (yAxisCount - 1) * interval
          }
          /**
           * 格式化数值
           */
          private fun formatNum(num: Int): String {
              val absNum = Math.abs(num)
              return if (absNum >= 0 && absNum < 1000) {
                  return num.toString()
              } else {
                  val format = DecimalFormat("0.0")
                  val value = num / 1000f
                  "${format.format(value)}k"
              }
          }
          /**
           * 根据Y轴最大值、数量获取Y轴的标准间隔
           */
          private fun getYInterval(maxY: Int): Int {
              val yIntervalCount = yAxisCount - 1
              val rawInterval = maxY / yIntervalCount.toFloat()
              val magicPower = floor(log10(rawInterval.toDouble()))
              var magic = 10.0.pow(magicPower).toFloat()
              if (magic == rawInterval) {
                  magic = rawInterval
              } else {
                  magic *= 10
              }
              val rawStandardInterval = rawInterval / magic
              val standardInterval = getStandardInterval(rawStandardInterval) * magic
              return standardInterval.roundToInt()
          }
          /**
           * 根据初始的归一化后的间隔,转化为目标的间隔
           */
          private fun getStandardInterval(x: Float): Float {
              return when {
                  x <= 0.1f -> 0.1f
                  x <= 0.2f -> 0.2f
                  x <= 0.25f -> 0.25f
                  x <= 0.5f -> 0.5f
                  x <= 1f -> 1f
                  else -> getStandardInterval(x / 10) * 10
              }
          }
          /**
           * 重置参数
           */
          fun setOptions(newOptions: ChartOptions) {
              this.options = newOptions
              setData(points)
          }
          fun getOptions(): ChartOptions {
              return options
          }
          data class ChartBean(val xAxis: String, val yAxis: Int)
      
      }

      ChartOptions配置选项:

      class ChartOptions {
          //X轴配置
          var xAxisOptions = AxisOptions()
          //Y轴配置
          var yAxisOptions = AxisOptions()
          //数据配置
          var dataOptions = DataOptions()
      
      }
      /**
       * 轴线配置参数
       */
      class AxisOptions {
         companion object {
           private const val DEFAULT_TEXT_SIZE = 20f
             private const val DEFAULT_TEXT_COLOR = Color.BLACK
              private const val DEFAULT_TEXT_MARGIN = 20
              private const val DEFAULT_LINE_WIDTH = 2f
              private const val DEFAULT_RULER_WIDTH = 10f
          }
          /**
           * 文字大小
           */
          @FloatRange(from = 1.0)
          var textSize: Float = DEFAULT_TEXT_SIZE
          @ColorInt
          var textColor: Int = DEFAULT_TEXT_COLOR
          /**
           * X轴文字内容上下两侧margin
           */
          var textMarginTop: Int = DEFAULT_TEXT_MARGIN
          var textMarginBottom: Int = DEFAULT_TEXT_MARGIN
          /**
           * Y轴文字内容左右两侧margin
           */
          var textMarginLeft: Int = DEFAULT_TEXT_MARGIN
          var textMarginRight: Int = DEFAULT_TEXT_MARGIN
          /**
           * 轴线
           */
          var lineWidth: Float = DEFAULT_LINE_WIDTH
          @ColorInt
          var lineColor: Int = DEFAULT_TEXT_COLOR
          var isEnableLine = true
         var linePathEffect: PathEffect? = null
          /**
           * 刻度
           */
          var rulerWidth = DEFAULT_LINE_WIDTH
          var rulerHeight = DEFAULT_RULER_WIDTH
          @ColorInt
          var rulerColor = DEFAULT_TEXT_COLOR
          var isEnableRuler = true
          /**
           * 网格
           */
          var gridWidth: Float = DEFAULT_LINE_WIDTH
          @ColorInt
          var gridColor: Int = DEFAULT_TEXT_COLOR
          var gridPathEffect: PathEffect? = null
          var isEnableGrid = true
      }
      /**
       * 数据配置参数
       */
      class DataOptions {
          companion object {
              private const val DEFAULT_PATH_WIDTH = 2f
              private const val DEFAULT_PATH_COLOR = Color.BLACK
              private const val DEFAULT_CIRCLE_RADIUS = 10f
              private const val DEFAULT_CIRCLE_COLOR = Color.BLACK
          }
          var pathWidth = DEFAULT_PATH_WIDTH
          var pathColor = DEFAULT_PATH_COLOR
          var circleRadius = DEFAULT_CIRCLE_RADIUS
          var circleColor = DEFAULT_CIRCLE_COLOR
      }

      Demo样式:

      private fun initView() {
          val options = binding.chart.getOptions()
          //X轴
          val xAxisOptions = options.xAxisOptions
          xAxisOptions.isEnableLine = false
          xAxisOptions.textColor = Color.parseColor("#999999")
          xAxisOptions.textSize = dpToPx(12)
          xAxisOptions.textMarginTop = dpToPx(12).toInt()
          xAxisOptions.textMarginBottom = dpToPx(12).toInt()
          xAxisOptions.isEnableGrid = false
          xAxisOptions.isEnableRuler = false
          //Y轴
          val yAxisOptions = options.yAxisOptions
          yAxisOptions.isEnableLine = false
          yAxisOptions.textColor = Color.parseColor("#999999")
          yAxisOptions.textSize = dpToPx(12)
          yAxisOptions.textMarginLeft = dpToPx(12).toInt()
          yAxisOptions.textMarginRight = dpToPx(12).toInt()
          yAxisOptions.gridColor = Color.parseColor("#999999")
          yAxisOptions.gridWidth = dpToPx(0.5f)
          val dashLength = dpToPx(8f)
          yAxisOptions.gridPathEffect = DashPathEffect(floatArrayOf(dashLength, dashLength / 2), 0f)
          yAxisOptions.isEnableRuler = false
          //数据
          val dataOptions = options.dataOptions
          dataOptions.pathColor = Color.parseColor("#79EBCF")
          dataOptions.pathWidth = dpToPx(1f)
          dataOptions.circleColor = Color.parseColor("#79EBCF")
          dataOptions.circleRadius = dpToPx(3f)
          binding.chart.setOnClickListener {
              initChartData()
          }
          binding.toolbar.setLeftClick {
              finish()
          }
      }
      private fun initChartData() {
          val random = 1000
          val list = mutableListOf<LineChart.ChartBean>()
          list.add(LineChart.ChartBean("05-01", Random.nextInt(random)))
          list.add(LineChart.ChartBean("05-02", Random.nextInt(random)))
          list.add(LineChart.ChartBean("05-03", Random.nextInt(random)))
          list.add(LineChart.ChartBean("05-04", Random.nextInt(random)))
          list.add(LineChart.ChartBean("05-05", Random.nextInt(random)))
          list.add(LineChart.ChartBean("05-06", Random.nextInt(random)))
          list.add(LineChart.ChartBean("05-07", Random.nextInt(random)))
          binding.chart.setData(list)
          //文本
          val text = list.joinToString("\n") {
              "x : ${it.xAxis}  y:${it.yAxis}"
          }
          binding.value.text = text
      }

      到此这篇关于Android 实现自定义折线图控件的文章就介绍到这了,更多相关Android折线图控件内容请搜索自由互联以前的文章或继续浏览下面的相关文章希望大家以后多多支持自由互联!

      上一篇:Android开发Retrofit源码分析
      下一篇:没有了
      网友评论