原创首发
自定义控件之QQ附近的人雷达扫描效果

前言
之前跟着康师傅学习 Android开发自定义控件系列课程时看到 笔记Android自定义控件的分类 里有这么一个效果,如下图
然后我通过搜索引擎搜到了这篇文章(Android超高仿QQ附近的人搜索展示),根据这篇文章我对源码进行了重构、优化,实现了 使用普通自定义 View 实现雷达扫描效果版本 和 使用自定义 SurfaceView 实现雷达扫描效果版本,具体实现过程就不讲了,同学们看代码和注释吧!
自定义 View 与 自定义 SurfaceView
那么,我们来看看 普通自定义 View 和 自定义 SurfaceView 之间的关系吧!
What is a SurfaceView ?
- A view in your app's view hierarchy that has its own separate surface.
- A view that directly accesses a lower-level drawing surface.
- A view that is not part of the view hierarchy.
- A view that can be drawn to from a separate thread.
什么是 SurfaceView ?
- 应用程序的视图层次结构中的一个视图,它有自己独立的表面。
- 直接访问低层绘图图面的视图。
- 不属于视图层次结构的视图。
- 可以从单独的线程绘制的视图。
What is the most distinguishing benefit of using a SurfaceView ?
- A SurfaceView can make an app more responsive to user input.
- You can move drawing operations away from the UI thread.
- Your animations may run more smoothly.
使用 SurfaceView 最显著的好处是什么?
- SurfaceView 可以让应用对用户输入的响应更灵敏。
- 你可以把绘图操作从 UI 线程移开。
- 你的动画可以运行得更流畅。
When should you consider using a SurfaceView ? Select up to three.
- When your app does a lot of drawing, or does complex drawing.
- When your app combines complex graphics with user interaction.
- When your app uses a lot of images as backgrounds.
- When your app stutters, and moving drawing off the UI thread could improve performance.
什么时候应该考虑使用 SurfaceView ?
- 当你的应用需要大量绘图或复杂绘图时。
- 当你的应用程序结合了复杂的图形和用户交互。
- 当你的应用程序使用很多图片作为背景时。
- 当你的应用程序出现问题时,移动 UI 线程可以提高性能。
以上摘抄自 谷歌官方文档 ,当然里面有一个小例子,同学们可以跟着官方的例子去实现自定义 SurfaceView 哈~
最显著的区别就是普通view和它的宿主窗口共享一个绘图表面(Surface),SurfaceView 虽然也在 View 的树形结构中,但是它有属于自己的绘图表面,Surface 内部持有一个 Canvas ,可以利用这个 Canvas 绘制。
资源图片
universe_bg.jpg
circle_photo.png
使用普通自定义 View 实现雷达扫描效果版本
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.util.Log
import android.view.View
import cn.cqautotest.imui.R
import kotlin.math.min
/**
* 使用普通自定义 View 实现雷达扫描效果
*
* @property drawing Boolean
* @property mPaintBackground Paint
* @property mPaintLine Paint
* @property mPaintCircle Paint
* @property mPaintScan Paint
* @property mDiameter Int
* @property mRadius Int
* @property mRect Rect
* @property mMatrix Matrix
* @property mCurrentScanRotateAngle Int
* @property mBackgroundMipmap (android.graphics.Bitmap..android.graphics.Bitmap?)
* @property mCenterBitmap Bitmap
* @property mCircleProportions Array<Float>
* @property mScanRotateSpeed Int
* @property mCurrentScanCount Int
* @property mMaxScanItemCount Int
* @property mCurrentScanItem Int
* @property mScan Boolean
* @property mListener IScanListener
*/
class RadarScanView : View {
// 是否绘制
private var drawing: Boolean = false
// 画背景需要用到的画笔
private val mPaintBackground: Paint by lazy {
Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
}
}
// 画圆线需要用到的画笔
private val mPaintLine: Paint by lazy {
Paint().apply {
color = Color.parseColor("#3C8EAE")
isAntiAlias = true
strokeWidth = 1f
style = Paint.Style.STROKE
}
}
// 画圆需要用到的 paint画笔
private val mPaintCircle: Paint by lazy {
Paint().apply {
color = Color.WHITE
isAntiAlias = true
}
}
// 画扫描需要用到的画笔
private val mPaintScan: Paint by lazy {
Paint().apply {
style = Paint.Style.FILL_AND_STROKE
}
}
// 圆的直径大小
private var mDiameter: Int = 0
// 圆的半径大小
private var mRadius: Int = 0
// 矩形
private lateinit var mRect: Rect
// 旋转需要使用到的矩阵
private val mMatrix by lazy {
Matrix()
}
// 当前旋转的角度
private var mCurrentScanRotateAngle: Int = 0
// 背景的图片资源
private val mBackgroundMipmap = BitmapFactory.decodeResource(resources, R.drawable.universe_bg)
// 中间的图片资源
private var mCenterBitmap: Bitmap =
BitmapFactory.decodeResource(resources, R.drawable.circle_photo)
// 每层圆圈所占的比例
private val mCircleProportions: Array<Float> =
arrayOf(1 / 13f, 2 / 13f, 3 / 13f, 4 / 13f, 5 / 13f, 6 / 13f)
// 扫描时的旋转速度
private var mScanRotateSpeed = 5
// 当前扫描的次数
private var mCurrentScanCount: Int = 0
// 最大扫描次数
private var mMaxScanItemCount: Int = 1
// 当前扫描显示的 Item
private var mCurrentScanItem: Int = 0
// 只有设置数据后才开始扫描
private var mScan: Boolean = false
// 扫描时的回调接口
private lateinit var mListener: IScanListener
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
init {
drawing = true
// 保持屏幕常亮
keepScreenOn = true
// 设置在触摸模式下此视图是否可以接收焦点。将其设置为true也将确保该视图可聚焦。
isFocusableInTouchMode = true
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
drawing = true
post(object : Runnable {
override fun run() {
if (drawing) {
calc()
invalidate()
// 可以通过改变 postDelayed() 方法的第二个参数 delayMillis 调节扫描的速度
postDelayed(this, 10)
} else {
removeCallbacks(this)
}
}
})
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
drawing = false
}
/**
* 进行计算
*/
private fun calc() {
mCurrentScanRotateAngle = (mCurrentScanRotateAngle + mScanRotateSpeed) % 360
mMatrix.postRotate(mScanRotateSpeed.toFloat(), mRadius.toFloat(), mRadius.toFloat())
// 开始扫描显示标志为 true 且 只扫描一周
if (mScan && mCurrentScanCount <= (360 / mScanRotateSpeed) && ::mListener.isInitialized) {
if (mCurrentScanCount % mScanRotateSpeed == 0 && mCurrentScanItem < mMaxScanItemCount) {
mListener.onScanning(mCurrentScanItem, mCurrentScanRotateAngle)
mCurrentScanItem++
}
if (mCurrentScanItem == mMaxScanItemCount) {
mListener.onScanSuccess()
}
mCurrentScanCount++
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 测量
setMeasuredDimension(getMeasureSize(widthMeasureSpec), getMeasureSize(heightMeasureSpec))
}
/**
* 获取尺寸
*
* @param measureSpec Int
* @return Int
*/
private fun getMeasureSize(measureSpec: Int): Int = run {
val specMode = MeasureSpec.getMode(measureSpec)
val specSize = MeasureSpec.getSize(measureSpec)
if (specMode == MeasureSpec.EXACTLY) specSize else {
// 指定测量大小
if (specMode == MeasureSpec.AT_MOST) min(300, specSize) else 300
}
}
/**
* 当视图的大小发生变化时,这个方法会在布局过程中调用
*
* @param w Int
* @param h Int
* @param oldw Int
* @param oldh Int
*/
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
mDiameter = min(w, h)
mRadius = mDiameter / 2
mRect = Rect(
(mRadius - mDiameter * mCircleProportions[0]).toInt(),
(mRadius - mDiameter * mCircleProportions[0]).toInt(),
(mRadius + mDiameter * mCircleProportions[0]).toInt(),
(mRadius + mDiameter * mCircleProportions[0]).toInt()
)
Log.d(TAG, "onSizeChanged: ====>w:$w mRadius:$mRadius mDiameter:$mDiameter")
}
/**
* 在这个方法内进行重绘界面的操作
*
* @param canvas Canvas
*/
override fun onDraw(canvas: Canvas) {
// 绘制的层级由调用的先后顺序决定,先绘制的在下面,后绘制的在上面
drawBackground(canvas)
drawCircle(canvas)
drawScan(canvas)
drawCenterIcon(canvas)
}
/**
* 绘制背景
*
* @param canvas Canvas
*/
protected fun drawBackground(canvas: Canvas) {
canvas.drawBitmap(mBackgroundMipmap, 0f, 0f, mPaintBackground)
}
/**
* 绘制圆线圈
*
* @param canvas Canvas
*/
private fun drawCircle(canvas: Canvas) {
canvas.run {
for (i in 1..5) {
drawCircle(
mRadius.toFloat(),
mRadius.toFloat(),
mDiameter * mCircleProportions[i],
mPaintLine
)
}
}
}
/**
* 绘制扫描部分
*
* @param canvas Canvas
*/
private fun drawScan(canvas: Canvas) {
canvas.run {
save()
// 只赋值一次,避免在重绘时重复创建对象
if (mPaintScan.shader == null) {
mPaintScan.shader = SweepGradient(
mRadius.toFloat(), mRadius.toFloat(),
intArrayOf(Color.TRANSPARENT, Color.parseColor("#84B5CA")),
null
)
}
concat(mMatrix)
drawCircle(
mRadius.toFloat(), mRadius.toFloat(), mDiameter * mCircleProportions[4],
mPaintScan
)
restore()
}
}
/**
* 绘制中间的圆形图标
*
* @param canvas Canvas
*/
private fun drawCenterIcon(canvas: Canvas) {
canvas.drawBitmap(mCenterBitmap, null, mRect, mPaintCircle)
}
interface IScanListener {
/**
* 正在扫描(此时还没有扫描完毕)的监听
*
* @param position Int 扫描的位置
* @param scanAngle Int 扫描的角度
*/
fun onScanning(position: Int, scanAngle: Int)
/**
* 扫描成功时的回调
*/
fun onScanSuccess()
}
companion object {
private const val TAG = "RadarScanView"
}
}
使用自定义 SurfaceView 实现雷达扫描效果版本
import android.content.Context
import android.graphics.*
import android.os.Build
import android.util.AttributeSet
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.annotation.RequiresApi
import cn.cqautotest.imui.R
import kotlin.math.min
/**
* 使用自定义 SurfaceView 实现雷达扫描效果
*
* @property drawing Boolean
* @property mSurfaceHolder SurfaceHolder
* @property mPaintBackground Paint
* @property mPaintLine Paint
* @property mPaintCircle Paint
* @property mPaintScan Paint
* @property mWidth Int
* @property mHeight Int
* @property mMatrix Matrix
* @property mCurrentScanRotateAngle Int
* @property mBackgroundMipmap (android.graphics.Bitmap..android.graphics.Bitmap?)
* @property mCenterBitmap Bitmap
* @property mCircleProportions Array<Float>
* @property mScanRotateSpeed Int
* @property mCurrentScanCount Int
* @property mMaxScanItemCount Int
* @property mCurrentScanItem Int
* @property mScan Boolean
* @property mListener IScanListener
*/
class RadarScanSurfaceView : SurfaceView, SurfaceHolder.Callback, Runnable {
// 是否绘制
private var drawing: Boolean = false
private val mSurfaceHolder: SurfaceHolder by lazy {
holder
}
// 画背景需要用到的画笔
private val mPaintBackground: Paint by lazy {
Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
}
}
// 画圆线需要用到的画笔
private val mPaintLine: Paint by lazy {
Paint().apply {
color = Color.parseColor("#3C8EAE")
isAntiAlias = true
strokeWidth = 1f
style = Paint.Style.STROKE
}
}
// 画圆需要用到的 paint画笔
private val mPaintCircle: Paint by lazy {
Paint().apply {
color = Color.WHITE
isAntiAlias = true
}
}
// 画扫描需要用到的画笔
private val mPaintScan: Paint by lazy {
Paint().apply {
style = Paint.Style.FILL_AND_STROKE
}
}
// 圆的直径大小
private var mDiameter: Int = 0
// 圆的半径大小
private var mRadius: Int = 0
// 旋转需要使用到的矩阵
private val mMatrix by lazy {
Matrix()
}
// 当前旋转的角度
private var mCurrentScanRotateAngle: Int = 0
// 背景的图片资源
private val mBackgroundMipmap = BitmapFactory.decodeResource(resources, R.drawable.universe_bg)
// 中间的图片资源
private var mCenterBitmap: Bitmap =
BitmapFactory.decodeResource(resources, R.drawable.circle_photo)
// 每层圆圈所占的比例
private val mCircleProportions: Array<Float> =
arrayOf(1 / 13f, 2 / 13f, 3 / 13f, 4 / 13f, 5 / 13f, 6 / 13f)
// 扫描时的旋转速度
private var mScanRotateSpeed = 5
// 当前扫描的次数
private var mCurrentScanCount: Int = 0
// 最大扫描次数
private var mMaxScanItemCount: Int = 1
// 当前扫描显示的 Item
private var mCurrentScanItem: Int = 0
// 只有设置数据后才开始扫描
private var mScan: Boolean = false
// 扫描时的回调接口
private lateinit var mListener: RadarScanSurfaceView.IScanListener
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
init {
drawing = true
// 保持屏幕常亮
keepScreenOn = true
// 设置在触摸模式下此视图是否可以接收焦点。将其设置为true也将确保该视图可聚焦。
isFocusableInTouchMode = true
//注册回调方法
mSurfaceHolder.addCallback(this)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 测量
setMeasuredDimension(getMeasureSize(widthMeasureSpec), getMeasureSize(heightMeasureSpec))
}
/**
* 获取尺寸
*
* @param measureSpec Int
* @return Int
*/
private fun getMeasureSize(measureSpec: Int): Int = run {
val specMode = MeasureSpec.getMode(measureSpec)
val specSize = MeasureSpec.getSize(measureSpec)
if (specMode == MeasureSpec.EXACTLY) specSize else {
if (specMode == MeasureSpec.AT_MOST) min(300, specSize) else 300
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
mDiameter = min(w, h)
mRadius = mDiameter / 2
}
/**
* Surface 创建时触发
*
* @param holder SurfaceHolder
*/
override fun surfaceCreated(holder: SurfaceHolder) {
drawing = true
// 创建子线程,在子线程中进行持续绘制
Thread(this).start()
}
/**
* Surface 大小或格式发生变化时触发,在 surfaceCreated() 方法调用后该函数至少会被调用一次
*
* @param holder SurfaceHolder
* @param format Int
* @param width Int
* @param height Int
*/
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
}
/**
* 销毁时触发,一般不可见时就会销毁
*
* @param holder SurfaceHolder
*/
override fun surfaceDestroyed(holder: SurfaceHolder) {
drawing = false
}
@RequiresApi(Build.VERSION_CODES.O)
override fun run() {
while (drawing) {
mCurrentScanRotateAngle = (mCurrentScanRotateAngle + mScanRotateSpeed) % 360
mMatrix.postRotate(mScanRotateSpeed.toFloat(), mRadius.toFloat(), mRadius.toFloat())
// 捕获以下 lambda 内的所有异常
runCatching {
// 锁定并获取硬件画布
val canvas = holder.lockHardwareCanvas()
// 绘制的层级由调用的先后顺序决定,先绘制的在下面,后绘制的在上面
drawBackground(canvas)
drawCircle(canvas)
drawScan(canvas)
drawCenterIcon(canvas)
// 可以通过阻塞子线程的时间来调节雷达扫描的速度
Thread.sleep(20)
// 解锁画布并发布
holder.unlockCanvasAndPost(canvas)
}
// 开始扫描显示标志为 true 且 只扫描一周
if (mScan && mCurrentScanCount <= (360 / mScanRotateSpeed) && ::mListener.isInitialized) {
if (mCurrentScanCount % mScanRotateSpeed == 0 && mCurrentScanItem < mMaxScanItemCount) {
mListener.onScanning(mCurrentScanItem, mCurrentScanRotateAngle)
mCurrentScanItem++
}
if (mCurrentScanItem == mMaxScanItemCount) {
mListener.onScanSuccess()
}
mCurrentScanCount++
}
}
}
/**
* 绘制背景
*
* @param canvas Canvas
*/
protected fun drawBackground(canvas: Canvas) {
canvas.drawBitmap(mBackgroundMipmap, 0f, 0f, mPaintBackground)
}
/**
* 绘制圆线圈
*
* @param canvas Canvas
*/
private fun drawCircle(canvas: Canvas) {
canvas.run {
for (i in 1..5) {
drawCircle(
mRadius.toFloat(),
mRadius.toFloat(),
mDiameter * mCircleProportions[i],
mPaintLine
)
}
}
}
/**
* 绘制扫描部分
*
* @param canvas Canvas
*/
private fun drawScan(canvas: Canvas) {
canvas.run {
save()
// 只赋值一次
if (mPaintScan.shader == null) {
mPaintScan.shader = SweepGradient(
mRadius.toFloat(), mRadius.toFloat(),
intArrayOf(Color.TRANSPARENT, Color.parseColor("#84B5CA")),
null
)
}
concat(mMatrix)
drawCircle(
mRadius.toFloat(),
mRadius.toFloat(),
mDiameter * mCircleProportions[5],
mPaintScan
)
restore()
}
}
/**
* 绘制中间的圆形图标
*
* @param canvas Canvas
*/
private fun drawCenterIcon(canvas: Canvas) {
canvas.drawBitmap(
mCenterBitmap, null,
Rect(
(mRadius - mDiameter * mCircleProportions[0]).toInt(),
(mRadius - mDiameter * mCircleProportions[0]).toInt(),
(mRadius + mDiameter * mCircleProportions[0]).toInt(),
(mRadius + mDiameter * mCircleProportions[0]).toInt()
), mPaintCircle
)
}
interface IScanListener {
/**
* 正在扫描(此时还没有扫描完毕)的监听
*
* @param position Int 扫描的位置
* @param scanAngle Int 扫描的角度
*/
fun onScanning(position: Int, scanAngle: Int)
/**
* 扫描成功时的回调
*/
fun onScanSuccess()
}
companion object {
private const val TAG = "RadarScanSurfaceView"
}
}
实现效果图
GIF 图录制出来有点卡顿,所以就截图了,大家知道是在一直扫描的就好啦~
请同学们点赞、评论、打赏+关注啦~
本文由
A lonely cat
原创发布于
阳光沙滩
,未经作者授权,禁止转载