Android自定义View之蜘蛛网雷达效果

outside_default.png

/   今日科技快讯   /

近日,在将聊天机器人Bard作为主要用于搜索的AI服务推出后,谷歌似乎正在偏离初衷,没有提供任何具体的用例。在最近举行的全体员工会议上,该公司高管辩称,Bard不仅仅用于搜索,他们正探索构建一种协作式AI服务。

/   作者简介   /

本篇文章转自史大拿的博客,文章主要分享了自定义View中蛛网的效果,相信会对大家有所帮助!

原文地址:

https://juejin.cn/post/7147966036803846152

/   前言   /

系统:mac

android studio:4.1.3

kotlin version:1.5.0

gradle:gradle-6.5-bin.zip

本篇效果:

outside_default.png

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

/   画多边形   /

首先我们先来画一个五边形,想要绘制一个五边形,那么就是求出5个点即可。例如这样:

outside_default.png

首先我们需要定义圆的半径,也是五边形的“半径”。只需要算出每一个角的角度,那么就可以通过三角函数算出每一个点的坐标。

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

outside_default.png

那么五边形其实就是吧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()
    }

outside_default.png

/   绘制多条五边形   /

假如需要绘制成这样子:

outside_default.png

刚才我们绘制的是最中心绿色的五边形,那么这里就需要定义一个变量,来标识每一个五边形之间的间距。例如蓝色五边形和绿色五边形的间距为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()
        }
    }
 }

outside_default.png

/   连接最外层和最内层   /

连接最内层和最外层也比较简单,只需要循环有几条边的时候判断是否是最外层,然后将最外层的点和最内层的点相连接即可。如果需要和中心点相连接,那么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()
        }
    }

outside_default.png

那么现在需要一个:

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

outside_default.png

/   绘制文字   /

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

outside_default.png

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

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

outside_default.png

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

/   绘制数据   /

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

outside_default.png

假设我们当前需要设置的数据为3,2,3,1,1。那么我们只需要从0坐标开始,算出每一个对应的五边形即可。那么最终结果应该为:

outside_default.png

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

outside_default.png

/   手势滑动   /

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

场景1(右下角)

outside_default.png

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

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

场景2(左下角)

outside_default.png

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

绿色角度 = 90度

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

黑色角度 = 90 - 红色角度

场景3(左上角)

outside_default.png

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

  • dx = centerLocation.x - event.x

  • dy = centerLocation.x - event.y

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

绿色的角度 = 180度

场景4(右上角)

outside_default.png

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

黑色角度 = 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)
    }

outside_default.png

/   设置fling事件   /

我坦白了,fling事件我是偷的MPAndroidChart的源码,这个fling事件和平常的不太一样,有大坑...想了1天没想出来,只能看看前辈思路...如果需要可以自行下载看细节。

完整代码地址如下:

https://gitee.com/lanyangyangzzz/custom-view-project

推荐阅读:

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

2022年终总结,我的10年Android之旅

手把手带你搞懂AMS启动原理

欢迎关注我的公众号

学习技术或投稿

outside_default.png

outside_default.png

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

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Android自定义View雷达图,也称为蜘蛛网图或者星型图,是一种很常见的数据可视化方式。在这种图中,多个数据维度会以不同的角度展示,而每个维度的数据则会以不同的长度表示。这样一来,我们就可以通过一个图形快速地了解多个数据维度的情况。下面是一个简单的实现。 首先,我们需要在 XML 中定义自定义 View 的布局: ``` <com.example.radarview.RadarView android:id="@+id/radar_view" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="16dp" /> ``` 接着,在 Java 代码中实现 View 的绘制逻辑: ``` public class RadarView extends View { private int mCount = 6; // 雷达图维度 private float mRadius; // 雷达图半径 private float mAngle; // 雷达图每个维度的角度 private Paint mRadarPaint; // 雷达图画笔 private Paint mValuePaint; // 数据画笔 private String[] mTitles = {"A", "B", "C", "D", "E", "F"}; // 维度名称 private double[] mValues = {5, 4, 3, 2, 5, 1}; // 数据值 public RadarView(Context context) { this(context, null); } public RadarView(Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } public RadarView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { // 初始化雷达图画笔 mRadarPaint = new Paint(); mRadarPaint.setStyle(Paint.Style.STROKE); // 初始化数据画笔 mValuePaint = new Paint(); mValuePaint.setStyle(Paint.Style.FILL_AND_STROKE); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int width = MeasureSpec.getSize(widthMeasureSpec); int height = MeasureSpec.getSize(heightMeasureSpec); int size = Math.min(width, height); mRadius = size / 2f * 0.8f; mAngle = (float) (Math.PI * 2 / mCount); setMeasuredDimension(size, size); } @Override protected void onDraw(Canvas canvas) { // 绘制雷达图 drawRadar(canvas); // 绘制数据区域 drawValue(canvas); } private void drawRadar(Canvas canvas) { Path path = new Path(); float r = mRadius / (mCount - 1); // 计算多边形边长 for (int i = 0; i < mCount; i++) { float currentR = r * i + r; // 计算当前多边形的半径 path.reset(); for (int j = 0; j < mCount; j++) { if (j == 0) { path.moveTo(getMeasuredWidth() / 2f + currentR, getMeasuredHeight() / 2f); } else { float x = (float) (getMeasuredWidth() / 2f + currentR * Math.cos(mAngle * j)); float y = (float) (getMeasuredHeight() / 2f + currentR * Math.sin(mAngle * j)); path.lineTo(x, y); } } path.close(); // 闭合路径 canvas.drawPath(path, mRadarPaint); } // 绘制连接线 for (int i = 0; i < mCount; i++) { float x = (float) (getMeasuredWidth() / 2f + mRadius * Math.cos(mAngle * i)); float y = (float) (getMeasuredHeight() / 2f + mRadius * Math.sin(mAngle * i)); canvas.drawLine(getMeasuredWidth() / 2f, getMeasuredHeight() / 2f, x, y, mRadarPaint); } // 绘制维度名称 for (int i = 0; i < mCount; i++) { float x = (float) (getMeasuredWidth() / 2f + (mRadius + 20) * Math.cos(mAngle * i)); float y = (float) (getMeasuredHeight() / 2f + (mRadius + 20) * Math.sin(mAngle * i)); canvas.drawText(mTitles[i], x, y, mValuePaint); } } private void drawValue(Canvas canvas) { Path path = new Path(); for (int i = 0; i < mCount; i++) { float percent = (float) mValues[i] / 6f; // 计算数据值占比 float x = (float) (getMeasuredWidth() / 2f + mRadius * Math.cos(mAngle * i) * percent); float y = (float) (getMeasuredHeight() / 2f + mRadius * Math.sin(mAngle * i) * percent); if (i == 0) { path.moveTo(x, getMeasuredHeight() / 2f); } else { path.lineTo(x, y); } // 绘制数据点 canvas.drawCircle(x, y, 5, mValuePaint); } path.close(); // 闭合路径 mValuePaint.setStyle(Paint.Style.FILL); mValuePaint.setAlpha(127); canvas.drawPath(path, mValuePaint); } } ``` 在这个实现中,我们首先在 onMeasure 方法中计算出雷达图的半径和每个维度之间的角度。然后,在 onDraw 方法中先绘制雷达图,再绘制数据区域。在绘制雷达图时,我们通过计算每个多边形的边长和半径,以及每个维度的角度,来绘制多个同心多边形。然后,我们绘制多边形之间的连线和维度名称。在绘制数据区域时,我们通过计算每个数据值占比来绘制数据点,并使用 Path 来绘制闭合的数据区域。最后,我们再将数据区域填充上颜色。 这样,一个简单的雷达图就完成了。当然,这只是一个基础的实现,你可以根据自己的需求来进行更多的定制。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值