阅读 2168

Android自定义-手势滑动缩放渐变填充曲线折线图表

  • 越是没有本领的就越加自命不凡。

自定义

自定义老生常谈的技能了,年底没事干,希望这篇文章能够开启你的自定义大门。API并不难,难在开始,难在想法设计。各种案例逐步深入,直到画出你能想到的。

一、基础操作

  • 了解基本的坐标系,画笔,画布等操作。

1.新建类

LHC_Line_View继承View,重写onDraw方法,最简单代码架子。

class LHC_Line_View  @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0):View(context, attrs, defStyle) {
    @SuppressLint("DrawAllocation")
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
    }
}
复制代码

2.坐标系

  • View默认坐标系是自身的左上角。如下所示:我们在坐标(x,y)为(0,0)地方绘制圆圈。

首先我们的布局设置在屏幕中间xml如下:

<com.zj.utils.utils.view.LHC_Line_View
            android:background="@color/black"
            android:layout_centerInParent="true"
            android:layout_width="@dimen/dp_300"
            android:layout_height="@dimen/dp_200"/>
复制代码

此时我们看到屏幕中间有一块黑色的View。我们在onDraw方法里面进行绘制一个白色圆圈:


class LHC_Line_View @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) : View(context, attrs, defStyle) {
    @SuppressLint("DrawAllocation")
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val back_paint=Paint()
        back_paint.style= Paint.Style.FILL
        back_paint.color=Color.WHITE
        back_paint.strokeWidth=10f
        canvas.drawCircle(0f,0f,25f,back_paint)
    }
}
复制代码

因为圆心在左上角,而自身的宽度限制导致绘制出上图扇形。这里只要认识坐标系原点在左上角即可。

接下来转换坐标系为熟悉的坐标系,如下图三x轴右为正方向,y轴上为正方向。圆点为左下方,这里我们涉及到坐标系(canvas)的变换。

     override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val back_paint=Paint()
        back_paint.style= Paint.Style.FILL
        back_paint.color=Color.WHITE
        back_paint.strokeWidth=10f
        canvas.save()
        //竟变化坐标。y轴向上为正
        canvas.scale(1f,-1f)
        //平移坐标系到左下角
        canvas.translate(0f, -(measuredHeight.toFloat()))
        canvas.drawCircle(0f,0f,125f,back_paint)
    }
复制代码

坐标系的变换如下:

绘制过程和坐标系变化对比:

到这里已经成为我们熟悉的坐标系方向:

接下来我们绘制网格便于我们绘制过程更加直观

- 设置我们每格子宽高都为40像素。y轴格子个数 =measuredHeight/DensityUtils.px2dp(context,40f)
- 已知高度,我们每格子40像素。x轴格子个数  =measuredWidth/DensityUtils.px2dp(context,40f)
复制代码

不妨我们先画两条线段

 override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val back_paint=Paint()
        val grid_paint=Paint()

        back_paint.style= Paint.Style.FILL
        back_paint.color=Color.WHITE
        back_paint.strokeWidth=10f


        grid_paint.style= Paint.Style.STROKE
        grid_paint.color=Color.WHITE
        grid_paint.strokeWidth=2f
        canvas.save()
        //竟变化坐标。y轴向上为正
        canvas.scale(1f,-1f)
        //平移坐标系到左下角
        canvas.translate(0f, -(measuredHeight.toFloat()))
       
        //平行y轴的线段
        val pathY=Path()
        pathY.moveTo(DensityUtils.px2dp(context,40f),0f)
        pathY.lineTo(DensityUtils.px2dp(context,40f), measuredWidth.toFloat())
        canvas.drawPath(pathY,grid_paint)
        
        //平行x轴的线段
        val pathX=Path()
        pathX.moveTo(0f,DensityUtils.px2dp(context,40f))
        pathX.lineTo(measuredWidth.toFloat(),DensityUtils.px2dp(context,40f))
        canvas.drawPath(pathX,grid_paint)

    }
复制代码

上面我们已经绘制出了两条线段。只需要计算出每条线段位置并绘制或者平移画布进行绘制。

   override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val back_paint=Paint()
        val grid_paint=Paint()

        back_paint.style= Paint.Style.FILL
        back_paint.color=Color.WHITE
        back_paint.strokeWidth=10f


        grid_paint.style= Paint.Style.STROKE
        grid_paint.color=Color.WHITE
        grid_paint.strokeWidth=2f
        canvas.save()
        //竟变化坐标。y轴向上为正
        canvas.scale(1f,-1f)
        //平移坐标系到左下角
        canvas.translate(0f, -(measuredHeight.toFloat()))
     
       
       

        //平行x轴的线段
        val pathX=Path()
        pathX.moveTo(0f,DensityUtils.px2dp(context,40f))
        pathX.lineTo(measuredWidth.toFloat(),DensityUtils.px2dp(context,40f))
        canvas.drawPath(pathX,grid_paint)

        //x轴个数
        val countX=measuredWidth/DensityUtils.px2dp(context,40f)
        //y轴个数
        val countY=measuredHeight/DensityUtils.px2dp(context,40f)
        //平行y轴的线段
        for (index in 0 until countY.toInt()){
            val pathX=Path()
            pathX.moveTo(0f,DensityUtils.px2dp(context,40f)*(index+1))
            pathX.lineTo(measuredWidth.toFloat(),DensityUtils.px2dp(context,40f)*(index+1))
            canvas.drawPath(pathX,grid_paint)
        }

    }

复制代码

当然画布的变换可以更加方便的操作和实现效果:

for (index in 0 until countY.toInt()){
             //每画一条线就将画布平移40像素的单位进行下一个绘制。
            canvas.translate(0f,DensityUtils.px2dp(context,40f))
            canvas.drawPath(pathX,grid_paint)

}


复制代码

最后我们画出所有的x,y方向的线段即可:

class LHC_Line_View @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) : View(context, attrs, defStyle) {
    val grid_wh=DensityUtils.px2dp(context, 60f)
    @SuppressLint("DrawAllocation")
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val back_paint=Paint()
        val grid_paint=Paint()

        back_paint.style= Paint.Style.FILL
        back_paint.color=Color.WHITE
        back_paint.strokeWidth=10f

        grid_paint.style= Paint.Style.STROKE
        grid_paint.color=Color.argb(66,255,255,255)
        grid_paint.strokeWidth=2f


        canvas.save()
        //变换坐标系为我们常见的
        changeCanvaXY(canvas)
        //画网格
        drawGridView(canvas, grid_paint)

    }
    //变换为熟悉的坐标系
    private fun changeCanvaXY(canvas: Canvas) {
        //竟变化坐标。y轴向上为正
        canvas.scale(1f, -1f)
        //平移坐标系到左下角
        canvas.translate(0f, -(measuredHeight.toFloat()))
    }

    //绘制网格
    private fun drawGridView(canvas: Canvas, grid_paint: Paint) {
        //平行y轴的线段
        val pathY = Path()
        pathY.moveTo(grid_wh, 0f)
        pathY.lineTo(grid_wh, measuredHeight.toFloat())
        canvas.drawPath(pathY, grid_paint)

        //平行x轴的线段
        val pathX = Path()
        pathX.moveTo(0f, grid_wh)
        pathX.lineTo(measuredWidth.toFloat(), grid_wh)
        canvas.drawPath(pathX, grid_paint)

        //x轴个数
        val countX = measuredWidth /grid_wh
        //y轴个数
        val countY = measuredHeight /grid_wh
        canvas.save()

        for (index in 0 until countY.toInt()) {
            canvas.translate(0f,grid_wh)
            canvas.drawPath(pathX, grid_paint)

        }

        canvas.restore()
        for (index in 0 until countX.toInt()) {
            canvas.translate(grid_wh, 0f)
            canvas.drawPath(pathY, grid_paint)
        }
    }
}

复制代码

3.简单的折线图

  • 多练习Path和canvas的一些Api。之前写过绘制相关文章可以去看看,绘制简单的折线图开始。
1.学会Path相关api绘制折线图
  将下一个轮廓的起点设置为点(x,y)
  public void moveTo(float x, float y) 
  设置相对于上一个轮廓的最后一个点为相对位置下一个轮廓的起点。如果没有先前的轮廓就于moveTo ()相同。
  public void rMoveTo(float dx, float dy)
  与lineTo相同,但是将坐标视为相对于此轮廓上的最后一点。如果没有先前的点,就同moveTo(0,0)
  public void rLineTo(float dx, float dy)
  移动当前的路径
  public void offset(float dx, float dy)
  设置最后一个点
  public void setLastPoint(float dx, float dy)
  通过矩阵变换此路径中的点
  public void transform(@NonNull Matrix matrix)


  /**给当前的路径添加形状路径**/
  public void addCircle(float x, float y, float radius, @NonNull Direction dir) 
  给当前路径添加扇形路径等
  public void addArc(@NonNull RectF oval, float startAngle, float sweepAngle)
  给当前路径添加圆角矩形
  public void addRoundRect(@NonNull RectF rect, float rx, float ry, @NonNull Direction dir)
  给当前路径添加一个椭圆路径
  public void addOval(@NonNull RectF oval, @NonNull Direction dir)


  /**添加曲线相关**/
  从最后一个点开始添加二次贝塞尔曲线,逼近控制点(x1,y1),并在(x2,y2)处结束。如果没有为此轮廓调用moveTo(),则第一个点将自动设置为(0,0)
  public void quadTo(float x1, float y1, float x2, float y2) 
  从最后一点添加一个三次方贝塞尔曲线,逼近控制点*(x1,y1)和(x2,y2),并在(x3,y3)处结束。如果尚未对该轮廓进行moveTo()调用,则第一个点将自动设置为(0,0)。
  public void cubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
  public void rCubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
  将指定的弧形作为新轮廓附加到路径。如果路径的起点与路径的当前最后一个点不同,则将添加自动lineTo()以将当前轮廓连接到弧的起点。但是,如果路径为空,则使用圆弧的第一点调用moveTo()
  public void arcTo(@NonNull RectF oval, float startAngle, float sweepAngle,boolean forceMoveTo)
  自动关闭路径轮廓,如果最后一个点和第一个点不重合就会自动连接第一个点进行关闭
  public void close()


2.灵活的进行设置各种特效【渐变,动画,色彩等】
  paint相关的API...看之前链接中写过的文章
  
复制代码

我们来进行绘制折线且每个折线顶点都有一个圆圈。

  • 首先我们需要一个集合存储每个坐标点。
  • 然后进行遍历连接各个点同时绘制定点圆圈。

新建类存储每个点坐标

data class ViewPoint @JvmOverloads constructor(var x:Float,var y:Float)
复制代码

初始化集合画线

 //绘制折线图
    private fun drawLine(pointList: java.util.ArrayList<ViewPoint>, canvas: Canvas) {
       val linePaint=Paint()
       val path=Path()
       linePaint.style= Paint.Style.STROKE
       linePaint.color=Color.argb(255,34,192,255)
       linePaint.strokeWidth=10f
        //连线
        for (index in 0 until pointList.size){
            path.lineTo(pointList[index].x,pointList[index].y)
        }
        canvas.drawPath(path,linePaint)
    }

复制代码

我们进行绘制每个顶点的圆

 //绘制折线图
    private fun drawLine(pointList: java.util.ArrayList<ViewPoint>, canvas: Canvas) {
        val linePaint = Paint()
        val path = Path()
        linePaint.style = Paint.Style.STROKE
        linePaint.color = Color.argb(255, 34, 192, 255)
        linePaint.strokeWidth = 10f


        val circle_paint = Paint()
        circle_paint.strokeWidth = 10f
        circle_paint.style = Paint.Style.FILL
        circle_paint.color = Color.argb(255, 34, 192, 255)
        //连线
        for (index in 0 until pointList.size) {
            path.lineTo(pointList[index].x, pointList[index].y)
        }
        canvas.drawPath(path, linePaint)
        //画定点圆圈
        for (index in 0 until pointList.size) {
            canvas.drawCircle(pointList[index].x, pointList[index].y,16f,circle_paint)
        }


    }

复制代码

渐变的色彩填充都很实用,可能显得高大上接下来我们进行折线图一下部分-进行渐变填充。

  • 将折线形成一个闭合的区域,通过画笔设置Style.Fill然后设置shader即可变成你想要的渐变填充。
    //绘制折线图
    private fun drawLine(pointList: java.util.ArrayList<ViewPoint>, canvas: Canvas) {
        val linePaint = Paint()
        val path = Path()
        linePaint.style = Paint.Style.STROKE
        linePaint.color = Color.argb(255, 34, 192, 255)
        linePaint.strokeWidth = 10f


        val circle_paint = Paint()
        circle_paint.strokeWidth = 10f
        circle_paint.style = Paint.Style.FILL
        circle_paint.color = Color.argb(255, 34, 192, 255)

        //连线
        for (index in 0 until pointList.size) {
            path.lineTo(pointList[index].x, pointList[index].y)
        }
        canvas.drawPath(path, linePaint)

        //渐变色菜的填充
        //连线
        for (index in 0 until pointList.size) {
            path.lineTo(pointList[index].x, pointList[index].y)
        }
        val endIndex=pointList.size-1
        path.lineTo(pointList[endIndex].x, 0f)
        path.close()
        linePaint.style= Paint.Style.FILL
        linePaint.shader=getShader()
        canvas.drawPath(path, linePaint)


        //画定点圆圈
        for (index in 0 until pointList.size) {
            canvas.drawCircle(pointList[index].x, pointList[index].y, 16f, circle_paint)
        }

    }

    private fun getShader(): Shader {
        val shadeColors = intArrayOf(Color.argb(255, 250, 49, 33), Color.argb(165, 234, 115, 9), Color.argb(200, 32, 208, 88))
        return  LinearGradient((measuredWidth/2).toFloat(), measuredHeight.toFloat(), (measuredWidth/2).toFloat(), 0f, shadeColors, null, Shader.TileMode.CLAMP)
    }

复制代码

网格和黑色去掉之后。

class LHC_Line_View @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) : View(context, attrs, defStyle) {
    val grid_wh = DensityUtils.px2dp(context, 60f)
    var pointList: ArrayList<ViewPoint> = ArrayList()

    init {
        pointList.add(ViewPoint(0f, 10f))
        pointList.add(ViewPoint(120f, 140f))
        pointList.add(ViewPoint(380f, 200f))
        pointList.add(ViewPoint(420f, 400f))
        pointList.add(ViewPoint(600f, 340f))
        pointList.add(ViewPoint(600f, 340f))
        pointList.add(ViewPoint(700f, 340f))
        pointList.add(ViewPoint(800f, 400f))
        pointList.add(ViewPoint(900f, 340f))
        pointList.add(ViewPoint(1000f, 340f))



    }

    @SuppressLint("DrawAllocation")
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val back_paint = Paint()
        val grid_paint = Paint()

        back_paint.style = Paint.Style.FILL
        back_paint.color = Color.WHITE
        back_paint.strokeWidth = 10f

        grid_paint.style = Paint.Style.STROKE
        grid_paint.color = Color.argb(66, 111, 111, 111)
        grid_paint.strokeWidth = 2f
        canvas.save()

        //1.变换坐标系为我们常见的
        changeCanvaXY(canvas)

        //2.画网格
        //drawGridView(canvas, grid_paint)

        //3.画线
        drawLine(pointList, canvas)

        val text_paint=Paint()
        text_paint.color=Color.RED
        text_paint.textSize=24f
        text_paint.strokeWidth=10f


