自定义控件之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 绘制。

摘抄自:SurfaceView和普通view的区别及简单使用


资源图片

universe_bg.jpg


circle_photo.png

使用普通自定义 View 实现雷达扫描效果版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
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 实现雷达扫描效果版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
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 图录制出来有点卡顿,所以就截图了,大家知道是在一直扫描的就好啦~



请同学们点赞、评论、打赏+关注啦~


自定义控件之QQ附近的人雷达扫描效果
https://blog.funkt.cn/2021/02/10/1359331427635777536/
作者
anjiemo
发布于
2021年2月10日
许可协议
未经作者授权,禁止转载