android 自定义view: 蛛网/雷达图 (三)

1,461 阅读5分钟

本系列自定义View全部采用kt

系统: mac

android studio: 4.1.3

kotlin version: 1.5.0

gradle: gradle-6.5-bin.zip

本篇效果:

B2D3590C24C6466AC48EB2D156E6A749

蛛网图其实就是由多个多边形来组成蛛网的,那么先来画1个多边形来练练手

画多边形

首先我们先来画一个五边形,

想要绘制一个五边形,那么就是求出5个点即可

例如这样:

image-20220927134229909

首先我们需要定义圆的半径,也是五边形的“半径”

只需要算出每一个角的角度,那么就可以通过三角函数算出每一个点的坐标

  • 0的角度为360 / 5 * 0
  • 1的角度为360 / 5 * 1
  • 2的角度为360 / 5 * 2
  • 3的角度为360 / 5 * 3
  • 4的角度为360 / 5 * 4

来看看代码:

 class E3PolygonChartBlogView @JvmOverloads constructor(
     context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
 ) : View(context, attrs, defStyleAttr) {
 ​
     companion object {
         // 半径
         val SMALL_RADIUS = 100.dp
 ​
         // 几边形
         const val COUNT = 5
    }
 ​
     private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
 ​
     // 中心位置
     private val centerLocation by lazy {
         PointF(width / 2f, height / 2f)
    }
 ​
     override fun onDraw(canvas: Canvas) {
 ​
         val cx = centerLocation.x
         val cy = centerLocation.y
         // 辅助圆
         canvas.drawCircle(cx, cy, SMALL_RADIUS, paint)
 ​
 ​
         // 每一个的间隔
         val eachAngle = 360 / COUNT
        (0 until COUNT).forEach {
 ​
             val angle = it * eachAngle.toDouble()
 ​
             val x =
                (SMALL_RADIUS * cos(Math.toRadians(angle)) + centerLocation.x).toFloat()
             val y =
                (SMALL_RADIUS * sin(Math.toRadians(angle)) + centerLocation.y).toFloat()
 ​
             paint.color = colorRandom
             // 绘制每一个小圆
             canvas.drawCircle(x, y, 10.dp, paint)
        }
    }
 }

06453C8694763E0FAA32121C2E1ABA56

那么五边形其实就是吧5个点连接起来即可

   private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
 ​
     private val path = Path()
     override fun onDraw(canvas: Canvas) {
 ​
         // 每一个的间隔
         val eachAngle = 360 / COUNT
        (0 until COUNT).forEach {
             val angle = it * eachAngle.toDouble()
             val x =
                (SMALL_RADIUS * cos(Math.toRadians(angle)) + centerLocation.x).toFloat()
             val y =
                (SMALL_RADIUS * sin(Math.toRadians(angle)) + centerLocation.y).toFloat()
 // 连接每一个点
             if (it == 0) {
                 path.moveTo(x, y)
            } else {
                 path.lineTo(x, y)
            }
        }
         path.close() // 闭合
 ​
         paint.strokeWidth = 2.dp
         paint.style = Paint.Style.STROKE
         canvas.drawPath(path, paint) // 绘制
         path.reset()
    }

B21DA960774D3817CBA5E470A4213B9F

绘制多条五边形

假如需要绘制成这样子:

image-20220927140012180

刚才我们绘制的是最中心绿色的五边形,

那么这里就需要定义一个变量,来标识每一个五边形之间的间距

例如蓝色五边形和绿色五边形的间距为20.dp

那么蓝色五边形 五个点的半径 = 绿色五边形的半径 + 20.dp

