Android实现雷达View效果

样式效果

  还是先来看效果:

                        

  这是一个仿雷达扫描的效果,是之前在做地图sdk接入时就想实现的效果,但之前由于赶着毕业设计,就没有亲手去实现,不过现在自己撸一个发现还是挺简单的。

  这里主要分享一下我的做法。

 

目录

主体轮廓的实现(雷达的结构)

动画的实现(雷达扫描的效果)

目标点的加入(图片/点)


主体轮廓实现

  

  不难分析得出,这个View主要由外部的一个圆,中间的锚点圆以及扇形旋转区域组成。而且每个部分理应由不同的Paint去绘制,以方便去定制各部分的样式。

  外部圆以及锚点圆的绘制较为简单,主要的点还是要对整个View的宽高进行一定的限制,例如宽高必须相等且在某种模式下,取小的那个值来限定整个RadarView的最大值。那么该如何去控制呢?

onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int)

  由于我们继承自View,在onMeasure方法中,我们可以根据两个参数来获取Mode,并且根据Mode来指定宽/高对应的值,再通过setMeasuredDimension去指定控件主体的宽高即可。

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    val vWidth = measureDimension(widthMeasureSpec)
    val vHeight = measureDimension(heightMeasureSpec)
    val size = min(vWidth, vHeight)

    setMeasuredDimension(size, size)
}

private fun measureDimension(spec: Int) =  when (MeasureSpec.getMode(spec)) {
    MeasureSpec.EXACTLY -> {
        // exactly number or match_parent
        MeasureSpec.getSize(spec)
    }
    MeasureSpec.AT_MOST -> {
        // wrap_content
        min(mDefaultSize, MeasureSpec.getSize(spec))
    }
    else -> {
        mDefaultSize
    }
}

  测量工作完成了,我们自然可以去绘制了。为了不让中间的小圆看起来那么突兀(偏大或偏小),这里设置了一个scaleFactor的缩放因子,使其能根据外圆的尺寸来进行缩放。

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    // draw outside circle (background)
    canvas?.drawCircle(measuredWidth.toFloat() / 2, measuredHeight.toFloat() / 2, measuredWidth.toFloat() / 2, mOutlinePaint)
    if (mBorderWidth > 0F && mOutlinePaint.shader == null) {
        drawBorder(canvas)
    }

    // mOutlineRect = Rect(0, 0, measuredWidth, measuredHeight)
    canvas?.drawArc(mOutlineRect.toRectF(), mStartAngle, mSweepAngle, true, mSweepPaint)

    // draw center circle
    // scaleFactor = 30F
    canvas?.drawCircle(measuredWidth.toFloat() / 2, measuredHeight.toFloat() / 2, measuredWidth.toFloat() / 2 / mScaleFactor, mPaint)
}

private fun drawBorder(canvas: Canvas?) {
    Log.i("RadarView", "drawBorder")
    mOutlinePaint.style = Paint.Style.STROKE
    mOutlinePaint.color = mBorderColor
    mOutlinePaint.strokeWidth = mBorderWidth
    canvas?.drawCircle(measuredWidth.toFloat() / 2, measuredHeight.toFloat() / 2,
        (measuredWidth.toFloat() - mBorderWidth) / 2, mOutlinePaint)
    // 还原
    mOutlinePaint.style = Paint.Style.FILL_AND_STROKE
    mOutlinePaint.color = mBackgroundColor
}

  绘制了基准圆以后,要实现雷达扫描时那种渐变的效果,我们可以通过SweepGradient来操作。通过指定中心点,渐变颜色,以及颜色的分布,来定制扫描渐变的样式,默认的即时开头时gif展示的那种。由于这里是从第一象限开始旋转,因此将旋转的起点通过matrix逆时针旋转90度,从而达到由浅入深的效果。

