编程开源技术交流,分享技术与知识

网站首页 > 开源技术 正文

在Android折线图中实现不同Y值区间显示不同颜色

wxchong 2024-07-07 00:20:10 开源技术 12 ℃ 0 评论

最近在参与一个安卓项目,不忙的时候也会接一些开发任务,遇到一个需求,要实现一个折线图,且根据Y轴的值对应区间显示不同颜色。Android应用中各种图标的实现多数都是在用MPAndroidChart,我个人没什么深入的研究,就随大流吧。找了一些折线图实现的例子,都没有满足需求的实现方式,但是确认了图标中的线是由LineChartRenderer实现的。

既然现有方法无法实现需求,只能对原方法进行重写了。我们项目主要使用kotlin,所以下面我也以kotlin的语法进行展示,Android studio好像有kotlin和Java的转换工具,如果刚好有同学的需求和我这个类似,用的又是Java的话,可以用工具转成Java类。

先直接展示重写后的MyLineChartRenderer.kt

package com.example.mykotlinandroid.view

import android.graphics.*
import com.github.mikephil.charting.animation.ChartAnimator
import com.github.mikephil.charting.charts.LineChart
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.LineDataSet
import com.github.mikephil.charting.highlight.Highlight
import com.github.mikephil.charting.interfaces.dataprovider.LineDataProvider
import com.github.mikephil.charting.interfaces.datasets.IDataSet
import com.github.mikephil.charting.interfaces.datasets.ILineDataSet
import com.github.mikephil.charting.renderer.LineChartRenderer
import com.github.mikephil.charting.utils.ColorTemplate
import com.github.mikephil.charting.utils.ViewPortHandler
import java.util.*

