背景
随着冬季的脚步越来越远,南方的我今年就看了一场雪,下一场雪遥遥无期。
那我们来实现一个自定义的 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
)
)
最后我们加上背景图片,最终效果如下: