Android實(shí)現(xiàn)自定義飄雪效果
背景
隨著冬季的腳步越來(lái)越遠(yuǎn),南方的我今年就看了一場(chǎng)雪,下一場(chǎng)雪遙遙無(wú)期。
那我們來(lái)實(shí)現(xiàn)一個(gè)自定義的 View,它能模擬雪花飄落的景象。我們一起來(lái)看一下如何讓這些數(shù)字雪花在屏幕上輕盈地飛舞。

一個(gè)雪球下落
我們繪制一個(gè)圓,讓其勻速下落,當(dāng)超出屏幕就刷新:
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()
}
效果如下:

多個(gè)雪球下落
我們先簡(jiǎn)單的寫(xiě)個(gè)雪花數(shù)據(jù)類:
data class SnowItem(
val size: Float,
var positionX: Float,
var positionY: Float,
val downSpeed: Float
)
生成50個(gè)雪花:
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
}
來(lái)看一下50個(gè)雪花的效果:
private lateinit var mSnowItemList: List<SnowItem>
//需要拿到width,所以在onSizeChanged之后創(chuàng)建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
}
}

弦波動(dòng):讓雪花有飄落的感覺(jué)
上面的雪花是降落的,不是很逼真,我們?nèi)绾巫屟┗ㄓ酗h落的感覺(jué)了?我們可以給水平/豎直方向都加上弦波動(dòng)。
我們這里是以所有雪花為一個(gè)整體做弦波動(dòng)。
理解一下這句話的意思,就是說(shuō)所有的雪花水平/豎直方向波動(dòng)符合一個(gè)弦波動(dòng),而不是單個(gè)雪花的運(yùn)動(dòng)符合弦波動(dòng)。
[想象一下如果每個(gè)雪花都在左右扭動(dòng),數(shù)量一多,是不是就很亂!]
我們結(jié)合代碼在理解一下上述的話,記得看一下注釋:
// 通過(guò)角度->轉(zhuǎn)為弧度的值->正弦/余弦的值
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度]分正負(fù),所以給x方向,區(qū)分左右
val speedX = speed * sin(radians).toFloat()
val speedY = speed * cos(radians).toFloat()
//speedX和speedY隨機(jī)后,就確定下來(lái),
//就是說(shuō)某個(gè)雪花的speedX和speedY在下落的過(guò)程中是確定的
//即所有雪花為一個(gè)整體做弦波動(dòng)
我們需要添加水平方向的速度,所以我們需要修改SnowItem類:
data class SnowItem(
val size: Float,
val originalPosX: Int,
var positionX: Float,
var positionY: Float,
val speedX: Float,
val speedY: Float
)
修改完后,我們看一下SnowItem的創(chuàng)建:
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創(chuàng)建
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
}
}
看一下效果圖,再理解一下所有雪花為一個(gè)整體做弦波動(dòng)這句話。

正態(tài)分布:讓雪花大小更符合現(xiàn)實(shí)

隨機(jī)獲取一個(gè)正態(tài)分布的值,并通過(guò)遞歸的方式讓其在(-1,1).
private fun getRandomGaussian(): Double {
val gaussian = mRandom.nextGaussian() / 2
if (gaussian > -1 && gaussian < 1) {
return gaussian
} else {
return getRandomGaussian() // 遞歸:確保在(-1, 1)之間
}
}
根據(jù)正態(tài)分布修改一下雪花的大?。?/p>
//舊 val size = mRandom.nextInt(maxSize - minSize) + minSize //新 val size = abs(getRandomGaussian()) * (maxSize - minSize) + minSize
雪球變雪花
我們這里就不自己去畫(huà)雪花了,我們?nèi)フ覀€(gè)雪花的icon就行。
iconfont-阿里巴巴矢量圖標(biāo)庫(kù)我們給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
)
...
}
繪制的時(shí)候,我們使用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()
}

