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

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章