class MyLineChartRenderer(
    chart: LineDataProvider, animator: ChartAnimator?,
    viewPortHandler: ViewPortHandler?
) : LineChartRenderer(chart, animator, viewPortHandler) {

    private var mHighlightCirclePaint: Paint? = null
    private var isHeart = false
    private lateinit var pos: FloatArray
    private lateinit var colors: IntArray
    private lateinit var range: IntArray

    init {
        mChart = chart
        mCirclePaintInner = Paint(Paint.ANTI_ALIAS_FLAG)
        mCirclePaintInner.style = Paint.Style.FILL
        mCirclePaintInner.color = Color.WHITE
        mHighlightCirclePaint = Paint()
    }

    private var mLineBuffer = FloatArray(4)

    override fun drawLinear(c: Canvas?, dataSet: ILineDataSet) {
        val entryCount = dataSet.entryCount
        val isDrawSteppedEnabled = dataSet.mode == LineDataSet.Mode.STEPPED
        val pointsPerEntryPair = if (isDrawSteppedEnabled) 4 else 2
        val trans = mChart.getTransformer(dataSet.axisDependency)
        val phaseY = mAnimator.phaseY
        mRenderPaint.style = Paint.Style.STROKE

        // if the data-set is dashed, draw on bitmap-canvas
        val canvas: Canvas? = if (dataSet.isDashedLineEnabled) {
            mBitmapCanvas
        } else {
            c
        }
        mXBounds[mChart] = dataSet

        // if drawing filled is enabled
        if (dataSet.isDrawFilledEnabled && entryCount > 0) {
            drawLinearFill(c, dataSet, trans, mXBounds)
        }

        // more than 1 color
        if (dataSet.colors.size > 1) {
            if (mLineBuffer.size <= pointsPerEntryPair * 2) mLineBuffer =
                FloatArray(pointsPerEntryPair * 4)
            for (j in mXBounds.min..mXBounds.range + mXBounds.min) {
                var e: Entry = dataSet.getEntryForIndex(j) ?: continue
                if (e.y == 0f) continue
                mLineBuffer[0] = e.x
                mLineBuffer[1] = e.y * phaseY
                if (j < mXBounds.max) {
                    e = dataSet.getEntryForIndex(j + 1)
                    if (e == null) break
                    if (e.y == 0f) break
                    mLineBuffer[2] = e.x
                    if (isDrawSteppedEnabled) {
                        mLineBuffer[3] = mLineBuffer[1]
                        mLineBuffer[4] = mLineBuffer[2]
                        mLineBuffer[5] = mLineBuffer[3]
                        mLineBuffer[6] = e.x
                        mLineBuffer[7] = e.y * phaseY
                    } else {
                        mLineBuffer[3] = e.y * phaseY
                    }
                } else {
                    mLineBuffer[2] = mLineBuffer[0]
                    mLineBuffer[3] = mLineBuffer[1]
                }
                trans.pointValuesToPixel(mLineBuffer)
                if (!mViewPortHandler.isInBoundsRight(mLineBuffer[0])) break

                // make sure the lines don't do shitty things outside
                // bounds
                if (!mViewPortHandler.isInBoundsLeft(mLineBuffer[2])
                    || !mViewPortHandler.isInBoundsTop(mLineBuffer[1]) && !mViewPortHandler
                        .isInBoundsBottom(mLineBuffer[3])
                ) continue

                // get the color that is set for this line-segment
                mRenderPaint.color = dataSet.getColor(j)
                canvas!!.drawLines(mLineBuffer, 0, pointsPerEntryPair * 2, mRenderPaint)
            }
        } else { // only one color per dataset
            if (mLineBuffer.size < (entryCount * pointsPerEntryPair).coerceAtLeast(
                    pointsPerEntryPair
                ) * 2
            ) mLineBuffer = FloatArray(
                (entryCount * pointsPerEntryPair).coerceAtLeast(pointsPerEntryPair) * 4
            )
            var e1: Entry?
            var e2: Entry
            e1 = dataSet.getEntryForIndex(mXBounds.min)
            if (e1 != null) {
                var j = 0
                for (x in mXBounds.min..mXBounds.range + mXBounds.min) {
                    e1 = dataSet.getEntryForIndex(if (x == 0) 0 else x - 1)
                    e2 = dataSet.getEntryForIndex(x)
                    if (e1.y == 0f || e2.y == 0f) {
                        continue
                    }
                    mLineBuffer[j++] = e1.x
                    mLineBuffer[j++] = e1.y * phaseY
                    if (isDrawSteppedEnabled) {
                        mLineBuffer[j++] = e2.x
                        mLineBuffer[j++] = e1.y * phaseY
                        mLineBuffer[j++] = e2.x
                        mLineBuffer[j++] = e1.y * phaseY
                    }
                    mLineBuffer[j++] = e2.x
                    mLineBuffer[j++] = e2.y * phaseY
                }
                if (j > 0) {
                    trans.pointValuesToPixel(mLineBuffer)
                    val size =
                        ((mXBounds.range + 1) * pointsPerEntryPair).coerceAtLeast(pointsPerEntryPair) * 2
                    mRenderPaint.color = dataSet.color
                    if (isHeart) {
                        mRenderPaint.shader = LinearGradient(
                            0f,
                            mViewPortHandler.contentRect.top,
                            0f,
                            mViewPortHandler.contentRect.bottom,
                            colors,
                            pos,
                            Shader.TileMode.CLAMP
                        )
                    }
                    canvas!!.drawLines(mLineBuffer, 0, size, mRenderPaint)
                }
            }
        }
        mRenderPaint.pathEffect = null
    }

    /**
     * cache for the circle bitmaps of all datasets
     */
    private val mImageCaches = HashMap<IDataSet<*>, DataSetImageCache>()
    private val mCirclesBuffer = FloatArray(2)

    override fun drawCircles(c: Canvas) {
        mRenderPaint.style = Paint.Style.FILL
        val phaseY = mAnimator.phaseY
        mCirclesBuffer[0] = 0f
        mCirclesBuffer[1] = 0f
        val dataSets = mChart.lineData.dataSets
        for (i in dataSets.indices) {
            val dataSet = dataSets[i]
            if (!dataSet.isVisible || !dataSet.isDrawCirclesEnabled || dataSet.entryCount == 0) continue
            mCirclePaintInner.color = dataSet.circleHoleColor
            val trans = mChart.getTransformer(dataSet.axisDependency)
            mXBounds[mChart] = dataSet
            val circleRadius = dataSet.circleRadius
            val circleHoleRadius = dataSet.circleHoleRadius
            val drawCircleHole =
                dataSet.isDrawCircleHoleEnabled && circleHoleRadius < circleRadius && circleHoleRadius > 0f
            val drawTransparentCircleHole = drawCircleHole &&
                    dataSet.circleHoleColor == ColorTemplate.COLOR_NONE
            var imageCache: DataSetImageCache?
            if (mImageCaches.containsKey(dataSet)) {
                imageCache = mImageCaches[dataSet]
            } else {
                imageCache = DataSetImageCache(mRenderPaint, mCirclePaintInner)
                mImageCaches[dataSet] = imageCache
            }
            val changeRequired = imageCache!!.init(dataSet)

            // only fill the cache with new bitmaps if a change is required
            if (changeRequired) {
                imageCache.fill(dataSet, drawCircleHole, drawTransparentCircleHole)
            }
            val boundsRangeCount = mXBounds.range + mXBounds.min
            for (j in mXBounds.min..boundsRangeCount) {
                val e = dataSet.getEntryForIndex(j) ?: break
                if (e.y == 0f) continue
                mCirclesBuffer[0] = e.x
                mCirclesBuffer[1] = e.y * phaseY
                trans.pointValuesToPixel(mCirclesBuffer)
                if (!mViewPortHandler.isInBoundsRight(mCirclesBuffer[0])) break
                if (!mViewPortHandler.isInBoundsLeft(mCirclesBuffer[0]) ||
                    !mViewPortHandler.isInBoundsY(mCirclesBuffer[1])
                ) continue
                val circleBitmap = imageCache.getBitmap(j)
                if (circleBitmap != null) {
                    c.drawBitmap(
                        circleBitmap,
                        mCirclesBuffer[0] - circleRadius,
                        mCirclesBuffer[1] - circleRadius,
                        null
                    )
                }
            }
        }
    }

    private class DataSetImageCache(val mRenderPaint: Paint, val mCirclePaintInner: Paint) {
        private val mCirclePathBuffer = Path()
        private var circleBitmaps: Array<Bitmap?>? = null

        /**
         * Sets up the cache, returns true if a change of cache was required.
         *
         * @param set
         * @return
         */
        fun init(set: ILineDataSet): Boolean {
            val size = set.circleColorCount
            var changeRequired = false
            if (circleBitmaps == null) {
                circleBitmaps = arrayOfNulls(size)
                changeRequired = true
            } else if (circleBitmaps!!.size != size) {
                circleBitmaps = arrayOfNulls(size)
                changeRequired = true
            }
            return changeRequired
        }

        /**
         * Fills the cache with bitmaps for the given dataset.
         *
         * @param set
         * @param drawCircleHole
         * @param drawTransparentCircleHole
         */
        fun fill(set: ILineDataSet, drawCircleHole: Boolean, drawTransparentCircleHole: Boolean) {
            val colorCount = set.circleColorCount
            val circleRadius = set.circleRadius
            val circleHoleRadius = set.circleHoleRadius
            for (i in 0 until colorCount) {
                val conf = Bitmap.Config.ARGB_8888
                val circleBitmap = Bitmap.createBitmap(
                    (circleRadius * 2.1).toInt(),
                    (circleRadius * 2.1).toInt(), conf
                )
                val canvas = Canvas(circleBitmap)
                circleBitmaps!![i] = circleBitmap
                mRenderPaint.color = set.getCircleColor(i)
                if (drawTransparentCircleHole) {
                    // Begin path for circle with hole
                    mCirclePathBuffer.reset()
                    mCirclePathBuffer.addCircle(
                        circleRadius,
                        circleRadius,
                        circleRadius,
                        Path.Direction.CW
                    )

                    // Cut hole in path
                    mCirclePathBuffer.addCircle(
                        circleRadius,
                        circleRadius,
                        circleHoleRadius,
                        Path.Direction.CCW
                    )

                    // Fill in-between
                    canvas.drawPath(mCirclePathBuffer, mRenderPaint)
                } else {
                    canvas.drawCircle(
                        circleRadius,
                        circleRadius,
                        circleRadius,
                        mRenderPaint
                    )
                    if (drawCircleHole) {
                        canvas.drawCircle(
                            circleRadius,
                            circleRadius,
                            circleHoleRadius,
                            mCirclePaintInner
                        )
                    }
                }
            }
        }

        /**
         * Returns the cached Bitmap at the given index.
         *
         * @param index
         * @return
         */
        fun getBitmap(index: Int): Bitmap? {
            return circleBitmaps!![index % circleBitmaps!!.size]
        }
    }

    /***
     * 对高亮的值进行显示小圆点  如果没有此需要可删除
     */
    override fun drawHighlighted(c: Canvas, indices: Array<Highlight>) {
        super.drawHighlighted(c, indices)
        val phaseY = mAnimator.phaseY
        val lineData = mChart.lineData.getDataSetByIndex(0)
        val trans = mChart.getTransformer(lineData.axisDependency)
        mCirclesBuffer[0] = 0f
        mCirclesBuffer[1] = 0f
        for (high in indices) {
            val e = lineData.getEntryForXValue(high.x, high.y)
            mCirclesBuffer[0] = e.x
            mCirclesBuffer[1] = e.y * phaseY
            trans.pointValuesToPixel(mCirclesBuffer)
            mHighlightCirclePaint!!.color = lineData.highLightColor
            //根据不同的区间显示小圆点的颜色
            if (isHeart) {
                if (e.y >= range[0]) {
                    mHighlightCirclePaint!!.color = colors[0]
                } else if (e.y < range[0] && e.y >= range[1]) {
                    mHighlightCirclePaint!!.color = colors[2]
                } else if (e.y >= range[2] && e.y < range[1]) {
                    mHighlightCirclePaint!!.color = colors[4]
                } else {
                    mHighlightCirclePaint!!.color = colors[6]
                }
            }
            c.drawCircle(mCirclesBuffer[0], mCirclesBuffer[1], 10f, mHighlightCirclePaint!!)
            mHighlightCirclePaint!!.color = Color.WHITE
            c.drawCircle(mCirclesBuffer[0], mCirclesBuffer[1], 5f, mHighlightCirclePaint!!)
        }
    }

    override fun drawHorizontalBezier(dataSet: ILineDataSet) {
        val phaseY = mAnimator.phaseY
        val trans = mChart.getTransformer(dataSet.axisDependency)
        mXBounds[mChart] = dataSet
        cubicPath.reset()
        if (mXBounds.range >= 1) {
            var prev = dataSet.getEntryForIndex(mXBounds.min)
            var cur = prev

            // let the spline start
            cubicPath.moveTo(cur.x, cur.y * phaseY)
            for (j in mXBounds.min + 1..mXBounds.range + mXBounds.min) {
                prev = cur
                cur = dataSet.getEntryForIndex(j)
                val cpx = (prev.x
                        + (cur.x - prev.x) / 2.0f)
                cubicPath.cubicTo(
                    cpx, prev.y * phaseY,
                    cpx, cur.y * phaseY,
                    cur.x, cur.y * phaseY
                )
            }
        }

        // if filled is enabled, close the path
        if (dataSet.isDrawFilledEnabled) {
            cubicFillPath.reset()
            cubicFillPath.addPath(cubicPath)
            // create a new path, this is bad for performance
            drawCubicFill(mBitmapCanvas, dataSet, cubicFillPath, trans, mXBounds)
        }
        mRenderPaint.color = dataSet.color
        mRenderPaint.style = Paint.Style.STROKE
        trans.pathValueToPixel(cubicPath)
        if (isHeart) {
            mRenderPaint.shader = LinearGradient(
                0f, mViewPortHandler.contentRect.top,
                0f, mViewPortHandler.contentRect.bottom, colors, pos, Shader.TileMode.CLAMP
            )
        }
        mBitmapCanvas.drawPath(cubicPath, mRenderPaint)
        mRenderPaint.pathEffect = null
    }

    /***
     * @param isHeart true 开启分区间显示的颜色
     * @param medium 不同层级的判断条件
     * @param colors 不同区间的颜色值,从上到下的颜色 我这里是3个值  那么分成四段 colors数组长度就为4
     */
    fun setHeartLine(isHeart: Boolean, medium: Int, larger: Int, limit: Int, colors: IntArray) {
        this.isHeart = isHeart
        range = IntArray(3)
        range[0] = limit
        range[1] = larger
        range[2] = medium
        val pos = FloatArray(4)
        val yMax = (mChart as LineChart).axisLeft.axisMaximum
        val yMin = (mChart as LineChart).axisLeft.axisMinimum
        pos[0] = (yMax - limit) / (yMax - yMin)
        pos[1] = (limit - larger) / (yMax - yMin) + pos[0]
        pos[2] = (larger - medium) / (yMax - yMin) + pos[1]
        pos[3] = 1f
        this.pos = FloatArray(pos.size * 2)
        this.colors = IntArray(colors.size * 2)
        var index = 0
        for (i in pos.indices) {
            this.colors[index] = colors[i]
            this.colors[index + 1] = colors[i]
            if (i == 0) {
                this.pos[index] = 0f
                this.pos[index + 1] = pos[i]
            } else {
                this.pos[index] = pos[i - 1]
                this.pos[index + 1] = pos[i]
            }
            index += 2
        }
    }
}