到這里我們飄雪的效果基本實(shí)現(xiàn)了,但是目前的代碼結(jié)構(gòu)一團(tuán)糟,接下來(lái)我們整理一下代碼。
邏輯完善&性能優(yōu)化
首先我們將雪花的屬性如大小,速度等封裝一下:
data class SnowflakeParams(
val canvasWidth: Int, // 畫(huà)布的寬度
val canvasHeight: Int, // 畫(huà)布的高度
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, // 雪花的圖片
)
然后,讓每個(gè)雪花控制自己的繪制和更新。其次需要讓每個(gè)雪花可以復(fù)用從而減少資源消耗。
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()
}
//復(fù)用雪花
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)
}
//做一個(gè)線性插值,根據(jù)雪花的大小,來(lái)確定雪花的速度
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()
}
//根據(jù)雪花的位置,來(lái)確定雪花的透明度
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)之間
}
}
}
將繪制和更新邏輯放到每個(gè)雪花中,那么SnowView就會(huì)很簡(jiǎn)潔:
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()
}
}
下面是添加了透明度和優(yōu)化下落速度的效果圖,現(xiàn)在更加自然了。

在Snowflake中有不少隨機(jī)函數(shù)的計(jì)算,尤其是雪花數(shù)量非常龐大的時(shí)候,可能會(huì)引起卡頓, 我們將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 {
//子線程更新雪花位置/狀態(tài)
for (snowItem in mSnowItemList) {
snowItem.update()
}
postInvalidateOnAnimation()
}
}
...
override fun onDetachedFromWindow() {
mHandlerThread.quitSafely()
super.onDetachedFromWindow()
}
這里還有個(gè)小問(wèn)題, 就是多次創(chuàng)建新的Bitmap
private fun reset(){
...
params.snowflakeImage?.let {
//這里??
mSnowflakeImage = Bitmap.createScaledBitmap(it, mSize.toInt(), mSize.toInt(), false)
}
...
}
其實(shí)snowflakeImage是不變的,mSize的范圍在min-max之間,也沒(méi)多少個(gè)。我想到的解決方法,將size進(jìn)行裁剪后bitmap進(jìn)行緩存。(如果有其他的好辦法,可以告知我。)
private fun getSnowflakeBitmapFromCache(size: Int): Bitmap {
return snowflakeBitmapCache.getOrPut(size) {
// 創(chuàng)建新的 Bitmap 并放入緩存
Bitmap.createScaledBitmap(params.snowflakeImage, size, size, false)
}
}
在1000個(gè)雪花下,模擬器沒(méi)有任何卡頓,內(nèi)存也沒(méi)有啥漲幅。

最后就是將各個(gè)屬性跑給外面去設(shè)置.
- 方法1: 通過(guò)styleable的方式在xml里面使用,我就不多描述了
- 方法2: Builder模式去設(shè)置:
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模式創(chuàng)建:
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
)
)
最后我們加上背景圖片,最終效果如下:

項(xiàng)目代碼:https://github.com/Mrs-Chang/DailyLearn/blob/master/snow/src/main/java/com/chang/snow/SnowView.kt
以上就是Android實(shí)現(xiàn)自定義飄雪效果的詳細(xì)內(nèi)容,更多關(guān)于Android飄雪效果的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android動(dòng)畫(huà)入門(mén)教程之kotlin
最近在學(xué)習(xí)kotlin,所以下面這篇文章主要給大家介紹了關(guān)于Android動(dòng)畫(huà)入門(mén)教程之kotlin的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),需要的朋友可以參考借鑒,下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧。2017-12-12
圖解 Kotlin SharedFlow 緩存系統(tǒng)及示例詳解
Kotlin實(shí)現(xiàn)半圓形進(jìn)度條的方法示例
Android Studio 中運(yùn)行 groovy 程序的方法圖文詳解
從源碼編譯Android系統(tǒng)的Java類庫(kù)和JNI動(dòng)態(tài)庫(kù)的方法
Flutter進(jìn)階之實(shí)現(xiàn)動(dòng)畫(huà)效果(五)

