Android实现雷达View效果的示例代码

时间:2022-07-26
本文章向大家介绍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来共同使用,目前也只是开发阶段,扩展性可能考虑得还不是特别充足,也还没应用到具体项目中。但是,总觉得自己想的功能也该试着去实践一下~

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

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

那么,对应的公式就为:

要注意的是,这里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)
}
}

总结

到此这篇关于Android实现雷达View效果的文章就介绍到这了,更多相关android 雷达View效果内容请搜索ZaLou.Cn以前的文章或继续浏览下面的相关文章希望大家以后多多支持ZaLou.Cn!