MyLineChartRenderer.kt有了,就需要写一个使用这个的MyLineChart,我们对LineChart进行重写:

package com.example.mykotlinandroid.view

import android.content.Context
import android.util.AttributeSet
import com.github.mikephil.charting.charts.LineChart
import com.github.mikephil.charting.data.LineData

class MyLineChart: LineChart {

    constructor(context: Context): super(context)

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(
        context,
        attrs,
        defStyle
    )

    override fun init() {
        super.init()
        mRenderer = MyLineChartRenderer(this, mAnimator, mViewPortHandler)
    }

    override fun getLineData(): LineData {
        return mData
    }

    override fun onDetachedFromWindow() {

        // releases the bitmap in the renderer to avoid oom error
        if (mRenderer != null && mRenderer is MyLineChartRenderer) {
            (mRenderer as MyLineChartRenderer).releaseBitmap()
        }
        super.onDetachedFromWindow()
    }
}

后面就可以直接使用了,在布局文件中直接引用重写的MyLineChart类:

<com.example.mykotlinandroid.view.MyLineChart
            android:id="@+id/lc_my_line_chart"
            android:layout_width="match_parent"
            android:layout_height="300dp"
            android:layout_below="@id/tv_title"
            android:layout_marginTop="20dp"/>

把初始化的代码也贴上来:

package com.example.mykotlinandroid.fragment

import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.example.mykotlinandroid.R
import com.example.mykotlinandroid.databinding.FragmentLineChartBinding
import com.example.mykotlinandroid.view.MyLineChartRenderer
import com.github.mikephil.charting.components.XAxis
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.LineData
import com.github.mikephil.charting.data.LineDataSet

class LineChartFragment(mContext: Context) : Fragment(R.layout.fragment_line_chart) {

    private lateinit var binding: FragmentLineChartBinding

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {

        binding = FragmentLineChartBinding.inflate(inflater)
        initView()
        return binding.root
    }
    private fun initView() {
       //准备测试数据
        var list = ArrayList<Entry>()
        for (i in 1..10) {
            if (i % 2 == 0) {
                list.add(Entry(i.toFloat() - 1, i.toFloat() * 2))
            } else {
                list.add(Entry(i.toFloat() - 1, i.toFloat() * -2))
            }
        }
        val dataSet = LineDataSet(list, "lineDataSet")
        dataSet.mode = LineDataSet.Mode.HORIZONTAL_BEZIER
        val lineData = LineData(dataSet)
        binding.lcMyLineChart.data = lineData
        binding.lcMyLineChart.legend.isEnabled = false

        binding.lcMyLineChart.description.isEnabled = false

        //设置每一个分区的颜色,将设置内容传递给MyLineChartRenderer.kt
        if (binding.lcMyLineChart.renderer is MyLineChartRenderer) {
            val renderer: MyLineChartRenderer =
                binding.lcMyLineChart.renderer as MyLineChartRenderer
            val medium = 3
            val larger = 9
            val limit = 17
            val colors = IntArray(4)
            colors[0] = resources.getColor(R.color.blue, null)
            colors[1] = resources.getColor(R.color.black, null)
            colors[2] = resources.getColor(R.color.purple_200, null)
            colors[3] = resources.getColor(R.color.teal_200, null)
            //最关键的逻辑就是下面这一步了
            renderer.setHeartLine(true, medium, larger, limit, colors)
        }

        binding.lcMyLineChart.xAxis.axisMaximum = 10f
        binding.lcMyLineChart.xAxis.axisMinimum = 0f
        binding.lcMyLineChart.axisLeft.axisMaximum = 22f
        binding.lcMyLineChart.axisLeft.axisMinimum = -22f
        binding.lcMyLineChart.axisLeft.setDrawGridLines(false)
        binding.lcMyLineChart.axisRight.isEnabled = false
//        binding.lcMyLineChart.axisRight.setDrawGridLines(false)

        binding.lcMyLineChart.setScaleEnabled(false)
        binding.lcMyLineChart.xAxis.position = XAxis.XAxisPosition.BOTTOM
    }
}

上面这个例子是将小于3归为一个颜色,3到9一个颜色,9到17一个颜色,大于17一个颜色。可以根据自己的需要进行调整,至于要设置更多的颜色区间,就需要去改MyLineChartRenderer.kt里的代码了。

这里有几个遗留问题,第一个是不能开启缩放Y轴,这个实现逻辑是根据Y轴值的初始位置进行判定的,一旦缩放Y轴,颜色不会根据Y轴的值变化而变化,除非在代码中每次缩放都加载一次MyLineChartRenderer;第二个问题是MyLineChartRenderer.kt的方法drawLinear中有一个逻辑判断坐标点Y值是否为0,如果为0就跳过这个点,继续下一个点,这就导致你在使用这种mode时:

dataSet.mode = LineDataSet.Mode.LINEAR

? ?无法显示原点?。我还遇到过线条在滑动几下后自动消失,还有没有其他问题就不清楚了,如果你是LINEAR,建议把这个逻辑注销掉?。?

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表