🌨️ "你那里的冬天下雪了吗?我的View,雪花正在缓缓飘落" 🌨️

背景

随着冬季的脚步越来越远,南方的我今年就看了一场雪,下一场雪遥遥无期。

那我们来实现一个自定义的 View,它能模拟雪花飘落的景象。我们一起来看一下如何让这些数字雪花在屏幕上轻盈地飞舞。

一个雪球下落

我们绘制一个圆,让其匀速下落,当超出屏幕就刷新:

kotlin 复制代码
private val mSnowPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    color = Color.WHITE
    style = Style.FILL
}
// 雪花的位置
private var mPositionX = 300f
private var mPositionY = 0f
private var mSize = 20f // 雪花的大小

override fun draw(canvas: Canvas) {
    super.draw(canvas)
    canvas.drawCircle(mPositionX, mPositionY, mSize, mSnowPaint)
    updateSnow()
}

private fun updateSnow() {
    mPositionY += 10f
    if (mPositionY > height) {
        mPositionY = 0f
    }
    postInvalidateOnAnimation()
}

效果如下:

多个雪球下落

我们先简单的写个雪花数据类:

kotlin 复制代码
data class SnowItem(
    val size: Float,
    var positionX: Float,
    var positionY: Float,
    val downSpeed: Float
)

生成50个雪花:

kotlin 复制代码
private fun createSnowItemList(): List<SnowItem> {
    val snowItemList = mutableListOf<SnowItem>()
    val minSize = 10
    val maxSize = 20
    for (i in 0..50) {
        val size = mRandom.nextInt(maxSize - minSize) + minSize
        val positionX = mRandom.nextInt(width)
        val speed = size.toFloat()
        val snowItem = SnowItem(size.toFloat(), positionX.toFloat(), 0f, speed)
        snowItemList.add(snowItem)
    }
    return snowItemList
}

来看一下50个雪花的效果:

kotlin 复制代码
private lateinit var mSnowItemList: List<SnowItem>

//需要拿到width,所以在onSizeChanged之后创建itemList
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    mSnowItemList = createSnowItemList() 
}
    
override fun draw(canvas: Canvas) {
    super.draw(canvas)
    for (snowItem in mSnowItemList) {
        canvas.drawCircle(snowItem.positionX, snowItem.positionY, snowItem.size, mSnowPaint)
        updateSnow(snowItem)
    }
    postInvalidateOnAnimation()
}

private fun updateSnow(snowItem: SnowItem) {
    snowItem.positionY += snowItem.downSpeed
    if (snowItem.positionY > height) {
        snowItem.positionY = 0f
    }
}

弦波动:让雪花有飘落的感觉

上面的雪花是降落的,不是很逼真,我们如何让雪花有飘落的感觉了?我们可以给水平/竖直方向都加上弦波动。

我们这里是以所有雪花为一个整体 做弦波动。

理解一下这句话的意思,就是说所有的雪花水平/竖直方向波动符合一个弦波动,而不是单个雪花的运动符合弦波动。

[想象一下如果每个雪花都在左右扭动,数量一多,是不是就很乱!]

我们结合代码在理解一下上述的话,记得看一下注释:

kotlin 复制代码
// 通过角度->转为弧度的值->正弦/余弦的值
val angleMax = 10
val leftOrRight = mRandom.nextBoolean() //true: left, false: right
val angle = mRandom.nextDouble() * angleMax
val radians = if (leftOrRight) {
    Math.toRadians(-angle)
} else {
    Math.toRadians(angle)
}
//正弦 在[-90度,90度]分正负,所以给x方向,区分左右
val speedX = speed * sin(radians).toFloat()
val speedY = speed * cos(radians).toFloat()
//speedX和speedY随机后,就确定下来,
//就是说某个雪花的speedX和speedY在下落的过程中是确定的
//即所有雪花为一个整体做弦波动

我们需要添加水平方向的速度,所以我们需要修改SnowItem类:

kotlin 复制代码
data class SnowItem(
    val size: Float,
    val originalPosX: Int,
    var positionX: Float,
    var positionY: Float,
    val speedX: Float,
    val speedY: Float
)

修改完后,我们看一下SnowItem的创建:

kotlin 复制代码
private fun createSnowItemList(): List<SnowItem> {
    val snowItemList = mutableListOf<SnowItem>()
    val minSize = 10
    val maxSize = 20
    for (i in 0..50) {
        val size = mRandom.nextInt(maxSize - minSize) + minSize
        val speed = size.toFloat()
        //这一部分看上面代码的注释
        val angleMax = 10
        val leftOrRight = mRandom.nextBoolean()
        val angle = mRandom.nextDouble() * angleMax
        val radians = if (leftOrRight) {
            Math.toRadians(-angle)
        } else {
            Math.toRadians(angle)
        }
        val speedX = speed * sin(radians).toFloat()
        val speedY = speed * cos(radians).toFloat()
        val positionX = mRandom.nextInt(width)
        //snowItem创建
        val snowItem = SnowItem(
            size.toFloat(),
            positionX.toFloat(),
            positionX.toFloat(),
            0f,
            speedX,
            speedY
        )
        snowItemList.add(snowItem)
    }
    return snowItemList
}