以此类推

 class E3PolygonChartBlogView @JvmOverloads constructor(
     context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
 ) : View(context, attrs, defStyleAttr) {
 ​
     companion object {
         // 半径
         val SMALL_RADIUS = 100.dp
 ​
         // 几边形
         const val COUNT = 5
 ​
         // 有几条边
         const val NUMBER = 3
 ​
         // 每一条边的间隔
         val INTERVAL = 20.dp
    }
 ​
     private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
 ​
     // 中点
     private val centerLocation by lazy {
         PointF(width / 2f, height / 2f)
    }
 ​
     private val path = Path()
     override fun onDraw(canvas: Canvas) {
         // 每一个的间隔
         val eachAngle = 360 / COUNT
       
         // 循环有几条边
        (0 until NUMBER).forEachIndexed { index, element ->
 ​
             // 循环每一条边有几个点
            (0 until COUNT).forEach { count ->
                 // 半径 = 当前是第几条边 * 间距 + 最中间的距离
                 val radius = element * INTERVAL + SMALL_RADIUS
                                      
                 val angle = count * eachAngle.toDouble()
 ​
                 val x =
                    (radius * cos(Math.toRadians(angle)) + centerLocation.x).toFloat()
                 val y =
                    (radius * sin(Math.toRadians(angle)) + centerLocation.y).toFloat()
                 if (count == 0) {
                     path.moveTo(x, y)
                } else {
                     path.lineTo(x, y)
                }
            }
             path.close() // 闭合
             paint.strokeWidth = 2.dp
             paint.style = Paint.Style.STROKE
             canvas.drawPath(path, paint)
             paint.reset()
        }
    }
 }

8A0CD28DDE249FD3F0DE766FC0589A53

连接最外层和最内层

连接最内层和最外层也比较简单, 只需要循环有几条边的时候判断是否是最外层,

然后将最外层的点和最内层的点相连接即可

如果需要和中心点相连接,那么stop点为 centerLocation即可

 override fun onDraw(canvas: Canvas) {
         // 每一个的间隔
         val eachAngle = 360 / COUNT
         // 循环有几条边
        (0 until NUMBER).forEachIndexed { index, element ->
 ​
             // 循环每一条边有几个点
            (0 until COUNT).forEach { count ->
                 // 半径 = 当前是第几条边 * 间距 + 最中间的距离
                 val radius = element * INTERVAL + SMALL_RADIUS
                 val angle = count * eachAngle.toDouble()
 ​
                 val x =
                    (radius * cos(Math.toRadians(angle)) + centerLocation.x).toFloat()
                 val y =
                    (radius * sin(Math.toRadians(angle)) + centerLocation.y).toFloat()
                  .....
 ​
                 // 当前是最后一层
                 if (index == NUMBER - 1) {
                     // 最内层x,y 坐标
                     val stopX =
                        (SMALL_RADIUS * cos(Math.toRadians(angle)) + centerLocation.x).toFloat()
                     val stopY =
                        (SMALL_RADIUS * sin(Math.toRadians(angle)) + centerLocation.y).toFloat()
                     canvas.drawLine(x, y, stopX, stopY, paint)
                     // 连接中心点
                     // canvas.drawLine(x, y, centerLocation.x, centerLocation.y, paint)
                }
            }
             path.close() // 闭合
 ​
             canvas.drawPath(path, paint)
             paint.reset()
        }
    }

E34889CA78EB0748B42D7D094D7E727D

那么现在需要一个

  • 10边形
  • 每一条边有7个点
  • 最中心的半径为 20.dp
  • 每一个边的间距 = 20.dp

只需要改这4个变量即可:

 companion object {
     // 半径
     val SMALL_RADIUS = 20.dp
 ​
     // 几边形
     const val COUNT = 10
 ​
     // 有几条边
     const val NUMBER = 7
 ​
     // 每一条边的间隔
     val INTERVAL = 20.dp
 }

3F44C3AACA9A683DD7FAD003C4F39FBC

绘制文字

还是和上面的套路一样,先来思考文字需要绘制到什么地方?

image-20220927143649966

我们的多边形只到红色的,那么为了保持和最外层有一点距离,所以我们需要将文字绘制到虚线处,