        //4.绘制文字
        val endIndex=pointList.size-1
        canvas.save()
        canvas.translate(pointList[endIndex].x,pointList[endIndex].y)
        canvas.scale(1f,-1f)
        canvas.rotate((10).toFloat())
        canvas.drawText("100万",0,"100万".length,-20f,-40f,text_paint)

    }

    //绘制折线图
    private fun drawLine(pointList: java.util.ArrayList<ViewPoint>, canvas: Canvas) {
        val linePaint = Paint()
        val path = Path()
        linePaint.style = Paint.Style.STROKE
        linePaint.color = Color.argb(255, 34, 192, 255)
        linePaint.strokeWidth = 10f


        val circle_paint = Paint()
        circle_paint.strokeWidth = 10f
        circle_paint.style = Paint.Style.FILL
        circle_paint.color = Color.argb(255, 34, 192, 255)





        //连线
        for (index in 0 until pointList.size) {
            path.lineTo(pointList[index].x, pointList[index].y)
        }
        canvas.drawPath(path, linePaint)

        //渐变色菜的填充
        //连线
        for (index in 0 until pointList.size) {
            path.lineTo(pointList[index].x, pointList[index].y)
        }
        val endIndex=pointList.size-1
        path.lineTo(pointList[endIndex].x, 0f)
        path.close()
        linePaint.style= Paint.Style.FILL
        linePaint.shader=getShader()
        canvas.drawPath(path, linePaint)


        //画定点圆圈
        for (index in 0 until pointList.size) {
            canvas.drawCircle(pointList[index].x, pointList[index].y, 16f, circle_paint)
        }




    }

    private fun getShader(): Shader {
        val shadeColors = intArrayOf(Color.argb(255, 250, 49, 33), Color.argb(165, 250, 49, 33), Color.argb(0, 250, 49, 33))
        return  LinearGradient((measuredWidth/2).toFloat(), measuredHeight.toFloat(), (measuredWidth/2).toFloat(), 0f, shadeColors, null, Shader.TileMode.CLAMP)
    }


    //变换为熟悉的坐标系
    private fun changeCanvaXY(canvas: Canvas) {
        //竟变化坐标。y轴向上为正
        canvas.scale(1f, -1f)
        //平移坐标系到左下角
        canvas.translate(0f, -(measuredHeight.toFloat()))
    }

    //绘制网格
    private fun drawGridView(canvas: Canvas, grid_paint: Paint) {
        //平行y轴的线段
        val pathY = Path()
        pathY.moveTo(grid_wh, 0f)
        pathY.lineTo(grid_wh, measuredHeight.toFloat())
        canvas.drawPath(pathY, grid_paint)

        //平行x轴的线段
        val pathX = Path()
        pathX.moveTo(0f, grid_wh)
        pathX.lineTo(measuredWidth.toFloat(), grid_wh)
        canvas.drawPath(pathX, grid_paint)

        //x轴个数
        val countX = measuredWidth / grid_wh
        //y轴个数
        val countY = measuredHeight / grid_wh
        canvas.save()

        for (index in 0 until countY.toInt()) {
            canvas.translate(0f, grid_wh)
            canvas.drawPath(pathX, grid_paint)

        }

        canvas.restore()
        canvas.save()
        for (index in 0 until countX.toInt()) {
            canvas.translate(grid_wh, 0f)
            canvas.drawPath(pathY, grid_paint)
        }
        canvas.restore()

    }

}

data class ViewPoint @JvmOverloads constructor(var x: Float, var y: Float)

复制代码

二.修饰折线图

  • 上面虽然我们绘制出了折线图但是要达到使用还是远远不够的,折线图是提供用户信息的一个视图,那必不可少的就是每个定点和x以及y轴代表的含义等。

1、折线图添加文字修饰。

自定义View中添加文字提供了丰富的API。我们接下来进行绘制顶点的文本。

顶图是Flutter进行绘制的。当然原生android也可以了。Swift在自定义绘制方面显的尴尬,希望苹果有所改进期待。

我们来看一下所提供的API,通过x,y来定位text的位置:

 /**
     * Draw the text, with origin at (x,y), using the specified paint. The origin is interpreted
     * based on the Align setting in the paint.
     *
     * @param text The text to be drawn
     * @param start The index of the first character in text to draw
     * @param end (end - 1) is the index of the last character in text to draw
     * @param x The x-coordinate of the origin of the text being drawn
     * @param y The y-coordinate of the baseline of the text being drawn
     * @param paint The paint used for the text (e.g. color, size, style)
     */
    public void drawText(@NonNull String text, int start, int end, float x, float y,
            @NonNull Paint paint) {

复制代码

接下来我们利用定点进行绘制文字:

  • 已知定点坐标(120f, 140f)
  • 为了画笔不被污染,我们重新定义画笔。

//4.重写定义画笔
        val text_paint=Paint()
        text_paint.color=Color.RED
        text_paint.textSize=44f
        text_paint.strokeWidth=10f

        //(120f, 140f)
        canvas.drawText("140万",0,"140万".length,120f,140f,text_paint)
复制代码

我们可以从图中看到字体在线下面,字体被反转。字体在线下面我们可以利用坐标y增大即可。被反转,并没有API提供文字旋转,而我们可以通过旋转画布。而画布的旋转坐标点是左下角因为旋转点到旋转目标不在同一点导致不好控制旋转。我们要很好的利用canvas.save和canvas.restore可以让我们的问题很快速方便的解决。

  • canvas.save将当前坐标系的快照存储在堆栈内。压栈方式一层层
  • canvas.translate,canvas.scale,canvas.rotate等进行坐标系变换。
  • canvas.restore返回到想要的坐标系状态。

代码如下

   //4.绘制文字
   //1.左下角(0,0)的坐标系状态快照保存起来到堆栈
    canvas.save()
   //2.移动坐标系到屏幕(x,y)位置这时候屏幕(x,y)位置的坐标系作为坐标圆点(0,0)
    canvas.translate(pointList[1].x,pointList[1].y)
   //3变换坐标,坐标系右下角为正方向
    canvas.scale(1f,-1f)
   //4.将坐标系顺时针旋转10度
    canvas.rotate((10).toFloat())
   //5.在坐标系(0,0)处绘制文字
    canvas.drawText("100万",0,"100万".length,0f,0f,text_paint)
   //6.堆栈最顶层也就是最近save的一次快照恢复坐标系到存储时候的状态。删除堆栈里面的快照。 
    canvas.restore()

复制代码

绘制结果:

我们坐标系在(6)canvas.restore之前圆点在图100万这里。这里我们看到重合到渐变上面所以我们去向上平移,我们需要清除当前我们的坐标系是如下:

因为坐标系右下角为正方向。测试我们想将字体向上移动。那么我们去减小y即可:

   //5.在坐标系(0,0)处绘制文字
   //5.1 这里-40f在y轴负方向
    canvas.drawText("100万",0,"100万".length,0f,40f,text_paint)

复制代码

到这里我想你一定会利用canvas的变换和状态存储做很多有高效有趣的绘制。将每个字体搞个颜色背景,这里我们可以进行单独的绘制字体之前-绘制文字想要的各种背景。

        //4.重写定义画笔
        val text_paint = Paint()
        text_paint.color = Color.RED
        text_paint.textSize = 44f
        text_paint.strokeWidth = 10f

   
        val textBackPaint= getTextBackgroudPaint()

        //4.绘制文字
        canvas.save()
        //平移一步到位。往上平移一点。比定点高。避免重复
        canvas.translate(pointList[1].x, pointList[1].y+ getTextHeight(textBackPaint))
        //变换坐标
        canvas.scale(1f, -1f)
        canvas.rotate((10).toFloat())

        //绘制背景
        canvas.drawRoundRect(0f, -getTextHeight(textBackPaint),getTextWidth(textBackPaint,"100万"), getTextHeight(textBackPaint)/2,10f,10f, getTextBackgroudPaint())
        //绘制文字
        canvas.drawText("100万", 0, "100万".length, 0f, 0f, text_paint)
        canvas.restore()
    }

    private fun getTextHeight(textBackPaint: Paint): Float {
        val fontMetrics: Paint.FontMetrics = textBackPaint.fontMetrics
        val height1 = fontMetrics.descent - fontMetrics.ascent + fontMetrics.leading
        // height2测得的高度可能稍微比height1高一些
        // height2测得的高度可能稍微比height1高一些
        val height2 = fontMetrics.bottom - fontMetrics.top + fontMetrics.leading
        return height2
    }

    private fun getTextWidth(textBackPaint: Paint,textStr:String): Float {
        val paint = Paint()
         // 设置字体大小
         // 设置字体大小
        paint.textSize = 44f
        // 文字的宽度
        // 文字的宽度
        val strWidth = paint.measureText(textStr)
        return strWidth
    }

 

复制代码

