记一次自定义View:拖动小球图表


前言

拖动小球图表? 什么鬼. 我们直接看图吧


在这里插入图片描述

这只是个半成品, 博主没有做完. 连原型长什么样子我都忘了. 产品拿着好像是一个天气应用. 让我比着做. 博主做的不算慢(也就一天左右吧), 但产品居然说不做就不做了. 业务不做梳理和规划, 说做啥就做啥的行为, 很让人头大!!

虽然没做完, 但核心逻辑都已经写出来了. 今天博主重新整理了一下, 就当巩固自定义View的知识了!


一、分析

  1. 简单元素: Y轴刻度线, XY轴标注, 区域计算划分等
  2. 折线点, 折线. 选中区域 标注高亮等
  3. 绘制小球. 以及圆弧区域. 我们用 贝赛尔曲线 绘制
  4. 监听 触摸事件, 拖动小球, 以及最终小球吸附动画

博主已经不记得这个图表是要干啥呢, 大部分参数都写死了…

二、贝赛尔曲线

不了解的可以自行百度.
简单解释: 三角形的两条边 上面分别有两个点. 同时从一端向另一端匀速运动. 两个点的连线 就是圆弧的切线

还是看图形象, 随便从网上搬了一个…!!
在这里插入图片描述

1.曲线绘制思路

小球左右 及 小球顶部 各一个贝赛尔曲线(共3个). 至于点位, 博主是估量选择的.
怎奈博主画图水平为0, 用电脑自带的画图软件画了一下, 各位看官 将就一下哈 😂

在这里插入图片描述

三、上代码吧

注释都写在代码里了.

1.自定义View代码