还是当绘制最外层的时候开始绘制 文字

     @SuppressLint("DrawAllocation")
     override fun onDraw(canvas: Canvas) {
 ​
         // 每一个的间隔
         val eachAngle = 360 / COUNT
         // 循环有几条边
        (0 until NUMBER).forEachIndexed { index, element ->
 ​
             // 循环每一条边有几个点
            (0 until COUNT).forEach { count ->
                 // 半径 = 当前是第几条边 * 间距 + 最中间的距离
                 val radius = element * INTERVAL + SMALL_RADIUS
                 val angle = count * eachAngle.toDouble()
 ​
                 val x =
                    (radius * cos(Math.toRadians(angle)) + centerLocation.x).toFloat()
                 val y =
                    (radius * sin(Math.toRadians(angle)) + centerLocation.y).toFloat()
                ...
 ​
                 // 绘制最外层和内层连接线
                ...
 ​
 ​
                 // 设置文字
                 if (index == NUMBER - 1) {
                     val text = "文字${count}"
 ​
                     val rect = Rect()
 ​
                     // 计算文字宽高 计算完成之后会把值赋值给rect
                     paint.getTextBounds(text, 0, text.length, rect)
                     val textWidth = rect.width()
                     val textHeight = rect.height()
 ​
                     val tempRadius = radius + textHeight
                     val textX =
                        (tempRadius * cos(Math.toRadians(angle)) + centerLocation.x).toFloat() - textWidth / 2f
                     val textY =
                        (tempRadius * sin(Math.toRadians(angle)) + centerLocation.y).toFloat()
 ​
                     paint.textSize = 16.dp
                     paint.style = Paint.Style.FILL
                     paint.color = E3PolygonChartView.TEXT_COLOR
                     // 绘制最外层文字
                     canvas.drawText(text, textX, textY, paint)
 ​
                }
            }
          ...
        }
    }

19E53575991C7EA402921BC102BD9EEF

到目前为止,蛛网的雏形就差不多了,接下来绘制具体的数据

绘制数据

绘制数据之前先来看看现在点的坐标

image-20220927144811150

假设我们当前需要设置的数据为 3,2,3,1,1

那么我们只需要从0坐标开始,算出每一个对应的五边形即可

那么最终结果应该为:

image-20220927145126335

 override fun onDraw(canvas: Canvas) {
   
   // 绘制网格
          ...
   
   // 绘制数据
         drawArea(canvas)
 }
 ​
 var data = listOf(3f2f3f1f1f)
  private fun drawArea(canvas: Canvas) {
         data.forEachIndexed { index, value ->
             val location = getLocation(index, value)
 ​
             if (index == 0) {
                 path.moveTo(location.x, location.y)
            } else {
                 path.lineTo(location.x, location.y)
            }
        }
         path.close()
 ​
 ​
         paint.style = Paint.Style.STROKE
         paint.color = Color.RED
         canvas.drawPath(path, paint) // 绘制边
 ​
         paint.style = Paint.Style.FILL
         paint.alpha = (255 * 0.1).toInt()
         canvas.drawPath(path, paint) // 绘制内边
         path.reset()
    }
 /*
  * 作者:史大拿
  * 创建时间: 9/27/22 2:54 PM
  * @number 第几个点
  * @count 第几条边
  */
 private fun getLocation(number: Int, count: Float): PointF = let {
     // 角度
     val angle = 360 / COUNT * number
   
     // 半径
     val radius = (count - 1) * INTERVAL + SMALL_RADIUS
   
     val x =
        (radius * cos(Math.toRadians(angle.toDouble())) + centerLocation.x).toFloat()
     val y =
        (radius * sin(Math.toRadians(angle.toDouble())) + centerLocation.y).toFloat()
 ​
     return PointF(x, y)
 }

75125DAA30DB08E91CEE56C3C95F6217

手势滑动

雷达图的手势滑动和其他的不太一样, 因为他需要计算的是角度

场景1(右下角)

image-20220927152922871

假设当前滑动的位置在右下角,那么他的角度就为 红色的角度

  • 红色的角度 = atan(dy / dx)

场景2 (左下角)

image-20220927153048043

假设当前滑动的位置在左下角,那么他的角度就为 黑色的角度 + 绿色的角度

  • 绿色角度 = 90度
  • 红色的角度 = atan(dy / dx)
  • 黑色角度 = 90 - 红色角度

场景3(左上角)

image-20220927151948901

假设当前滑动的位置在左上角,那么他的角度就为 红色的角度 + 绿色角度

dx = centerLocation.x - event.x

dy = centerLocation.x - event.y

  • 红色的角度 = atan(dy / dx)
  • 绿色的角度 = 180度

