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

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

系统mac

android studio: 4.1.3

kotlin version1.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(3f, 2f, 3f, 1f, 1f)
 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天没想出来,只能看看前辈思路…

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

完整代码

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

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

s10g

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值