接下来我们进行绘制所有的文字。


    private fun drawText(canvas: Canvas) {
        //4.重写定义画笔

        for (index in 0 until titleList.size){
            val text_paint = Paint()
            text_paint.color = Color.WHITE
            text_paint.textSize = 22f
            text_paint.strokeWidth = 10f
            
            val textBackPaint= getTextBackgroudPaint()

            //4.绘制文字之前保存在堆栈将坐标系。
            canvas.save()
            //平移一步到位。往上平移一点。比定点高。避免重复
            canvas.translate(pointList[index].x, pointList[index].y+ getTextHeight(textBackPaint))
            //变换坐标
            canvas.scale(1f, -1f)
            canvas.rotate((10).toFloat())

            //绘制背景
            canvas.drawRoundRect(0f, -getTextHeight(textBackPaint),getTextWidth(textBackPaint,titleList[index]), getTextHeight(textBackPaint)/2,10f,10f, getTextBackgroudPaint())
            //绘制文字
            canvas.drawText(titleList[index], 0,titleList[index].length, 0f, 0f, text_paint)
            //恢复坐标系
            canvas.restore()
        }


    }
复制代码

当然对于x轴y轴的绘制我们也不可缺少吧

    private fun drawXAndY(canvas: Canvas) {
        val x_paint = Paint()
        x_paint.style = Paint.Style.STROKE
        x_paint.color = Color.WHITE
        x_paint.strokeWidth = 10f
        x_paint.shader=getShaderXY(true)

        val y_paint = Paint()
        y_paint.style = Paint.Style.STROKE
        y_paint.color = Color.WHITE
        y_paint.strokeWidth = 10f
        y_paint.shader=getShaderXY(false)
        val path=Path()
        path.moveTo(0f,0f)
        path.lineTo(0f,measuredHeight+10f)
        //Y轴的箭头绘制
        val verticlePath=Path()
        verticlePath.moveTo(-20f,measuredHeight-60f)
        verticlePath.lineTo(0f,measuredHeight-40f)
        verticlePath.lineTo(20f,measuredHeight-60f)
        path.addPath(verticlePath)

        //画y轴
        canvas.drawPath(path,y_paint)

        val pathx=Path()
        pathx.moveTo(0f,0f)
        pathx.lineTo(measuredWidth-20f,0f)

        val horizontalPath=Path()
        horizontalPath.moveTo(measuredWidth-60f,20f)
        horizontalPath.lineTo(measuredWidth-40f,0f)
        horizontalPath.lineTo(measuredWidth-60f,-20f)
        pathx.addPath(horizontalPath)
        //画x轴
        canvas.drawPath(pathx,x_paint)
    }

复制代码

这样的折线图远远不够。x和y轴没有具体的刻度文字含义,我们慢慢来....一步步到这里我们应该可以随意操作了吧。接下来我们绘制x轴和y轴的刻度。首先我们将这个曲线设定为某公司进几月来每月的收益总额

x轴:月份
y轴:钱
复制代码

大概想想一下格式如下:

1.水平方向月我们分为6个月以来的。那么每一等分有measureWidth/6
2.利用canvas的变换.根据字体宽度和高度来制定圆点。进行绘制。
3.循环绘制字体。          
复制代码

代码:

   //绘制x轴的文字
    private fun drawXTitle(canvas: Canvas) {
        val xtitle_paint = Paint()
        xtitle_paint.color = Color.BLACK
        xtitle_paint.textSize = 24f
        xtitle_paint.strokeWidth = 10f
        val xwidth = (measuredWidth - marginXAndY) / 6
        xtitle_paint.shader=getShadersStarAndEnd(xwidth,0f)
        for(index in 0 until 6) {
            canvas.save()
            //平移坐标圆点到绘制文字点
            canvas.translate(xwidth*(index+1), -getTextHeight(xtitle_paint))
            //坐标系变换,目的让文字正常摆放。
            canvas.scale(1f, -1f)
            canvas.drawText("${index+2}月", 0, "${index+2}月".length, -getTextWidth(xtitle_paint, "${index+2}月") / 2, 0f, xtitle_paint)
            canvas.restore()
        }
    }

复制代码

同理我们绘制y轴

1.最大为1000万。
2.最大高度为 measuredHeight - marginXAndY。
3.我们200万为一等分。
复制代码

代码:

//绘制x轴的文字
    private fun drawYTitle(canvas: Canvas) {
        val ytitle_paint = Paint()
        ytitle_paint.color = Color.BLACK
        ytitle_paint.textSize = 24f
        ytitle_paint.strokeWidth = 10f
        val yHeight = (measuredHeight - marginXAndY) /5
        val yyHeight = (measuredHeight.toFloat()) /5

        for(index in 0 until 5) {
            //为了炫酷。哈哈
            ytitle_paint.shader=getShadersStarAndEnd(getTextWidth(ytitle_paint,"${yHeight*(index+1)}万")/2,0f)
            canvas.save()
            //平移坐标圆点到绘制文字点
            canvas.translate(-getTextWidth(ytitle_paint,"${yHeight*(index+1)}万")/2, yHeight*(index+1))
            //坐标系变换,目的让文字正常摆放。
            canvas.scale(1f, -1f)
            canvas.drawText("${yyHeight.toInt()*(index+1)}万", 0, "${yyHeight.toInt()*(index+1)}万".length, -getTextWidth(ytitle_paint, "${yyHeight.toInt()*(index+1)}万") / 2, -getTextHeight(ytitle_paint)/2, ytitle_paint)
            canvas.restore()
        }
    }

复制代码

再来看看效果

    //绘制x轴的文字
    private fun drawYTitle(canvas: Canvas) {
        val ytitle_paint = Paint()
        ytitle_paint.color = Color.BLACK
        ytitle_paint.textSize = 24f
        ytitle_paint.strokeWidth = 10f
        val yHeight = (measuredHeight - marginXAndY) /5
        val yyHeight = (measuredHeight.toFloat()) /5

        for(index in 0 until 5) {
            //为了炫酷。哈哈
            ytitle_paint.shader=getShadersStarAndEnd(getTextWidth(ytitle_paint,"${yHeight*(index+1)}万")/2,0f)
            canvas.save()
            //平移坐标圆点到绘制文字点
            canvas.translate(-getTextWidth(ytitle_paint,"${yHeight*(index+1)}万")/2, yHeight*(index+1))
            //坐标系变换,目的让文字正常摆放。
            canvas.scale(1f, -1f)
            canvas.drawText("${yyHeight.toInt()*(index+1)}万", 0, "${yyHeight.toInt()*(index+1)}万".length, -getTextWidth(ytitle_paint, "${yyHeight.toInt()*(index+1)}万") / 2, -getTextHeight(ytitle_paint)/2, ytitle_paint)
            canvas.restore()
        }
    }
    
    

复制代码

到这里我们应该可以随意多变的装饰了吧?

我们来给每一个字体添加一个圆角背景。同样的canvas变换操作不做坐标转换过程分析了

//字体宽度和高度的测量都很简单。通过字体所在的巨形来绘制底图背景:
  canvas.drawRoundRect(-getTextWidth(ytitle_paint,"${yyHeight.toInt()*(index+1)}万")/2, -(getTextHeight(ytitle_paint)+getTextHeight(ytitle_paint)/2), getTextWidth(ytitle_paint,"${yyHeight.toInt()*(index+1)}万")/2, 0f, 10f, 10f, getTextBackgroudPaint(40,5,0))
复制代码

同样Y轴旁边字体也可以最终代码:

class LHC_Line_View @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) : View(context, attrs, defStyle) {
    val grid_wh = DensityUtils.px2dp(context, 200f)
    var pointList: ArrayList<ViewPoint> = ArrayList()
    var titleList: ArrayList<String> = ArrayList()
    var marginXAndY=100f
    var arrowLRHeight=10f
    var arrowLength=marginXAndY+20

    init {
        pointList.add(ViewPoint(0f, 10f))
        pointList.add(ViewPoint(120f, 140f))
        pointList.add(ViewPoint(380f, 200f))
        pointList.add(ViewPoint(420f, 360f))
        pointList.add(ViewPoint(600f, 300f))
        pointList.add(ViewPoint(600f, 300f))
        pointList.add(ViewPoint(700f, 300f))
        pointList.add(ViewPoint(800f, 360f))
        pointList.add(ViewPoint(900f, 300f))
        pointList.add(ViewPoint(950f, 300f))


        titleList.add("10万")
        titleList.add("140万")
        titleList.add("200万")
        titleList.add("400万")
        titleList.add("340万")
        titleList.add("340万")
        titleList.add("340万")
        titleList.add("400万")
        titleList.add("340万")
        titleList.add("340万")


    }