场景4(右上角)

image-20220927153220471

假设当前滑动的位置在右上角,那么他的角度就为 绿色角度 + 黑色角度

  • 黑色角度 = 90度 - 红色角度
  • 红色角度 = atan(dy / dx)
  • 绿色角度 = 270度

判断是否是左上角 或者右上角,只需要判断两个点的x,y值即可

来看看计算角度代码:

 @param startP: 开始点
 @param endP: 结束点
 ​
 fun PointF.angle(endP: PointF)Float {
     val startP = this
 ​
     // 原始位置
     val angle = if (startP.x >= endP.x && startP.y >= endP.y) {
         Log.e("szjLocation""end在start右下角")
         0
    } else if (startP.x >= endP.x && startP.y <= endP.y) {
         Log.e("szjLocation""end在start右上角")
         270
    } else if (startP.x <= endP.x && startP.y <= endP.y) {
         Log.e("szjLocation""end在start左上角")
         180
    } else if (startP.x <= endP.x && startP.y >= endP.y) {
         Log.e("szjLocation""end在start左下角")
         90
    } else {
         0
    }
     // 计算距离
     val dx = startP.x - endP.x
     val dy = startP.y - endP.y
     // 弧度
     val radian = abs(atan(dy / dx))
 ​
     // 弧度转角度
     var a = Math.toDegrees(radian.toDouble()).toFloat()
 ​
     if (startP.x <= endP.x && startP.y >= endP.y) {
         // 左下角
         a = 90 - a
    } else if (startP.x >= endP.x && startP.y <= endP.y) {
         // 右上角
         a = 90 - a
    }
     return a + angle
 }
 var offsetAngle = 0f // 偏移角度
 private var downAngle = 0f // 按下角度
 private var originAngle = 0f // 原始角度
 ​
 @SuppressLint("ClickableViewAccessibility")
 override fun onTouchEvent(event: MotionEvent)Boolean {
     when (event.action) {
         MotionEvent.ACTION_DOWN -> {
             downAngle = centerLocation.angle(PointF(event.x, event.y))
             originAngle = offsetAngle
        }
         MotionEvent.ACTION_MOVE -> {
             parent.requestDisallowInterceptTouchEvent(true)
 ​
             // 当前偏移角度 = 现在角度 - 按下角度 + 原始角度
             offsetAngle =
                 centerLocation.angle(PointF(event.x, event.y)) - downAngle + originAngle
 ​
             Log.e("szjOffset","$offsetAngle")
        }
         MotionEvent.ACTION_UP -> {
        }
    }
     invalidate()
 ​
     return true
 }

如果这里角度不知道为啥 = 现在角度 - 按下角度 + 原始角度 可以看第一篇道理都是一样的,就不过多解释了!

最后计算出来的角度直接赋值给onDraw即可

 override fun onDraw(canvas: Canvas) {
         // 每一个的间隔
         val eachAngle = 360 / COUNT
         // 循环有几条边
        (0 until NUMBER).forEachIndexed { index, element ->
 ​
             // 循环每一条边有几个点
            (0 until COUNT).forEach { count ->
                 val angle = count * eachAngle.toDouble() + offsetAngle // TODO 设置角度
 ​
                 val x =
                    (radius * cos(Math.toRadians(angle)) + centerLocation.x).toFloat()
                 val y =
                    (radius * sin(Math.toRadians(angle)) + centerLocation.y).toFloat()
                 if (count == 0) {
                     path.moveTo(x, y)
                } else {
                     path.lineTo(x, y)
                }
 ​
                 // 连接最外层和最内层
   ....
 ​
 ​
                 // 设置文字
                ....
                 
            }
            ....
 ​
             canvas.drawPath(path, paint)
             path.reset()
        }
 ​
         // 绘制数据
         drawArea(canvas)
    }

F87B6CCC7AFF17E94BED54F6CA42A99B

设置fling事件

我坦白了,fling事件我是偷的MPAndroidChart的源码,

这个fling事件和平常的不太一样,有大坑.. 想了1天没想出来,只能看看前辈思路...

如果需要可以自行下载看细节

完整代码

原创不易,您的点赞就是对我最大的帮助!