class DraggableBallChartView(context: Context, attrs: AttributeSet?)
    : View(context, attrs), ValueAnimator.AnimatorUpdateListener {

    // **************** 画笔 ***************
    private val mPaintFill: Paint       // 画 文字, 背景, 小球
    private val mPaintStroke: Paint     // 画 线, 小球描边
    private var mPath: Path

    // **************** 小球相关尺寸 ***************
    private val ballRadius = 36f                // 小球的初始半径(拖动半径. 不拖动时更小);
    private val touchRadius = 42f               // 小球的触控半径 (适当增大触控范围)
    private val dragStart = 4f * ballRadius     // 小球允许拖动的左侧最小剩余宽度
    private var dragEnd = 0f                    // 小球右侧最小剩余宽度;  要减去Y轴标注的宽度
    private var ballX = 0f                      // 小球的X轴位置, 初始等于 dragStart+paddingStart

    private val curveKzd = 2.5f * ballRadius    // 贝塞尔曲线, 两侧控制点 与圆心的X轴距离
    private val curveJsd = 1.4f * ballRadius    // 贝塞尔曲线, 两侧结束点 某控制距离
    private var curveHighestY = 0f              // 贝塞尔曲线, 中段 最高控制点的高度;

    // **************** 刻度线, 折线 ***************
    private var mScalelines: FloatArray? = null     // Y轴刻度线. 灰色横线;
    private var mChartLines: FloatArray? = null     // 绿色折线
    private val pointRadius = 12f                   // 折线点半径
    private val lingHealth = 4f                     // 圆描边灰线的宽度
    private val halfLine = lingHealth / 2f          // 直线的宽度

    private var mLineSpace: Float = 0f              // Y轴刻度线间距
    private var mWidthSpace: Float = 0f             // X轴 折线点之间的距离
    private var mFirstWidth: Float = 0f             // X轴 首个折线点的位置
    private var mTextY: Float = 0f                  // X轴 标注的 字底位置

    // **************** 颜色参数 ***************
    private val colorGrayLine: Int = Color.parseColor("#EAECEE")    //灰线的颜色
    private val colorTextOn = Color.parseColor("#333333")           //选中的文字颜色
    private val colorTextOff = Color.parseColor("#818CA4")          //未选中的文字颜色
    private val colorPointOn = Color.parseColor("#24D49C")          //选中的点颜色
    private val colorPointOff = Color.parseColor("#91E9CD")         //未选中的点颜色


    // **************** 折线参数值, 横轴参数值 目前写死了 ***************
    private var texts = arrayOf("4月", "5月", "6月", "7月", "8月")
    private var values = arrayOf(175, 178, 186, 173, 167)
    private var valuesY: FloatArray? = null     // 每个折线点的具体高度;
    private var current = 0                     // 当前选中项索引(目前只有折线点和文字高色)


    // **************** 拖动小球的参数 ***************
    private var downX = 0f          // 拖动的起始点击位置;
    private var isDrag = false      // 是否在拖动过程中
    private  var lastX = 0f         // 点击小球时, 小球的位置记录
    private var mAnimator: ValueAnimator? = null


    init {
        // 创建画笔
        mPaintFill = Paint(Paint.ANTI_ALIAS_FLAG).also {
            it.style = Paint.Style.FILL
            it.textSize = 36f
        }

        mPaintStroke = Paint(Paint.ANTI_ALIAS_FLAG)
        mPaintStroke.style = Paint.Style.STROKE

        mPath = Path()
        dragEnd = dragStart + getTextWidth(mPaintFill, "190")

        // 小球初始位置
        ballX = dragStart + paddingStart
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        calculateOfPosition(measuredWidth, measuredHeight)
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        calculateOfPosition(w, h)
    }

    /**
     * 计算位置; 1.横线位置; 2.横线坐标数组 3.X轴标注位置(横纵); 4.Y轴标注; 5.点位; 6.折线数组
     */
    private fun calculateOfPosition(w: Int, h: Int){
        //横线位置: 假设底部预留 6倍小球半径 的空间(标注 及 小球); 刻度线固定为4条;
        mLineSpace = (h - ballRadius * 6f) / 4f
        val end = (w - paddingEnd).toFloat()
        mScalelines = floatArrayOf(
            paddingStart.toFloat(), mLineSpace, end, mLineSpace,
            paddingStart.toFloat(), mLineSpace * 2f, end, mLineSpace * 2f,
            paddingStart.toFloat(), mLineSpace * 3f, end, mLineSpace * 3f,
            paddingStart.toFloat(), mLineSpace * 4f, end, mLineSpace * 4f
        )

        // X轴标注 高度位置
        mTextY = height - ballRadius * 4

        // X轴 首点位置, 及点间距;
        mFirstWidth = paddingStart + dragStart
        mWidthSpace = (w - paddingStart - paddingEnd - dragStart - dragEnd) / (texts.size - 1)


        // 维护折线点 Y坐标;  这里最大值200f 是写死的;
        valuesY = FloatArray(texts.size)
        for (i in texts.indices){
            valuesY!![i] = (200f - values[i]) / 10f * mLineSpace
        }

        // 维护折线数组
        mChartLines = FloatArray(texts.size * 4)
        for (i in 0..texts.size-2){
            if(i < texts.size - 1){
                mChartLines!![i*4] = mFirstWidth + mWidthSpace * i
                mChartLines!![i*4 + 1] = valuesY!![i]
                mChartLines!![i*4 + 2] = mFirstWidth + mWidthSpace * (i + 1)
                mChartLines!![i*4 + 3] = valuesY!![i + 1]
            }
        }

        // 贝塞尔曲线 中段 最高控制点高度
        curveHighestY = height - 3f * ballRadius
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        canvas ?: return

        // 绘制背景, 以及小球
        drawBgAndBall(canvas)

        // 绘制 Y轴刻度线, 以及刻度值
        drawScaleLineAndMark(canvas)

        // 绘制折线, 折线点, X轴标注
        drawBrokenLineAndMark(canvas)
    }

    /**
     * 绘制背景, 小球
     */
    private fun drawBgAndBall(canvas: Canvas){
        // 白色背景, 填充模式
        mPaintFill.color = Color.WHITE
        mPath.reset()

        // 小球不拖动时, 小一点;
        val realRadius = if(isDrag){
            ballRadius
        } else {
            ballRadius - 4f
        }

        // 移动到曲线起点. 然后 三段贝塞尔曲线
        val heightJsd = height - curveJsd
        mPath.moveTo(ballX - dragStart, height.toFloat())
        mPath.quadTo(ballX - curveKzd, height.toFloat(), ballX - curveJsd, heightJsd)
        mPath.quadTo(ballX, curveHighestY, ballX + curveJsd, heightJsd)
        mPath.quadTo(ballX + curveKzd, height.toFloat(), ballX + dragStart, height.toFloat())

        mPath.lineTo(width.toFloat(), height.toFloat())
        mPath.lineTo(width.toFloat(), 0f)
        mPath.lineTo(0f, 0f)
        mPath.lineTo(0f, height.toFloat())
        mPath.close()

        // 白色小球
        val centerY = height - realRadius - halfLine
        mPath.addCircle(ballX, centerY, realRadius - halfLine, Path.Direction.CW)
        canvas.drawPath(mPath, mPaintFill)

        // 绘制小球描边
        mPaintStroke.strokeWidth = lingHealth
        mPaintStroke.color = colorGrayLine
        canvas.drawCircle(ballX, centerY, realRadius, mPaintStroke)
    }

    /**
     * 绘制 Y轴刻度线, 以及刻度值
     */
    private fun drawScaleLineAndMark(canvas: Canvas){
        //绘制横线; Y轴刻度线
        mScalelines?.let {
            mPaintStroke.strokeWidth = halfLine
            canvas.drawLines(it, mPaintStroke)
        }

        //绘制Y轴标注
        mPaintFill.textAlign = Paint.Align.RIGHT
        mPaintFill.color = colorTextOff
        val end = (width - paddingEnd).toFloat()
        canvas.drawText("190", end, mLineSpace - 8f, mPaintFill)
        canvas.drawText("180", end, mLineSpace * 2f - 8f, mPaintFill)
        canvas.drawText("170", end, mLineSpace * 3f - 8f, mPaintFill)
        canvas.drawText("0", end, mLineSpace * 4f - 8f, mPaintFill)
    }

    /**
     * 绘制折线, 折线点, X轴标注
     */
    private fun drawBrokenLineAndMark(canvas: Canvas){
        // 绘制折线
        mChartLines?.let {
            mPaintStroke.color = colorPointOn
            canvas.drawLines(it, mPaintStroke)
        }

        mPaintFill.textAlign = Paint.Align.CENTER
        for(i in texts.indices){
            // 折线点, X轴标注
            val x = mFirstWidth + mWidthSpace * i
            mPaintFill.color = if(current == i) colorPointOn else colorPointOff
            canvas.drawCircle(x, valuesY!![i], pointRadius, mPaintFill)
            mPaintFill.color = if(current == i) colorTextOn else colorTextOff
            canvas.drawText(texts[i], x, mTextY, mPaintFill)
        }
    }

    /**
     * 处理事件分发
     */
    override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                downX = event.x // 记录x坐标
                val downY = event.y // 记录y坐标

                // 判断是否点到了小球
                if (downX <= ballX + touchRadius
                    && downX >= ballX - touchRadius
                    && downY >= height - touchRadius * 2
                    && downY <= height) {

                    cancleAinimator()
                    isDrag = true
                    lastX = ballX
                }
            }
            MotionEvent.ACTION_MOVE -> if (isDrag) {
                val moveX = event.x - downX
                if (abs(moveX) > 5) { // 偏移量的绝对值大于 5 为 滑动事件
                    var nowCenter = lastX + moveX

                    // 超限控制
                    if (nowCenter < mFirstWidth)
                        nowCenter = mFirstWidth
                    if (nowCenter > width - dragEnd - paddingEnd)
                        nowCenter = width - dragEnd - paddingEnd

                    ballX = nowCenter
                    invalidate()
                }
            }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> if (isDrag) {
                isDrag = false
                var nowX = event.x

                // 超限控制
                if (nowX < mFirstWidth)
                    nowX = mFirstWidth
                if (nowX > width - dragEnd - paddingEnd)
                    nowX = width - dragEnd - paddingEnd

                // 计算当前索引
                current = ((nowX - mFirstWidth) / mWidthSpace + 0.5f).toInt()

                // 属性动画, 移动小球位置. 让其吸附在准确位置;
                val targetX = mFirstWidth + current * mWidthSpace
                startAnimator(nowX, targetX, nowX - targetX)
            }
        }
        return true
    }

    private fun startAnimator(nowX: Float, toX: Float, dis: Float) {
        cancleAinimator()

        // 时间跟距离为线性关系
        val time = abs(dis).toLong()

        // 太近的话就不执行动画了;
        if (time < 10) {
            ballX = toX
            invalidate()
            return
        }

        mAnimator = ValueAnimator.ofFloat(nowX, toX).also {
            it.duration = time
            it.interpolator = AccelerateDecelerateInterpolator()
            it.addUpdateListener(this)
            it.start()
        }
    }

    override fun onAnimationUpdate(animation: ValueAnimator) {
        ballX = animation.animatedValue as Float
        invalidate()
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        cancleAinimator()
    }

    private fun cancleAinimator() {
        mAnimator?.cancel()
        mAnimator = null
    }

    /**
     * 获取文字宽度
     */
    fun getTextWidth(paint: Paint, str: String): Int {
        var iRet = 0
        if (str.isNotEmpty()) {
            val widths = FloatArray(str.length)
            paint.getTextWidths(str, widths)
            for (element in widths) {
                iRet += ceil(element).toInt()
            }
        }
        return iRet
    }
}

2.布局代码

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:background="@color/black">
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".test.customview.DraggableBallChartActivity">
        <com.example.kotlinmvpframe.test.customview.custom.DraggableBallChartView
            android:id="@+id/dbc_ball"
            android:layout_width="match_parent"
            android:layout_height="240dp"
            android:layout_marginTop="20dp"
            android:paddingHorizontal="12dp"
            app:layout_constraintTop_toTopOf="parent"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

总结

没有总结

上一篇: 记一次自定义View:滑动标尺
下一篇: 酝酿中…

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值