    @SuppressLint("DrawAllocation")
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val back_paint = Paint()

        back_paint.style = Paint.Style.FILL
        back_paint.color = Color.WHITE
        back_paint.strokeWidth = 10f

        canvas.save()

        //1.变换坐标系为我们常见的
        changeCanvaXY(canvas)

        //2.画x轴和y轴
        drawXAndY(canvas)

        //3.画文字
        drawXTitle(canvas)
        //3.1绘制y轴旁边的文字
        drawYTitle(canvas)

        //2.画网格
        //drawGridView(canvas)

        //3.画线
        drawLine(pointList, canvas)


        //4.绘制文字
        drawText(canvas)
    }
    //绘制x轴的文字
    private fun drawXTitle(canvas: Canvas) {
        val xtitle_paint = Paint()
        xtitle_paint.color = Color.BLACK
        xtitle_paint.textSize = 24f
        xtitle_paint.strokeWidth = 10f
        val xwidth = (measuredWidth - marginXAndY) / 6
        xtitle_paint.shader=getShadersStarAndEnd(xwidth,0f)
        //骚的东西看Api试一试就明白了
        xtitle_paint.setShadowLayer(20f,20f,20f,Color.BLUE)
        for(index in 0 until 6) {
            canvas.save()
            //平移坐标圆点到绘制文字点
            canvas.translate(xwidth*(index+1), -(getTextHeight(xtitle_paint)))
            //坐标系变换,目的让文字正常摆放。
            canvas.scale(1f, -1f)

            //绘制背景骚一波
            //绘制背景
            canvas.drawRoundRect(-getTextWidth(xtitle_paint,"${index+2}月月")/2, -getTextHeight(xtitle_paint)/2, getTextWidth(xtitle_paint,"${index+2}月月")/2, getTextHeight(xtitle_paint) / 2, 10f, 10f, getTextBackgroudPaint(40,5,0))
            //绘制文字
            canvas.drawText("${index+2}月", 0, "${index+2}月".length, -getTextWidth(xtitle_paint, "${index+2}月") / 2, getTextHeight(xtitle_paint)/3, xtitle_paint)
            canvas.restore()
        }
    }
    //绘制x轴的文字
    private fun drawYTitle(canvas: Canvas) {
        val ytitle_paint = Paint()
        ytitle_paint.color = Color.BLACK
        ytitle_paint.textSize = 24f
        ytitle_paint.strokeWidth = 10f
        val yHeight = (measuredHeight - marginXAndY) /5
        val yyHeight = (measuredHeight.toFloat()) /5

        for(index in 0 until 5) {
            //为了炫酷。哈哈
            ytitle_paint.shader=getShadersStarAndEnd(getTextWidth(ytitle_paint,"${yHeight*(index+1)}万")/2,0f)
            ytitle_paint.setShadowLayer(6f,4f,-3f,Color.argb(100,255,100,100))
            canvas.save()
            //平移坐标圆点到绘制文字点
            canvas.translate(-getTextWidth(ytitle_paint,"${yHeight*(index+1)}万万")/2, yHeight*(index+1))
            //坐标系变换,目的让文字正常摆放。
            canvas.scale(1f, -1f)
            //绘制背景。为了调整位置。多加了个字符。就不具体计算了
            canvas.drawRoundRect(-getTextWidth(ytitle_paint,"${yyHeight.toInt()*(index+1)}万")/2, -(getTextHeight(ytitle_paint)+getTextHeight(ytitle_paint)/2), getTextWidth(ytitle_paint,"${yyHeight.toInt()*(index+1)}万")/2, 0f, 10f, 10f, getTextBackgroudPaint(40,5,0))
            canvas.drawText("${yyHeight.toInt()*(index+1)}万", 0, "${yyHeight.toInt()*(index+1)}万".length, -getTextWidth(ytitle_paint, "${yyHeight.toInt()*(index+1)}万") / 2, -getTextHeight(ytitle_paint)/2, ytitle_paint)
            canvas.restore()
        }
    }

    private fun drawXAndY(canvas: Canvas) {
        val x_paint = Paint()
        x_paint.style = Paint.Style.STROKE
        x_paint.color = Color.WHITE
        x_paint.strokeWidth = 3f
        x_paint.shader = getShaderXY(true)
        x_paint.setShadowLayer(6f,4f,-3f,Color.argb(100,255,100,100))


        val y_paint = Paint()
        y_paint.style = Paint.Style.STROKE
        y_paint.color = Color.WHITE
        y_paint.strokeWidth = 3f
        y_paint.shader = getShaderXY(false)
        y_paint.setShadowLayer(6f,-6f,-3f,Color.argb(100,100,255,100))

        val path = Path()
        path.moveTo(0f, 0f)
        path.lineTo(0f, measuredHeight + 10f)
        //Y轴的箭头绘制
        val verticlePath = Path()
        verticlePath.moveTo(-arrowLRHeight, measuredHeight - arrowLength)
        verticlePath.lineTo(0f, measuredHeight - marginXAndY)
        verticlePath.lineTo(arrowLRHeight, measuredHeight - arrowLength)
        path.addPath(verticlePath)

        //画y轴
        canvas.drawPath(path, y_paint)

        val pathx = Path()
        pathx.moveTo(0f, 0f)
        pathx.lineTo(measuredWidth - 20f, 0f)

        val horizontalPath = Path()
        horizontalPath.moveTo(measuredWidth - arrowLength, arrowLRHeight)
        horizontalPath.lineTo(measuredWidth - marginXAndY, 0f)
        horizontalPath.lineTo(measuredWidth - arrowLength, -arrowLRHeight)
        pathx.addPath(horizontalPath)
        //画x轴
        canvas.drawPath(pathx, x_paint)
    }

    private fun drawText(canvas: Canvas) {
        //4.重写定义画笔

        for (index in 0 until titleList.size) {
            val text_paint = Paint()
            text_paint.color = Color.WHITE
            text_paint.textSize = 22f
            text_paint.strokeWidth = 10f

            val textBackPaint = getTextBackgroudPaint(225,165,65)

            //4.绘制文字之前保存在堆栈将坐标系。
            canvas.save()
            //平移一步到位。往上平移一点。比定点高。避免重复
            canvas.translate(pointList[index].x, pointList[index].y + getTextHeight(textBackPaint))
            //变换坐标
            canvas.scale(1f, -1f)
            canvas.rotate((10).toFloat())

            //绘制背景
            canvas.drawRoundRect(0f, -getTextHeight(textBackPaint), getTextWidth(textBackPaint, titleList[index]), getTextHeight(textBackPaint) / 2, 10f, 10f, getTextBackgroudPaint(225,165,65))
            //绘制文字
            canvas.drawText(titleList[index], 0, titleList[index].length, 0f, 0f, text_paint)
            //恢复坐标系
            canvas.restore()
        }


    }

    private fun getTextHeight(textBackPaint: Paint): Float {
        val fontMetrics: Paint.FontMetrics = textBackPaint.fontMetrics
        val height1 = fontMetrics.descent - fontMetrics.ascent + fontMetrics.leading
        // height2测得的高度可能稍微比height1高一些
        // height2测得的高度可能稍微比height1高一些
        val height2 = fontMetrics.bottom - fontMetrics.top + fontMetrics.leading
        return height2
    }

    private fun getTextWidth(textBackPaint: Paint, textStr: String): Float {
        // 文字的宽度
        // 文字的宽度
        val strWidth = textBackPaint.measureText(textStr)
        return strWidth
    }

    private fun getTextBackgroudPaint(alpha:Int,centerAlpha:Int,endAlpha:Int): Paint {
        val paint = Paint()
        paint.textSize = 22f
        val random = Random()
        val R = random.getRandom(225)
        val G = random.getRandom(225)
        val B = random.getRandom(225)

        val R1 = random.getRandom(225)
        val G1 = random.getRandom(225)
        val B1 = random.getRandom(225)

        val R2 = random.getRandom(225)
        val G2 = random.getRandom(225)
        val B2 = random.getRandom(225)
        val shadeColors = intArrayOf(Color.argb(alpha, R, G, B), Color.argb(centerAlpha, R1, G1, B1), Color.argb(endAlpha, R2, G2, B2))
        val mShader = LinearGradient(0f, 0f, 44f, 44f, shadeColors, null, Shader.TileMode.CLAMP)
        paint.shader = mShader
        return paint
    }

    //绘制折线图
    private fun drawLine(pointList: java.util.ArrayList<ViewPoint>, canvas: Canvas) {
        val linePaint = Paint()
        val path = Path()
        linePaint.style = Paint.Style.STROKE
        linePaint.color = Color.argb(255, 225, 225, 255)
        linePaint.strokeWidth = 10f


        val circle_paint = Paint()
        circle_paint.strokeWidth = 10f
        circle_paint.style = Paint.Style.FILL


        //连线
        for (index in 0 until pointList.size) {
            path.lineTo(pointList[index].x, pointList[index].y)
        }
        canvas.drawPath(path, linePaint)


        //渐变色菜的填充
        for (index in 0 until pointList.size) {
            path.lineTo(pointList[index].x, pointList[index].y)
        }
        val endIndex = pointList.size - 1
        path.lineTo(pointList[endIndex].x, 0f)
        path.close()
        linePaint.style = Paint.Style.FILL
        linePaint.shader = getShader()
        linePaint.setShadowLayer(16f,6f,-6f,Color.argb(100,100,255,100))
        canvas.drawPath(path, linePaint)


        //画定点圆圈
        for (index in 0 until pointList.size) {
            circle_paint.shader = getShaders()
            canvas.drawCircle(pointList[index].x, pointList[index].y, 16f, circle_paint)
        }


    }

    private fun getShader(): Shader {
        val shadeColors = intArrayOf(Color.argb(255, 250, 49, 33), Color.argb(209, 250, 49, 33), Color.argb(0, 250, 49, 33))
        return LinearGradient((measuredWidth / 2).toFloat(), measuredHeight.toFloat(), (measuredWidth / 2).toFloat(), 0f, shadeColors, null, Shader.TileMode.CLAMP)
    }


    //变换为熟悉的坐标系
    private fun changeCanvaXY(canvas: Canvas) {
        //竟变化坐标。y轴向上为正
        canvas.scale(1f, -1f)
        //平移坐标系到左下角
        canvas.translate(marginXAndY, -(measuredHeight.toFloat()) + marginXAndY)
    }

    //绘制网格
    private fun drawGridView(canvas: Canvas) {
        val grid_paint = Paint()

        grid_paint.style = Paint.Style.STROKE
        grid_paint.color = Color.argb(45, 111, 111, 111)
        grid_paint.strokeWidth = 1f

        //平行y轴的线段
        val pathY = Path()
        pathY.moveTo(grid_wh, 0f)
        pathY.lineTo(grid_wh, measuredHeight.toFloat())
        canvas.drawPath(pathY, grid_paint)

        //平行x轴的线段
        val pathX = Path()
        pathX.moveTo(0f, grid_wh)
        pathX.lineTo(measuredWidth.toFloat(), grid_wh)
        canvas.drawPath(pathX, grid_paint)

        //x轴个数
        val countX = measuredWidth / grid_wh
        //y轴个数
        val countY = measuredHeight / grid_wh
        canvas.save()

        for (index in 0 until countY.toInt()) {
            canvas.translate(0f, grid_wh)
            canvas.drawPath(pathX, grid_paint)

        }

        canvas.restore()
        canvas.save()
        for (index in 0 until countX.toInt()) {
            canvas.translate(grid_wh, 0f)
            canvas.drawPath(pathY, grid_paint)
        }
        canvas.restore()

    }



}