private fun setShader(size: Int) {
    val shader = SweepGradient(size.toFloat() / 2, size.toFloat() / 2,
        mScanColors?: mDefaultScanColors,  // 可通过setScanColors()来定制颜色
        floatArrayOf(0F, 0.5F, 1F))  // 这里默认走平均分布
    val matrix = Matrix()
    // 逆时针旋转90度
    matrix.setRotate(-90F, size.toFloat() / 2, size.toFloat() / 2)
    shader.setLocalMatrix(matrix)
    mSweepPaint.shader = shader
}

  这里完成了测量与绘制的工作,那么我们在布局里引用以后,就会看到这样的效果:

  这时,由于我们之前在测量的时候,将宽高最小值作为绘制的基准大小给予了RadarView,因此measuredWidth和measuredHeight是相等的,但是由于在布局中指定了match_parent属性,那么实际的控件宽高还是和父布局一致(在这里即占满屏幕宽高,由于宽比高小,所以看到绘制的图形会偏向上方;如果设置了高比宽小,那么绘制的图形就会位于左侧)。一般的雷达控件应该都是居中显示的,所以我在这里也重写了onLayout方法,来实现居中的效果。

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
    // 设置默认居中
    var l = left
    var r = right
    var t = top
    var b = bottom
    when {
        width > height -> {
            // 宽度比高度大 那么要设置默认居中就得把left往右移 right往左移
            l = (width - measuredWidth) / 2
            r = width - l
            layout(l, t, r, b)
        }
        height > width -> {
            // 高度比宽度大 那么要设置默认居中就得把top往下移 bottom往上移
            t = (height - measuredHeight) / 2
            b = height - t
            layout(l, t, r, b)
        }
        else -> super.onLayout(changed, left, top, right, bottom)
    }
}

 

动画的实现

  完成了绘制,接下来就是思考该如何让他动起来了。由绘制的代码不难想到,我这里考虑的是通过mStartAngle的变化来控制绘制的角度旋转,而ValueAnimator则正好能获取到每次更新时value的值,因此这里我选用了这个方案。

fun start() {
    Log.i("RadarView", "animation start")
    mIsAnimating = true
    mAnimator.duration = 2000
    mAnimator.repeatCount = ValueAnimator.INFINITE
    mAnimator.addUpdateListener {
        val angle = it.animatedValue as Float
        mStartAngle = angle

//        Log.i("RadarView", "mStartAngle = $mStartAngle and curValue = ${it.animatedValue}")
        postInvalidate()
    }
    mAnimator.start()
}

 

  这里就需要注意一个点,就是canvas在绘制时,后绘制的会覆盖在前绘制的图像上,所以需要注意绘制的顺序。当然,这里也可以把mOutlineRect的宽高设置为measuredWidth - mBorderWidth,那么就能保证绘制填充角度时,不会把边界覆盖。

  至此,动画的效果便完成了。

目标点的加入

  首先,前两点已经能满足大多的雷达扫描需求了。这里这个添加目标点(target)纯粹是我自己想加入的功能,因为觉得可以结合地图sdk的MapView来共同使用,目前也只是开发阶段,扩展性可能考虑得还不是特别充足,也还没应用到具体项目中。但是,总觉得自己想的功能也该试着去实践一下~

  这里主要运用的圆的计算公式:

  x^{2} + y^{2} = r^2

  由于Android的坐标系的原点是在左上角,y轴过顶点向下延伸。由我们的绘制可知,此绘制图像在坐标系中的位置大概如下图所示:

  那么,对应的公式就为:(x - cx)^{2} + (y - cy)^{2} < r^2

  要注意的是,这里r的计算会根据图/点的设置来动态计算,具体例子通过代码来进行分析。

// 随机落点
fun addTarget(size: Int, type: TYPE = TYPE.RANDOM) {
    val list = ArrayList<PointF>()
    val r = measuredWidth.toFloat() / 2
    val innerRect = Rect((r - r / mScaleFactor).toInt(), (r - r / mScaleFactor).toInt(),
        (r + r / mScaleFactor).toInt(), (r + r / mScaleFactor).toInt())
    // 圆的中心点
    val circle = PointF(measuredWidth.toFloat() / 2, measuredHeight.toFloat() / 2)
    while (list.size < size) {
        val ranX = Random.nextDouble(0.0, r * 2.0).toFloat()
        val ranY = Random.nextDouble(0.0, r * 2.0).toFloat()
        val ranPointF = PointF(ranX, ranY)
        if (innerRect.contains(ranPointF.toPoint())) {
            continue
        }
        // 圆公式
        if (!mNeedBitmap &&
            (ranX - circle.x).pow(2) + (ranY - circle.y).pow(2) <
              (r - mTargetRadius - mBorderWidth).toDouble().pow(2.0)) {
            // 普通点
            addTargetFromType(type, list, ranX, ranY, r, ranPointF)
        } else if (mNeedBitmap &&
            (ranX - circle.x).pow(2) + (ranY - circle.y).pow(2) <
              (r - mBorderWidth - max(mBitmap.width, mBitmap.height) / 2).toDouble().pow(2)) {
            // 图
            addTargetFromType(type, list, ranX, ranY, r, ranPointF)
        } else {
            continue
        }
    }
    mTargetList = list
    for (target in list) {
        Log.i("RadarView", "target = [${target.x}, ${target.y}]")
    }
    invalidate()
}

  可以看到,当target为普通点时,r的计算还要减去targetRadius,即目标点的半径,同时还要减去边界的宽度,如图所示:

  

  当target为图时,由于宽高不定,故除了边界外,还要减去大的边,那么r的计算则为:

  同时为了避免图片的尺寸过大,这里同样采取了一个默认值与一个缩放因子,从而保证图的完整性以及避免过大而引起的视觉丑化。

  关于落点的位置,目前采取的是随机落点,如果应用到地图扫点的话,可以通过地图sdk内的距离计算工具再与RadarView的坐标做一个比例转换,从而达到雷达内显示该点具体方位。

  关于落点的分布,目前提供了5种类型:分别是全象限随机、第一象限、第二象限、第三象限与第四象限随机。

Github

  若须直接调用,可移步至 https://github.com/CarsonWoo/RadarView

完整代码

class RadarView : View {

    enum class TYPE { RANDOM, FIRST, SECOND, THIRD, FOURTH }

    private val mPaint by lazy { Paint(Paint.ANTI_ALIAS_FLAG) }

    private val mSweepPaint by lazy { Paint(Paint.ANTI_ALIAS_FLAG) }

    private val mOutlinePaint by lazy { Paint(Paint.ANTI_ALIAS_FLAG) }

    private val mTargetPaint by lazy { Paint(Paint.ANTI_ALIAS_FLAG) }

    private val mDefaultSize = 120// px

    // limit the size of bitmap
    private var mBitmapMaxSize = 0F

    private var mBitmapWHRatio = 0F

    private val mScaleFactor = 30F

    private var mStartAngle = 0F
    private val mSweepAngle = -60F

    private var mScanColors: IntArray? = null

    private val mDefaultScanColors = intArrayOf(Color.parseColor("#0F7F7F7F"),
        Color.parseColor("#7F7F7F7F"),
        Color.parseColor("#857F7F7F"))

    private val mDefaultBackgroundColor = Color.WHITE

    private var mBackgroundColor: Int = mDefaultBackgroundColor

    private var mBorderColor: Int = Color.BLACK

    private var mBorderWidth = 0F

    private var mTargetColor: Int = Color.RED

    private var mTargetRadius = 10F

    private lateinit var mOutlineRect: Rect

    private val mAnimator = ValueAnimator.ofFloat(0F, 360F)

    private var mTargetList: ArrayList<PointF>? = null

    private var mIsAnimating = false

    private var mNeedBitmap = false

    private var mBitmap = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)

    constructor(context: Context): this(context, null)

    constructor(context: Context, attributeSet: AttributeSet?) : super(context, attributeSet)

    init {
        mPaint.color = Color.GRAY
        mPaint.strokeWidth = 10F
        mPaint.style = Paint.Style.FILL_AND_STROKE
        mPaint.strokeJoin = Paint.Join.ROUND
        mPaint.strokeCap = Paint.Cap.ROUND

        mSweepPaint.style = Paint.Style.FILL

        mOutlinePaint.style = Paint.Style.FILL_AND_STROKE
        mOutlinePaint.color = mBackgroundColor

        mTargetPaint.style = Paint.Style.FILL
        mTargetPaint.color = mTargetColor
        mTargetPaint.strokeWidth = 10F
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val vWidth = measureDimension(widthMeasureSpec)
        val vHeight = measureDimension(heightMeasureSpec)
        val size = min(vWidth, vHeight)

        setShader(size)

        setMeasuredDimension(size, size)

        setParamUpdate()
    }

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        // 设置默认居中
        var l = left
        var r = right
        var t = top
        var b = bottom
        when {
            width > height -> {
                // 宽度比高度大 那么要设置默认居中就得把left往右移 right往左移
                l = (width - measuredWidth) / 2
                r = width - l
                layout(l, t, r, b)
            }
            height > width -> {
                // 高度比宽度大 那么要设置默认居中就得把top往下移 bottom往上移
                t = (height - measuredHeight) / 2
                b = height - t
                layout(l, t, r, b)
            }
            else -> super.onLayout(changed, left, top, right, bottom)
        }
    }

    private fun setShader(size: Int) {
        val shader = SweepGradient(size.toFloat() / 2, size.toFloat() / 2,
            mScanColors?: mDefaultScanColors,
            floatArrayOf(0F, 0.5F, 1F))
        val matrix = Matrix()
        matrix.setRotate(-90F, size.toFloat() / 2, size.toFloat() / 2)
        shader.setLocalMatrix(matrix)
        mSweepPaint.shader = shader
    }

    fun setScanColors(colors: IntArray) {
        this.mScanColors = colors
        setShader(measuredWidth)
        invalidate()
    }

    fun setRadarColor(@ColorInt color: Int) {
        this.mBackgroundColor = color
        this.mOutlinePaint.color = color
        invalidate()
    }

    fun setRadarColor(colorString: String) {
        if (!colorString.startsWith("#") || colorString.length != 7 || colorString.length != 9) {
            Log.e("RadarView", "colorString parse error, please check your enter param")
            return
        }
        val color = Color.parseColor(colorString)
        setRadarColor(color)
    }

    fun setBorderColor(@ColorInt color: Int) {
        this.mBorderColor = color
        invalidate()
    }

    fun setBorderColor(colorString: String) {
        if (!colorString.startsWith("#") || colorString.length != 7 || colorString.length != 9) {
            Log.e("RadarView", "colorString parse error, please check your enter param")
            return
        }
        val color = Color.parseColor(colorString)
        setBorderColor(color)
    }

    fun setRadarGradientColor(colors: IntArray) {
        val shader = SweepGradient(measuredWidth.toFloat() / 2,
            measuredHeight.toFloat() / 2, colors, null)
        mOutlinePaint.shader = shader
        invalidate()
    }

    fun setBorderWidth(width: Float) {
        this.mBorderWidth = width
        invalidate()
    }

    private fun setParamUpdate() {
        mOutlineRect = Rect(0, 0, measuredWidth, measuredHeight)

        mBitmapMaxSize = measuredWidth.toFloat() / mScaleFactor
    }

    private fun measureDimension(spec: Int) =  when (MeasureSpec.getMode(spec)) {
        MeasureSpec.EXACTLY -> {
            // exactly number or match_parent
            MeasureSpec.getSize(spec)
        }
        MeasureSpec.AT_MOST -> {
            // wrap_content
            min(mDefaultSize, MeasureSpec.getSize(spec))
        }
        else -> {
            mDefaultSize
        }
    }

    override fun setBackground(background: Drawable?) {
        // 取消传统背景设置
//        super.setBackground(background)
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        // draw outside circle (background)
        canvas?.drawCircle(measuredWidth.toFloat() / 2, measuredHeight.toFloat() / 2, measuredWidth.toFloat() / 2, mOutlinePaint)
        if (mBorderWidth > 0F && mOutlinePaint.shader == null) {
            drawBorder(canvas)
        }

        canvas?.drawArc(mOutlineRect.toRectF(), mStartAngle, mSweepAngle, true, mSweepPaint)

        if (!mTargetList.isNullOrEmpty() && !mIsAnimating) {
            drawTarget(canvas)
        }

        // draw center circle
        canvas?.drawCircle(measuredWidth.toFloat() / 2, measuredHeight.toFloat() / 2, measuredWidth.toFloat() / 2 / mScaleFactor, mPaint)
    }

    private fun drawBorder(canvas: Canvas?) {
        Log.i("RadarView", "drawBorder")
        mOutlinePaint.style = Paint.Style.STROKE
        mOutlinePaint.color = mBorderColor
        mOutlinePaint.strokeWidth = mBorderWidth
        canvas?.drawCircle(measuredWidth.toFloat() / 2, measuredHeight.toFloat() / 2,
            (measuredWidth.toFloat() - mBorderWidth) / 2, mOutlinePaint)
        // 还原
        mOutlinePaint.style = Paint.Style.FILL_AND_STROKE
        mOutlinePaint.color = mBackgroundColor
    }

    private fun drawTarget(canvas: Canvas?) {
        mTargetList?.let {
            Log.e("RadarView", "draw target")
            for (target in it) {
                if (mNeedBitmap) {
                    canvas?.drawBitmap(mBitmap, target.x - mBitmap.width / 2,
                        target.y - mBitmap.height / 2, mTargetPaint)
                } else {
                    canvas?.drawCircle(target.x, target.y, mTargetRadius, mTargetPaint)
                }
            }
        }
    }

    fun setBitmapEnabled(enabled: Boolean, drawable: Drawable) {
        // 这里是为了防止界面还未获取到宽高时 会导致onMeasure走不到 那么maxSize就会为0
        post {
            this.mNeedBitmap = enabled
            this.mBitmapWHRatio = drawable.intrinsicWidth.toFloat() / drawable.intrinsicHeight.toFloat()
            mBitmap = if (mBitmapWHRatio >= 1) {
                // 宽比高大
                drawable.toBitmap(
                    width = min(mBitmapMaxSize, drawable.intrinsicWidth.toFloat()).toInt(),
                    height = (min(mBitmapMaxSize, drawable.intrinsicWidth.toFloat()) / mBitmapWHRatio).toInt(),
                    config = Bitmap.Config.ARGB_8888)
            } else {
                // 高比宽大
                drawable.toBitmap(
                    height = min(mBitmapMaxSize, drawable.intrinsicHeight.toFloat()).toInt(),
                    width = (min(mBitmapMaxSize, drawable.intrinsicHeight.toFloat()) * mBitmapWHRatio).toInt(),
                    config = Bitmap.Config.ARGB_8888
                )
            }
        }
    }

    // 随机落点
    fun addTarget(size: Int, type: TYPE = TYPE.RANDOM) {
        val list = ArrayList<PointF>()
        val r = measuredWidth.toFloat() / 2
        val innerRect = Rect((r - r / mScaleFactor).toInt(), (r - r / mScaleFactor).toInt(),
            (r + r / mScaleFactor).toInt(), (r + r / mScaleFactor).toInt())
        // 圆的中心点
        val circle = PointF(measuredWidth.toFloat() / 2, measuredHeight.toFloat() / 2)
        while (list.size < size) {
            val ranX = Random.nextDouble(0.0, r * 2.0).toFloat()
            val ranY = Random.nextDouble(0.0, r * 2.0).toFloat()
            val ranPointF = PointF(ranX, ranY)
            if (innerRect.contains(ranPointF.toPoint())) {
                continue
            }
            // 圆公式
            if (!mNeedBitmap &&
                (ranX - circle.x).pow(2) + (ranY - circle.y).pow(2) <
                  (r - mTargetRadius - mBorderWidth).toDouble().pow(2.0)) {
                // 在圆内
                addTargetFromType(type, list, ranX, ranY, r, ranPointF)
            } else if (mNeedBitmap &&
                (ranX - circle.x).pow(2) + (ranY - circle.y).pow(2) <
                  (r - mBorderWidth - max(mBitmap.width, mBitmap.height) / 2).toDouble().pow(2)) {
                addTargetFromType(type, list, ranX, ranY, r, ranPointF)
            } else {
                continue
            }
        }
        mTargetList = list
        for (target in list) {
            Log.i("RadarView", "target = [${target.x}, ${target.y}]")
        }
        invalidate()
    }

    private fun addTargetFromType(type: TYPE, list: ArrayList<PointF>, ranX: Float, ranY: Float,
                                  r: Float, ranPointF: PointF) {
        when (type) {
            TYPE.RANDOM -> {
                list.add(ranPointF)
            }
            TYPE.FOURTH -> {
                if (ranX in r.toDouble()..2 * r.toDouble() && ranY in r.toDouble()..2 * r.toDouble()) {
                    list.add(ranPointF)
                }
            }
            TYPE.THIRD -> {
                if (ranX in 0.0..r.toDouble() && ranY in r.toDouble()..2 * r.toDouble()) {
                    list.add(ranPointF)
                }
            }
            TYPE.SECOND -> {
                if (ranX in 0.0..r.toDouble() && ranY in 0.0..r.toDouble()) {
                    list.add(ranPointF)
                }
            }
            TYPE.FIRST -> {
                if (ranX in r.toDouble()..2 * r.toDouble() && ranY in 0.0..r.toDouble()) {
                    list.add(ranPointF)
                }
            }
        }
    }

    fun start() {
        Log.i("RadarView", "animation start")
        mIsAnimating = true
        mAnimator.duration = 2000
        mAnimator.repeatCount = ValueAnimator.INFINITE
        mAnimator.addUpdateListener {
            val angle = it.animatedValue as Float
            mStartAngle = angle

            Log.i("RadarView", "mStartAngle = $mStartAngle and curValue = ${it.animatedValue}")
            postInvalidate()
        }
        mAnimator.start()
    }

    fun start(startVal: Float, endVal: Float) {
        mIsAnimating = true
        mAnimator.setFloatValues(startVal, endVal)
        mAnimator.duration = 2000
        mAnimator.repeatCount = ValueAnimator.INFINITE
        mAnimator.addUpdateListener {
            mStartAngle = it.animatedValue as Float

            Log.i("RadarView", "mStartAngle = $mStartAngle and curValue = ${it.animatedValue}")
            postInvalidate()
        }
        mAnimator.start()
    }

    fun stop() {
        mIsAnimating = false
        if (mAnimator.isRunning) {
            mAnimator.cancel()
            mAnimator.removeAllListeners()
        }
        mStartAngle = 0F
    }

}

调用方式

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    radar_view.setBorderWidth(5F)
    radar_view.setRadarColor(Color.TRANSPARENT)
    radar_view.setBitmapEnabled(true, resources.getDrawable(R.mipmap.ic_launcher_round))
//        radar_view.setScanColors(intArrayOf(Color.RED, Color.LTGRAY, Color.CYAN))
//        radar_view.setRadarGradientColor(intArrayOf(Color.RED, Color.GREEN, Color.BLUE))

    btn_start.setOnClickListener {
        radar_view.start()
//        workThreadAndCallback()
    }

    btn_stop.setOnClickListener {
        radar_view.stop()
        radar_view.addTarget(7)
    }
}

 

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
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 来绘制闭合的数据区域。最后,我们再将数据区域填充上颜色。 这样,一个简单的雷达图就完成了。当然,这只是一个基础的实现,你可以根据自己的需求来进行更多的定制。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值