Android高级自定义,手势滑动缩放/渐变填充/曲线折线图表


/   今日科技快讯   /

近日网易公司发布2020年第四季度及2020财政年度业绩。根据财报,网易公司第四季度净收入为人民币197.6亿元,同比增长25.6%。第四季度,网易公司各项业务稳健发展。其中,在线游戏服务净收入134.0亿元,同比增加15.5%;有道净收入11.1亿元,同比增加169.7%;创新及其他业务净收入52.5亿元,同比增加41.3%。

/   作者简介   /

辛苦了一周,终于可以放松放松了,祝大家都有一个愉快的周末!

本篇文章来自路很长o0同学投稿,分享了自己开发的曲线折线图表,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章!

路很长o0的博客地址:

https://juejin.cn/user/4019470242152616/posts

/   基础操作   /

自定义老生常谈的技能了,年底没事干,希望这篇文章能够开启你的自定义大门。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。之前写过绘制相关文章(https://juejin.cn/post/6904446734727184398)可以去看看,绘制简单的折线图开始。

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)
    }

网格和黑色去掉之后。

/   修饰折线图   /

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

折线图添加文字修饰

自定义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轴的刻度。首先我们将这个曲线设定为某公司进几月来每月的收益总额

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万为一等分。

    //绘制y轴的文字
    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()
        }
    }

/   任意区域点击   /

到这里我们也许在炫酷方面已经无从下手?对于很多的自定义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,0)
3.可以得出(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()))

对于点击事件的拦截我们在onTouchEvent进行操作,不清除的看另一片点击事件关的文章(https://juejin.cn/post/6920498961056956430)。

 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)
    }

很精准有没有。

2. 区域点击

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)
        }
    }

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

/   手势带来更好的体验   /

由于篇幅限制,本章节涉及滑动与缩放部分大家可以点击底部 阅读原文 进行查看!

推荐阅读:

我的新书,《第一行代码 第3版》已出版!

一篇小短文,带你了解屏幕刷新背后的故事

这样学习View的测量算法,真的很有趣哦

欢迎关注我的公众号

学习技术或投稿

长按上图,识别图中二维码即可关注

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值