Android

关注公众号 jb51net

关闭
首页 > 软件编程 > Android > Android飘雪效果

Android实现自定义飘雪效果

作者:蹦蹦蹦

随着冬季的脚步越来越远,南方的我今年就看了一场雪,下一场雪遥遥无期,那我们来实现一个自定义的 View,它能模拟雪花飘落的景象,所以本文给大家介绍了基于Android实现自定义飘雪效果,感兴趣的朋友可以参考下

背景

随着冬季的脚步越来越远,南方的我今年就看了一场雪,下一场雪遥遥无期。
那我们来实现一个自定义的 View,它能模拟雪花飘落的景象。我们一起来看一下如何让这些数字雪花在屏幕上轻盈地飞舞。

一个雪球下落

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

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

效果如下:

多个雪球下落

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

data class SnowItem(
    val size: Float,
    var positionX: Float,
    var positionY: Float,
    val downSpeed: Float
)

生成50个雪花:

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个雪花的效果:

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

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

上面的雪花是降落的,不是很逼真,我们如何让雪花有飘落的感觉了?我们可以给水平/竖直方向都加上弦波动。
我们这里是以所有雪花为一个整体做弦波动。
理解一下这句话的意思,就是说所有的雪花水平/竖直方向波动符合一个弦波动,而不是单个雪花的运动符合弦波动。

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

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

// 通过角度->转为弧度的值->正弦/余弦的值
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类:

data class SnowItem(
    val size: Float,
    val originalPosX: Int,
    var positionX: Float,
    var positionY: Float,
    val speedX: Float,
    val speedY: Float
)

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

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
}

雪花位置更新如下:

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

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

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

//旧
val size = mRandom.nextInt(maxSize - minSize) + minSize
//新
val size = abs(getRandomGaussian()) * (maxSize - minSize) + minSize

雪球变雪花

我们这里就不自己去画雪花了,我们去找个雪花的icon就行。
iconfont-阿里巴巴矢量图标库我们给SnowItem加上雪花icon资源的属性:

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裁剪为和雪球一样大:

//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去绘制:

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

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

逻辑完善&性能优化

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

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, // 雪花的图片
)

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

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就会很简洁:

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的方法放子线程中:

...
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

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

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

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

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

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

 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模式创建:

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

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

项目代码:https://github.com/Mrs-Chang/DailyLearn/blob/master/snow/src/main/java/com/chang/snow/SnowView.kt

以上就是Android实现自定义飘雪效果的详细内容,更多关于Android飘雪效果的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文