雪花位置更新如下:

kotlin 复制代码
private fun updateSnow(snowItem: SnowItem) {
    snowItem.positionY += snowItem.speedY
    snowItem.positionX += snowItem.speedX
    if (snowItem.positionY > height) {
        snowItem.positionY = 0f
        snowItem.positionX = snowItem.originalPosX
    }
}

看一下效果图,再理解一下所有雪花为一个整体做弦波动这句话。

正态分布:让雪花大小更符合现实

随机获取一个正态分布的值,并通过递归的方式让其在(-1,1).

kotlin 复制代码
private fun getRandomGaussian(): Double {
    val gaussian = mRandom.nextGaussian() / 2
     if (gaussian > -1 && gaussian < 1) {
         return gaussian
    } else {
         return getRandomGaussian() // 递归:确保在(-1, 1)之间
    }
}

根据正态分布修改一下雪花的大小:

kotlin 复制代码
//旧
val size = mRandom.nextInt(maxSize - minSize) + minSize
//新
val size = abs(getRandomGaussian()) * (maxSize - minSize) + minSize

雪球变雪花

我们这里就不自己去画雪花了,我们去找个雪花的icon就行。
iconfont-阿里巴巴矢量图标库

我们给SnowItem加上雪花icon资源的属性:

kotlin 复制代码
data class SnowItem(
    val size: Float,
    val originalPosX: Float,
    var positionX: Float,
    var positionY: Float,
    val speedX: Float,
    val speedY: Float,
    val snowflakeBitmap: Bitmap? = null
)

将icon裁剪为和雪球一样大:

kotlin 复制代码
//todo 需要兼容类型
private val mSnowflakeDrawable = ContextCompat.getDrawable(context, R.drawable.icon_snowflake) as BitmapDrawable
...
private fun createSnowItemList(): List<SnowItem> {
    ...
    val size = abs(getRandomGaussian()) * (maxSize - minSize) + minSize
    val bitmap = Bitmap.createScaledBitmap(mSnowflakeDrawable.bitmap, size.toInt(), size.toInt(), false)
    val snowItem = SnowItem(
        size.toFloat(),
        positionX.toFloat(),
        positionX.toFloat(),
        0f,
        speedX,
        speedY,
        bitmap
    )
    ...
}

绘制的时候,我们使用bitmap去绘制:

kotlin 复制代码
override fun draw(canvas: Canvas) {
    super.draw(canvas)
    for (snowItem in mSnowItemList) {
        if (snowItem.snowflakeBitmap != null) {
            //如果有snowflakeBitmap,绘制Bitmap
            canvas.drawBitmap(snowItem.snowflakeBitmap, snowItem.positionX, snowItem.positionY, mSnowPaint)
        } else {
            canvas.drawCircle(snowItem.positionX, snowItem.positionY, snowItem.size, mSnowPaint)
        }
        updateSnow(snowItem)
    }
    postInvalidateOnAnimation()
}


到这里我们飘雪的效果基本实现了,但是目前的代码结构一团糟,接下来我们整理一下代码。

逻辑完善&性能优化

首先我们将雪花的属性如大小,速度等封装一下:

kotlin 复制代码
data class SnowflakeParams(
    val canvasWidth: Int, // 画布的宽度
    val canvasHeight: Int, // 画布的高度
    val sizeMinInPx: Int = 30, // 雪花的最小大小
    val sizeMaxInPx: Int = 50, // 雪花的最大大小
    val speedMin: Int = 10,  // 雪花的最小速度
    val speedMax: Int = 20, // 雪花的最大速度
    val alphaMin: Int = 150, // 雪花的最小透明度
    val alphaMax: Int = 255, // 雪花的最大透明度
    val angleMax: Int = 10, // 雪花的最大角度
    val snowflakeImage: Bitmap? = null, // 雪花的图片
)

然后,让每个雪花控制自己的绘制和更新。其次需要让每个雪花可以复用从而减少资源消耗。

kotlin 复制代码
class Snowflak(private val params: SnowflakeParams) {
    private val mRandom = Random()

    private var mSize: Double = 0.0
    private var mAlpha: Int = 255
    private var mSpeedX: Double = 0.0
    private var mSpeedY: Double = 0.0
    private var mPositionX: Double = 0.0
    private var mPositionY: Double = 0.0
    private var mSnowflakeImage: Bitmap? = null

    private val mPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.WHITE
        style = Style.FILL
    }

    init {
        reset()
    }

    //复用雪花
    private fun reset(){
        val deltaSize = params.sizeMaxInPx - params.sizeMinInPx
        mSize = abs(getRandomGaussian()) * deltaSize + params.sizeMinInPx
        params.snowflakeImage?.let {
            mSnowflakeImage = Bitmap.createScaledBitmap(it, mSize.toInt(), mSize.toInt(), false)
        }
        //做一个线性插值,根据雪花的大小,来确定雪花的速度
        val lerp = (mSize - params.sizeMinInPx) / (params.sizeMaxInPx - params.sizeMinInPx)
        val speed = lerp * (params.speedMax - params.speedMin) + params.speedMin

        val angle = mRandom.nextDouble() * params.angleMax
        val leftOrRight = mRandom.nextBoolean() //true: left, false: right
        val radians = if (leftOrRight) {
            Math.toRadians(-angle)
        } else {
            Math.toRadians(angle)
        }
        mSpeedX = speed * sin(radians)
        mSpeedY = speed * cos(radians)

        mAlpha = mRandom.nextInt(params.alphaMax - params.alphaMin) + params.alphaMin
        mPaint.alpha = mAlpha

        mPositionX = mRandom.nextDouble() * params.canvasWidth
        mPositionY = -mSize
    }

    fun update() {
        mPositionX += mSpeedX
        mPositionY += mSpeedY
        if (mPositionY > params.canvasHeight) {
            reset()
        }
        //根据雪花的位置,来确定雪花的透明度
        val alphaPercentage = (params.canvasHeight - mPositionY).toFloat() / params.canvasHeight
        mPaint.alpha = (alphaPercentage * mAlpha).toInt()
    }

    fun draw(canvas: Canvas) {
        if (mSnowflakeImage != null) {
            canvas.drawBitmap(mSnowflakeImage!!, mPositionX.toFloat(), mPositionY.toFloat(), mPaint)
        } else {
            canvas.drawCircle(mPositionX.toFloat(), mPositionY.toFloat(), mSize.toFloat(), mPaint)
        }
    }

    private fun getRandomGaussian(): Double {
        val gaussian = mRandom.nextGaussian() / 2
        return if (gaussian > -1 && gaussian < 1) {
            gaussian
        } else {
            getRandomGaussian() // 确保在(-1, 1)之间
        }
    }
}

将绘制和更新逻辑放到每个雪花中,那么SnowView就会很简洁:

kotlin 复制代码
class SnowView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    private lateinit var mSnowItemList: List<Snowflake>

    private val mSnowflakeImage = ContextCompat.getDrawable(context, R.drawable.icon_snowflake)?.toBitmap()

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        mSnowItemList = createSnowItemList()
    }

    private fun createSnowItemList(): List<Snowflake> {
        return List(80) {
            Snowflake(SnowflakeParams(width, height, snowflakeImage = mSnowflakeImage))
        }
    }

    override fun draw(canvas: Canvas) {
        super.draw(canvas)
        for (snowItem in mSnowItemList) {
            snowItem.draw(canvas)
            snowItem.update()
        }
        postInvalidateOnAnimation()
    }
}

下面是添加了透明度和优化下落速度的效果图,现在更加自然了。

在Snowflake中有不少随机函数的计算,尤其是雪花数量非常庞大的时候,可能会引起卡顿, 我们将update的方法放子线程中:

kotlin 复制代码
...
private lateinit var mHandler: Handler
private lateinit var mHandlerThread : HandlerThread
...
override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    mHandlerThread = HandlerThread("SnowView").apply {
        start()
        mHandler = Handler(looper)
    }
}
...
override fun draw(canvas: Canvas) {
    super.draw(canvas)
    for (snowItem in mSnowItemList) {
        snowItem.draw(canvas)
    }
    mHandler.post {
        //子线程更新雪花位置/状态
        for (snowItem in mSnowItemList) {
            snowItem.update()
        }
        postInvalidateOnAnimation()
    }
}
...
override fun onDetachedFromWindow() {
    mHandlerThread.quitSafely()
    super.onDetachedFromWindow()
}

这里还有个小问题, 就是多次创建新的Bitmap

kotlin 复制代码
 private fun reset(){
    ...
    params.snowflakeImage?.let {
        //这里👇
        mSnowflakeImage = Bitmap.createScaledBitmap(it, mSize.toInt(), mSize.toInt(), false)
    }
    ...
 }

