最近看海洋世界相关的纪录片看到水下的气泡 bulubulu 的特别好看,于是想着:我也要做一个!
说干就干,大致思路是写一个自定义 View ,绘制 pointlist,通过 touch 事件更新当前手指触摸位置,并更新 point 的大小与位置实现动画。
那么我们需要做哪些工作?
1:新建 Point 以及 pointlist
给出 Point 的成员变量,如坐标,半径,运动方向,运动速度 等数据。pointList 则用来存储当前需要显示的点(这里新建一个类 PointPool 对 pointList 进行管理)
2:重写 onTouchEvent
通过 Down 与 Move 方法,更新当前手指所在的坐标,方便后续添加 point
3:重写 onDraw,以及新建绘制方法
调用 canvas.drawCircle 函数,绘制 pointList 内所有的点
4:新建用于实现动画的 handler
这里使用 handler 来处理绘制消息,收到消息则更新 pointList 并调用 invalidate 重绘
handler 内存泄露是个老问题了,注意使用弱引用,静态内部类,以及在合适的时机移除消息等手段避免这个问题
5:重写 onMeasure
为了解决 wrap_content 与 match_parent 显示一致的问题,这也是自定义 View 必做的工作了。
代表 match_parent 的 EXACTLY 直接测量就行,AT—MOST 与 UNSPECIFIED 则根据实际情况与功能需要来处理
预览图:
以下为完整代码:
class PixelEffectView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
companion object {
private const val MSG_UPDATE_EFFECT = 1
}
private val pointPool = PointPool(50)
private var isLooping = false
private var isTouching = false
private var currentLocation = Point(0F, 0F)
private val handler = UpdateHandler(this)
private var desiredWidth: Int = dp2px(200).toInt()
private var desiredHeight: Int = dp2px(200).toInt()
private val paint: Paint
init {
context.theme.obtainStyledAttributes(
attrs,
R.styleable.PixelEffectView,
0, 0
).apply {
try {
val pointColor =
getDimensionPixelSize(R.styleable.PixelEffectView_pointColor, R.color.blue_400)
paint = Paint().apply {
color = ContextCompat.getColor(context, pointColor)
}
} finally {
recycle()
}
}
}
//不重写会导致wrap_content出现match_parent的效果
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = when (MeasureSpec.getMode(widthMeasureSpec)) {
MeasureSpec.EXACTLY -> MeasureSpec.getSize(widthMeasureSpec)
MeasureSpec.AT_MOST -> desiredWidth.coerceAtMost(MeasureSpec.getSize(widthMeasureSpec))
else -> desiredWidth
}
val height = when (MeasureSpec.getMode(heightMeasureSpec)) {
MeasureSpec.EXACTLY -> MeasureSpec.getSize(heightMeasureSpec)
MeasureSpec.AT_MOST -> desiredHeight.coerceAtMost(MeasureSpec.getSize(heightMeasureSpec))
else -> desiredHeight
}
setMeasuredDimension(width, height)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
drawPoints(canvas)
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
currentLocation.x = event.x
currentLocation.y = event.y
isTouching = true
startUpdateLoop()
}
MotionEvent.ACTION_MOVE -> {
if (isTouchOutsideBounds(this,event)){
isTouching = false
}else{
currentLocation.x = event.x
currentLocation.y = event.y
isTouching = true
startUpdateLoop()
}
}
else -> {
isTouching = false
}
}
return true
}
private class UpdateHandler(view: PixelEffectView) : Handler(Looper.getMainLooper()) {
private val viewRef = WeakReference(view)
override fun handleMessage(msg: Message) {
val view = viewRef.get()
if (view != null) {
if (msg.what == MSG_UPDATE_EFFECT) {
view.updateData()
view.invalidate()
if (view.pointPool.isNoActiveExist()) {
view.stopUpdateLoop()
}
if (view.isLooping) {
sendEmptyMessageDelayed(MSG_UPDATE_EFFECT, 17L)
}
}
}
}
}
private fun isTouchOutsideBounds(view: View, event: MotionEvent): Boolean {
val left = 0
val top = 0
val right = view.width
val bottom = view.height
return event.x < left || event.x > right || event.y < top || event.y > bottom
}
private fun startUpdateLoop() {
if (isLooping) return
isLooping = true
handler.sendEmptyMessage(MSG_UPDATE_EFFECT)
}
private fun stopUpdateLoop() {
isLooping = false
handler.removeMessages(MSG_UPDATE_EFFECT)
}
private fun updateData() {
if (isTouching) {
//只有在触摸的时候,才会尝试往 activeList 里添加 point
pointPool.addPoint2PointList(currentLocation.x, currentLocation.y)
}
if (isLooping) {
pointPool.updateActiveList()
}
}
private fun drawPoints(canvas: Canvas) {
pointPool.getActiveList().forEach {
canvas.drawCircle(it.x, it.y, it.radius, paint)
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
handler.removeMessages(MSG_UPDATE_EFFECT)
}
}
data class Point(
var x: Float,
var y: Float,
var radius: Float = 50F,
var direction: Double = Random.nextDouble() * 2 * Math.PI,
var speed: Float = Random.nextFloat() * 8F,
) {
fun isActive() = radius > 0
fun reset(x: Float, y: Float, radius: Float = 50F) {
this.x = x
this.y = y
this.radius = radius
direction = Random.nextDouble() * 2 * Math.PI
speed = Random.nextFloat() * 8F
}
fun update() {
radius -= 1F
x += speed * cos(direction).toFloat()
y += speed * sin(direction).toFloat()
}
}
//使用 PointPool 来管理 point 的添加、重置与刷新
class PointPool(private val maxSize: Int) {
private val pointList = mutableListOf<Point>()
fun getActiveList() = pointList
fun isNoActiveExist() = pointList.firstOrNull { it.isActive() } == null
fun updateActiveList() {
pointList.filter {
it.isActive()
}.forEach {
it.update()
}
}
fun addPoint2PointList(x: Float, y: Float) {
if (pointList.size >= maxSize) {
//数量到达上限了,需要重置point并重复利用
pointList.firstOrNull {
it.isActive().not()
}?.reset(x,y)
} else {
//数量没到上限,直接往里加新的 Point
pointList.add(Point(x, y))
}
}
}