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个雪花下,模拟器没有任何卡顿,内存也没有啥涨幅。
最后就是将各个属性跑给外面去设置.
- 方法1: 通过styleable的方式在xml里面使用,我就不多描述了
- 方法2: Builder模式去设置:
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飘雪效果的资料请关注脚本之家其它相关文章!