data class ViewPoint @JvmOverloads constructor(var x: Float, var y: Float)


复制代码

到这里应该比较满意一些花里胡哨的玩家口味吧。

还能更炫么,当然能!我们继续..... 画布中来个常见的说明等标注框也可以随便来

    private fun drawRRect(canvas: Canvas) {
        //右上角画一个
        val rrPaint=Paint()
        rrPaint.color=Color.WHITE
        rrPaint.style= Paint.Style.FILL
        rrPaint.strokeWidth=1f
        rrPaint.setShadowLayer(5f,-5f,-5f,Color.argb(50,111,111,111))
        //背景
        canvas.drawRoundRect(measuredWidth-400f,measuredHeight-150f,measuredWidth-180f,measuredHeight-250f,5f,5f,rrPaint)
        rrPaint.setShadowLayer(5f,1f,1f,Color.argb(50,111,111,111))
        //为了阴影上下左右都有
        canvas.drawRoundRect(measuredWidth-400f,measuredHeight-150f,measuredWidth-180f,measuredHeight-250f,5f,5f,rrPaint)
        rrPaint.color= Color.GREEN
        canvas.drawCircle(measuredWidth-390f,measuredHeight-170f,5f,rrPaint)

        canvas.save()
        //变换坐标
        canvas.translate(measuredWidth-380f,measuredHeight-180f)
        canvas.scale(1f, -1f)
        val ttPaint=Paint()
        ttPaint.color=Color.BLACK
        ttPaint.style= Paint.Style.FILL
        ttPaint.strokeWidth=1f
        ttPaint.textSize=24f
        canvas.drawText("总额: 2980万",0f,0f,ttPaint)
        canvas.drawText("月最大金额: 400万",0f,getTextHeight(ttPaint),ttPaint)
        canvas.restore()
    }

复制代码

三、 任意区域可点击的折线图

到这里我们也许在炫酷方面已经无从下手?对于很多的自定义View是否能够操作每一个文字或者想要点击的区域。然后去搞一些你敢想搞不了的事呢?例如我点击上面的文字。来个泡泡飞上去?哈哈接下来我们试着玩一玩了?

...下图可以看到的却我点击每个文字框被正确的计算到位置了,那到底如何来精确的计算想要点击的位置呢?

1.画布区域点击事件

  • 接下来我在自定义的View中点击左上角,在onTouch里面打印一下坐标(event.x,event.y):
   @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent): Boolean {
        if (event.action == MotionEvent.ACTION_DOWN) {
           Log.e("onTouchEvent", "onTouchEvent:x"+event.x )
           Log.e("onTouchEvent", "onTouchEvent:y"+event.y )
     
        return super.onTouchEvent(event)
    }
 }
 //点击左上角结果:
E/onTouchEvent: onTouchEvent:x0.0
E/onTouchEvent: onTouchEvent:y8.996094
E/onTouchEvent: onTouchEvent:x0.0
E/onTouchEvent: onTouchEvent:y1.9501953

//点击坐下角
E/onTouchEvent: onTouchEvent:x0.0
E/onTouchEvent: onTouchEvent:y730.3877

//点击右上角

E/onTouchEvent: onTouchEvent:y23.08789
E/onTouchEvent: onTouchEvent:x1027.2656
E/onTouchEvent: onTouchEvent:y23.08789
E/onTouchEvent: onTouchEvent:x1027.2656
E/onTouchEvent: onTouchEvent:y23.08789

//点击右下角
E/onTouchEvent: onTouchEvent:x1027.2656
E/onTouchEvent: onTouchEvent:y723.3418
E/onTouchEvent: onTouchEvent:x1027.2656
E/onTouchEvent: onTouchEvent:y723.3418

复制代码

点击event.x,和event.y并不和我们的转变之后的坐标系一样。我们只是对canvas的坐标系进行来变幻。但是View所在的屏幕点击事件坐标系并没有变换。

  • 同一平面任意两个坐标系都可以通过,旋转,缩放,平移进行变换进行重合。也就意味着我们的点击事件坐标系和我们canvas画布坐标系不管如何变换都可以进行(event.x,envent.y)和(canvas.x,canvas.y)之间的一一映射。而且两个坐标系都以像素为单位。防止写不明白,上图绘制了两个重合的坐标系,红色为点击事件坐标系,黑色为canvas坐标系。由此可以推导出映射关系:
1.我们点击之后在点击事件坐标系中拿到的是(event.x,event.y)= (measureWidth,measureHeight)
2.canvas坐标系里面我们对应的是(canvas.x,canvas.y)=(measureWidth,03.可以得出(canvas.x,canvas.y)= (event.x,measureWidth-event.y)
4.因为我们的canvas坐标系设置了margin这里我们得到(canvas.x,canvas.y)= (event.x-marginXAndY,measureHeight-event.y-marginXAndY)

复制代码

到这里我们简单的拿到了两个坐标直接映射的关系:

(canvas.x,canvas.y)= (event.x-marginXAndY,measureHeight-event.y-marginXAndY)

在这里我们坐标映射原理上没啥问题了。至于有没有误差或正确与否如何验证呢?对于我们绘制的文字也是通过Paint,在绘制中并没有提供Paint.setOnclick字样的点击事件。这就显得很被动,但是Rect提供了contais函数,用来判断点是否在某一个矩形区域内部。

当然了API还是需要常看和动手的。如何在一个平面坐标系内部判断一个点在某一个区域内呢?如图下图派下面代码就不多讲了吧。

  • x >= left && x < right && y >= top && y < bottom

这里给了我们很大的突破口。我们记得上面布局绘制了文字,而且绘制了文字的背景通过Rect。每一个定点对应一个文字和对应的文字Rect背景。所以在绘制文字背景时候我们将Rect存储起来。当然了你觉得canvas中那些是你想进行点击事件操作的那么可以去通过Rect去判断。

  • 存储Rect
rctArrayList.add(Rect(pointList[index].x.toInt(), pointList[index].y.toInt(), (pointList[index].x+titleWidth).toInt(), (pointList[index].y+getTextHeight(text_paint)).toInt()))
复制代码

对于点击事件的拦截我们在onTachEvent进行操作,不清除的看另一片点击事件关的文章

 override fun onTouchEvent(event: MotionEvent): Boolean {
        if (event.action == MotionEvent.ACTION_DOWN) {
            clickDow=true
            return true
        }
        if (event.action == MotionEvent.ACTION_UP&&clickDow) {
            for (index in 0 until rctArrayList.size){
                //转换坐标为判断是否在文字背景框所在区域内部
                val contais=rctArrayList[index].contains((event.x.toInt()-marginXAndY).toInt(), (measuredHeight-marginXAndY-event.y.toInt()).toInt())
                if (contais){
                    ToastUtils.showLong("点击文字=$index")
                    break
                }
            }
            clickDow=false
        }
        return super.onTouchEvent(event)
    }
复制代码

很精准有没有。探究到这里还有什么不能做的事呢?每次点击修改canvas右上角的内容,基本操作.

这里我们也能精确的定位点击事件,这样好的交互才能被创造

1.区域点击带来的精彩

Echars里面都有个特效我们能不能实现一下呢?

这里我们先实现一下上面效果"点击部分弹出一个框框显示内容"。对于曲线什么动画都是基操,别说曲线了,学会了贝塞尔曲线基本的原理。我们刀,人,建筑......只要能看见的万物,讲道理都可以画出来。下图来个案例,稳住军心。

通过上面得点击事件和canvas坐标系得转换映射我们已经很精确定位到画布中位置,接下来代码操作。

1.全局定义一个Rect
2.点击事件里面,创建初始化一个动态🉐️Rect
3.设置定时器来设置显示消失时间等操作。

复制代码
  • 第一步简单,第二步我们创建图中点击圆圈下面的Rect即可,对于内容你看数据本身了。咋们就来个小黑框。内容咋就简单点,画个框框又得费好几分钟。Rect我们别学我写死哦。既然上面我们学会了字体测量那么你们就严格点,这里我直接写死了。
上面推导公式别忘记了:
(canvas.x,canvas.y)= (event.x-marginXAndY,measureHeight-event.y-marginXAndY)
 var x=event.x-marginXAndY
 var y=measureHeight-event.y-marginXAndY
 明确坐标系的正负方向很重要
 那么我们巨型在下面图2 :
 Rect(left,top,right,bottom)=Rect(x-100,y,x+100,y-200)

复制代码

  • 步骤一,全局新建变量,在点击事件里面初始化。
//全局...当然拿到坐标也可以。都行咋么方便咋么来。
 var blackRect: Rect? = null
 //用来判断显示黑框不
 var visible = false
 


 
 
 
//2.点击时间初始化Rect
@SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent): Boolean {
        if (event.action == MotionEvent.ACTION_DOWN) {
            clickDow = true
            return true
        }
        if (event.action == MotionEvent.ACTION_UP && clickDow) {
            for (index in 0 until rctArrayList.size) {
                //转换坐标为
                val contais = rctArrayList[index].contains((event.x.toInt() - marginXAndY).toInt(), (measuredHeight - marginXAndY - event.y.toInt()).toInt())
                if (contais) {
                    ToastUtils.showLong("点击文字=$index")
                    rightTopSubject = titleList[index]
                    //初始化Rect
                    val x = event.x - marginXAndY
                    val y = measuredHeight - event.y - marginXAndY
                    //明确坐标系的正负方向很重要
                    blackRect = Rect((x - 70).toInt(), y.toInt(), (x + 70).toInt(), (y - 200).toInt())
                    //可显示
                    visible=true
                    invalidate()
                    //当然动画什么的都可以去刷新...这里比较简单的搞一搞效果而已
                    postDelayed({
                        visible=false
                        invalidate()
                    }, 2000)
                    break
                }
            }
            clickDow = false
        }
        return super.onTouchEvent(event)
    }
    
    
//3.绘制弹出框部分代码
 private fun drawWindowRect(canvas: Canvas) {
        if (blackRect != null && visible) {
            val rrPaint = Paint()
            rrPaint.color = Color.BLACK
            rrPaint.style = Paint.Style.FILL
            rrPaint.strokeWidth = 1f
            rrPaint.setShadowLayer(5f, -5f, -5f, Color.argb(50, 111, 111, 111))
            //这里搞个圆角吧...避免太丑
            canvas.drawRoundRect(blackRect!!.left.toFloat(), blackRect!!.top.toFloat(), blackRect!!.right.toFloat(), blackRect!!.bottom.toFloat(), 10f, 10f, rrPaint)
            canvas.save()
            canvas.translate(blackRect!!.left.toFloat(), blackRect!!.top.toFloat())
            canvas.scale(1f, -1f)

            val ttPaint = Paint()
            ttPaint.color = Color.WHITE
            ttPaint.style = Paint.Style.FILL
            ttPaint.strokeWidth = 1f
            ttPaint.strokeCap = Paint.Cap.ROUND
            ttPaint.textSize = 24f
            //0f,0f表示圆点,这样看着可能好理解,20f,30f是我不打算测量文字直接写死的偏移。。
            canvas.drawText("M:${rightTopSubject}", 0f + 20f, 0f + 30f, ttPaint)
        }

    }
   
复制代码

看效果如下:样子就这样效果有了,至于弹出避免重复出现等你们可以优化...

四、手势带给折线图更好的体验

1.左右可滑动滑动的折线图。

好的自定义离不开手势,大家都看过K线图吧,见证过好几个技术群演变成日常股票分享群的过程。

看股票货币的朋友应该很熟悉。看到图中不仅可以左右移动还可以缩放。这交互性太炫酷了吧。接下来我们一步步来。手势滑动的计算至关重要但是没有超过初中生的计算分析能力,所以咋们也可以。先来个小案例。我们通过测量手势滑动距离来进行设置屏幕中间圆圈的位置:

首先创建简单的类绘制一个圆重写onTouchEvent事件:

class LHC_Scroll_distance_View @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) : androidx.appcompat.widget.AppCompatImageView(context, attrs, defStyle) {
    //
    var viewxToY = 60f
     var maxXInit=0f
    override fun onDraw(canvas: Canvas) {
        maxXInit= measuredWidth.toFloat()
        drawCircle(canvas)
    }

    private fun drawCircle(canvas: Canvas) {
        val linePaint = Paint()
        linePaint.isAntiAlias = true
        linePaint.strokeWidth = 20f
        linePaint.strokeCap = Paint.Cap.ROUND
        linePaint.color = Color.RED
        linePaint.style = Paint.Style.FILL

       
        canvas.drawCircle(viewxToY, (measuredHeight/2).toFloat(), 60f, linePaint)
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
            
            }
            MotionEvent.ACTION_MOVE -> {
               
            }
      
        }
        return true
    }
}

复制代码

运行之后

onTouchEvent可以根据屏幕MotionEvent信息进行计算...不清楚的可以看我的另一篇文章或者其他好文了解点View和ViewGroup的点击事件分发流程。