其实snowflakeImage是不变的,mSize的范围在min-max之间,也没多少个。我想到的解决方法,将size进行裁剪后bitmap进行缓存。(如果有其他的好办法,可以告知我。)

kotlin 复制代码
private fun getSnowflakeBitmapFromCache(size: Int): Bitmap {
    return snowflakeBitmapCache.getOrPut(size) {
        // 创建新的 Bitmap 并放入缓存
        Bitmap.createScaledBitmap(params.snowflakeImage, size, size, false)
    }
}

在1000个雪花下,模拟器没有任何卡顿,内存也没有啥涨幅。

最后就是将各个属性跑给外面去设置.

  • 方法1: 通过styleable的方式在xml里面使用,我就不多描述了
  • 方法2: Builder模式去设置:
kotlin 复制代码
 class Builder(private val context: Context) {
        private var canvasWidth: Int = 0
        private var canvasHeight: Int = 0
        private var sizeMinInPx: Int = 40
        private var sizeMaxInPx: Int = 60
        private var speedMin: Int = 10
        private var speedMax: Int = 20
        private var alphaMin: Int = 150
        private var alphaMax: Int = 255
        private var angleMax: Int = 10
        private var snowflakeImage: Bitmap? = null
        
        fun setCanvasSize(canvasWidth: Int, canvasHeight: Int) = apply {
            this.canvasWidth = canvasWidth
            this.canvasHeight = canvasHeight
        }

        fun setSizeRangeInPx(sizeMin: Int, sizeMax: Int) = apply {
            this.sizeMinInPx = sizeMin
            this.sizeMaxInPx = sizeMax
        }

        fun setSpeedRange(speedMin: Int, speedMax: Int) = apply {
            this.speedMin = speedMin
            this.speedMax = speedMax
        }

        fun setAlphaRange(alphaMin: Int, alphaMax: Int) = apply {
            this.alphaMin = alphaMin
            this.alphaMax = alphaMax
        }

        fun setAngleMax(angleMax: Int) = apply {
            this.angleMax = angleMax
        }

        fun setSnowflakeImage(snowflakeImage: Bitmap) = apply {
            this.snowflakeImage = snowflakeImage
        }

        fun setSnowflakeImageResId(@DrawableRes snowflakeImageResId: Int) = apply {
            this.snowflakeImage = ContextCompat.getDrawable(context, snowflakeImageResId)?.let {
                (it as BitmapDrawable).bitmap
            }
        }

        fun build(): SnowView {
            return SnowView(
                context, params = SnowflakeParams(
                    sizeMinInPx = sizeMinInPx,
                    sizeMaxInPx = sizeMaxInPx,
                    speedMin = speedMin,
                    speedMax = speedMax,
                    alphaMin = alphaMin,
                    alphaMax = alphaMax,
                    angleMax = angleMax,
                    snowflakeImage = snowflakeImage
                )
            )
        }
    }

使用builder模式创建:

kotlin 复制代码
 val snowView = SnowView.Builder(this)
     .setSnowflakeImageResId(R.drawable.icon_small_snowflake)
     .setSnowflakeCount(50)
     .setSpeedRange(10, 20)
     .setSizeRangeInPx(40, 60)
     .setAlphaRange(150, 255)
     .setAngleMax(10)
     .build()
     
 mBinding.clRoot.addView(
     snowView,
     ViewGroup.LayoutParams(
         ViewGroup.LayoutParams.MATCH_PARENT,
         ViewGroup.LayoutParams.MATCH_PARENT
     )
 )

最后我们加上背景图片,最终效果如下:

项目代码:
github.com/Mrs-Chang/D...

相关推荐
aqi001 小时前
FFmpeg开发笔记(五十四)使用EasyPusher实现移动端的RTSP直播
android·ffmpeg·音视频·直播·流媒体
Leoysq1 小时前
Unity实现原始的发射子弹效果
android
起司锅仔2 小时前
ActivityManagerService Activity的启动流程(2)
android·安卓
峥嵘life2 小时前
Android14 手机蓝牙配对后阻塞问题解决
android·智能手机
岁岁岁平安2 小时前
《飞机大战游戏》实训项目(Java GUI实现)(设计模式)(简易)
后端·游戏·设计模式·飞机大战·java-gui
万兴丶3 小时前
Unnity IOS安卓启动黑屏加图(底图+Logo gif也行)
android·unity·ios
大风起兮云飞扬丶3 小时前
安卓数据存储——SQLite
android·sqlite
丶白泽4 小时前
重修设计模式-结构型-适配器模式
前端·设计模式·适配器模式
CV猿码人4 小时前
设计模式-适配器模式
java·设计模式·适配器模式
茜茜西西CeCe4 小时前
移动技术开发:ListView水果列表
android·java·安卓·android-studio·listview·移动技术开发