自定义View之气泡

741 阅读3分钟

最近看海洋世界相关的纪录片看到水下的气泡 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 则根据实际情况与功能需要来处理

预览图:

Screen_recording_20250217_130919.gif

以下为完整代码:

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

}