1.MotionEvent.ACTION_DOWN 字面明白当触发按下操作时候触发回调。
2.MotionEvent.ACTION_MOVE 返回按下和抬起中间的所有的点。
源码里面是这样定义的:
    /**
     * Constant for {@link #getActionMasked}: A change has happened during a
     * press gesture (between {@link #ACTION_DOWN} and {@link #ACTION_UP}).
     * The motion contains the most recent point, as well as any intermediate
     * points since the last down or move event.
     */
public static final int ACTION_MOVE             = 2;

复制代码

按下,滑动,抬起等都会收到屏幕点击MotionEvent(位置,时间...详情),那屏幕上按下的点所在的位置,可以很好的获取。那左右滑动如何获取每次滑动的距离呢?

按下之后到抬起之间在不断的通知回调onTouchEvent的MotionEvent.ACTION_MOVE类型事件,所以我们会在MOtionEvent.ACTION_MOVE下面不断收到屏幕MotionEvent信息。event.x在不断变化,同样对应的圆也应该不断的随着细微的变化而同步变化平移。这样即可实现了左右滑动。

我们的圆心x轴圆心坐标默认(viewxToY,measureHeight/2),滑动过程保证viewXToY随着滑动变化而同样的进行View刷新变化,那就实现了圆圈的平移!!

如上图,在DOWN和UP之间有其实有连连不断的MotionEvent.ACTION_MOVE通知,我们每次通知过来用event.x-上一次记录的event.x()就是每次滑动通知的距离小段。所有的滑动小段加起来就是滑动距离...说的是否明白。

  override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN -> startX = event.x
            MotionEvent.ACTION_MOVE -> {
                //每次通知计算滑动的一点点
                val dis = event.x - startX
                //记录这次移动结束的event.x就是下一次的滑动起始滑动的位置
                startX = event.x
                //将每次的滑动小段距离在当前距离的基础上叠加起来
                viewxToY=viewxToY+dis
                //通知刷新View
                invalidate()
            }
        }
        return true
    }

复制代码

迫不及待看看效果

我们看到基本和滑动同步,基本没啥大问题。我们来改造一波我们的代码看看折线图的。 先来个简单的平移一波x轴。慢慢来....嗯..原本搞个随机颜色的尴尬😅。

    //记录滑动距离
    private var viewxToY=0f
    
     //绘制x轴
    val pathx = Path()
    //手势滑动的距离加上
    pathx.moveTo(0f+viewxToY, 0f)
    pathx.lineTo(measuredWidth - 20f+viewxToY, 0f)
    
     var  startX=0f
    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent): Boolean {
        if (event.action == MotionEvent.ACTION_DOWN) {
            clickDow = true
            startX=event.x
            return true
        }
        if (event.action==MotionEvent.ACTION_MOVE){
            //每次通知计算滑动的一点点
            val dis = event.x - startX
            //记录这次移动结束的event.x就是下一次的滑动起始滑动的位置
            startX = event.x
            //将每次的滑动小段距离在当前距离的基础上叠加起来
            viewxToY += dis
            invalidate()
        }
        ...
        


复制代码

x轴下方字体我们也修改一下对应的坐标。

 //绘制背景
 //背景和位子的left+right都+viewxToY即可
 canvas.drawRoundRect(-getTextWidth(xtitle_paint, "${index + 2}月月") / 2+viewxToY, -getTextHeight(xtitle_paint) / 2, getTextWidth(xtitle_paint, "${index + 2}月月") / 2+viewxToY, getTextHeight(xtitle_paint) / 2, 10f, 10f, getTextBackgroudPaint(40, 5, 0))
            //绘制文字
 canvas.drawText("${index + 2}月", 0, "${index + 2}月".length, -getTextWidth(xtitle_paint, "${index + 2}月") / 2+viewxToY, getTextHeight(xtitle_paint) / 3, xtitle_paint)
       

复制代码

\

到这里我们实现了随着手势计算水平方向相对的滑动位置。但是看着很别扭....有没有发现滑动是任意滑动。但是我们的滑动不应该是为了滑动而滑动。x轴方向的内容超出屏幕滑动才显得很有必要。所以需要手势滑动中做好限制。接下来我们继续分析:

  • 折线绘制的开始我们的却没有去结合业务或者正式的去规划每个细节。折线图的展示很多场景和需求都不能够完全在一个屏幕宽度内部呈现,而开始我们就没有考虑到这一点,我想这个对于大家很简单,水平方向我们继续添加月份和坐标,水平方向x轴的长度我们通过每格宽度*坐标个数-1即等于所有的宽度去绘制x轴即可。
1.我们必须知道X轴平移左右最大和最小距离。
2.因为x轴我们经过变换最大滑动距离为minXInit=0,代表如果在圆点向右边是滑动不了的。
3.而我们的x轴因为超出了屏幕宽度所以我们想看到屏幕右边的内容=x轴的宽度-屏幕的宽度。
不知道说的ok不。看下图清晰一下。

复制代码

代码如下:

 if (event.action==MotionEvent.ACTION_MOVE){
            //每次通知计算滑动的一点点
            val dis = event.x - startX
            //记录这次移动结束的event.x就是下一次的滑动起始滑动的位置
            startX = event.x
            //将每次的滑动小段距离在当前距离的基础上叠加起来
            minXInit=measuredWidth-xwidthMax
            if (viewxToY + dis < minXInit) {
                viewxToY = minXInit
            } else if (viewxToY + dis > maxXInit) {
                viewxToY = maxXInit
            } else {
                viewxToY += dis
            }
            invalidate()
        }
复制代码

有点迫不及待了,效果如下:

2.手势可缩放的折线图

ScaleGestureDetector作为android中的检测手势的通知工具类很少被我们使用,常见的场景也就图片和图表折线图这些会出现缩放。当然了onTouchEvent也可以通过多触点(勾股定理)进行计算缩放比例,哈哈偷偷看看ScaleGestureDetector就明白了。

1.通过onTouchEvent来计算缩放比例

1. onTouchEvent中我们自己测量?还是我们看看ScaleGestureDetector内部拿着event是如何测
量缩放的呢?我们从通知方法入手override fun onScale(detector: ScaleGestureDetector)
   public float getScaleFactor() {
        if (inAnchoredScaleMode()) {
            // Drag is moving up; the further away from the gesture
            // start, the smaller the span should be, the closer,
            // the larger the span, and therefore the larger the scale
            final boolean scaleUp =
                    (mEventBeforeOrAboveStartingGestureEvent && (mCurrSpan < mPrevSpan)) ||
                    (!mEventBeforeOrAboveStartingGestureEvent && (mCurrSpan > mPrevSpan));
            final float spanDiff = (Math.abs(1 - (mCurrSpan / mPrevSpan)) * SCALE_FACTOR);
            return mPrevSpan <= mSpanSlop ? 1 : scaleUp ? (1 + spanDiff) : (1 - spanDiff);
        }
        return mPrevSpan > 0 ? mCurrSpan / mPrevSpan : 1;
    }
这里我们大概看到mPrevSpan,mCurrSpan这些变量通过各种判断和计算得到缩放因子。那我们顺着找
到mPrevSpan,mCurrSpan相关的附值地方。不用想就在onTouchEvent中,我们找到onTouchEvnet

 public boolean onTouchEvent(MotionEvent event) {
   ...
   //缩放滑动模式下通过股沟定理计算斜边。
   if (inAnchoredScaleMode()) {
            span = spanY;
        } else {
            span = (float) Math.hypot(spanX, spanY);
        }
     }
  //滑动过程给mCurrSpan负值   
  if (action == MotionEvent.ACTION_MOVE) {
            mCurrSpanX = spanX;
            mCurrSpanY = spanY;
            mCurrSpan = span;

            boolean updatePrev = true;

            if (mInProgress) {
                updatePrev = mListener.onScale(this);
            }

            if (updatePrev) {
                mPrevSpanX = mCurrSpanX;
                mPrevSpanY = mCurrSpanY;
                //记录这次结束时的斜边
                mPrevSpan = mCurrSpan;
                mPrevTime = mCurrTime;
            }
        }   
 
 }


复制代码

接下来我们自己模仿一个进行测量缩放比例两趾之间的距离前后进行相除:

这里很尴尬。写的文章超出了文字限制,咋们开始在CSDN或者下一篇再写吧

文章分类
Android
文章标签