這效果炸了,網(wǎng)易云音樂(lè)“宇宙塵?!碧匦В?/h1>
作者:Mlx
鏈接:https://juejin.im/post/6871049441546567688
1.前言
前段時(shí)間,女朋友用網(wǎng)易云音樂(lè)的時(shí)候看到一個(gè)宇宙塵埃特效,說(shuō)很好看,想要讓我給她開(kāi)VIP用。
笑話,作為一個(gè)程序員為什么不能自己實(shí)現(xiàn)!開(kāi)什么VIP!!
0202年了,Android開(kāi)發(fā)大都應(yīng)該是老油條了吧。如果你自定義View還是掌握得不夠熟練的話,那可就說(shuō)不過(guò)去了哦。自定義View可以說(shuō)是Android開(kāi)發(fā)中,無(wú)論是初級(jí),中級(jí)還是高級(jí)都必須掌握的一個(gè)點(diǎn)。
不然的話,UI一不小心設(shè)計(jì)的太炫酷,那你豈不是要和他打起來(lái)了?難道你不想成為下圖中的男人嗎?

所以,自定義View的重要性已經(jīng)不用我多說(shuō)了。本篇是針對(duì)有自定義View基礎(chǔ)知識(shí),但是苦于沒(méi)有好的項(xiàng)目模仿,或者說(shuō)看到了酷炫效果沒(méi)有思路不知道該如何下手的人。恭喜你,我將一步步手把手的帶你分析效果,然后代碼實(shí)現(xiàn)它。
我就知道沒(méi)圖是騙不到人的。先放圖,大家看一下最終實(shí)現(xiàn)的效果。
ps:為了能更快加載出來(lái),gif是壓縮了又壓縮,大家可以腦部清晰度。
ps2:小伙伴如果有好的gif壓縮網(wǎng)站可以推薦一波

咳咳,雖然畫(huà)質(zhì)不行,但是還是能看的出來(lái)效果是非常不錯(cuò)的。那么今天我就帶小伙伴們一起從頭到尾的實(shí)現(xiàn)一下這個(gè)效果吧。
2.特效分析
首先看動(dòng)圖,我們可以拆成兩部分完成,一個(gè)是里面不斷旋轉(zhuǎn)的圓形圖片,一個(gè)是外面不斷擴(kuò)散的粒子動(dòng)效。
我們由易到難來(lái)完成,畢竟柿子要挑軟的捏嘛。
另外由于本篇重點(diǎn)是講自定義View的,所以就不采用ViewGroup的方式來(lái)實(shí)現(xiàn)圖片和粒子動(dòng)效的結(jié)合了。而是采用分開(kāi)布局的方式。這樣做的好處是可以只專注于粒子動(dòng)效的實(shí)現(xiàn),而不需要去考慮測(cè)量,布局等。
至于自定義ViewGroup,下一篇文章我將會(huì)帶領(lǐng)大家實(shí)現(xiàn)一個(gè)非常非常非??犰诺男Ч?/span>
加載圖片
我們先觀察,首先這是一個(gè)圓形圖片。其次,它在不停的轉(zhuǎn)。

咳咳,先別罵,容我說(shuō)完嘛。
圓形圖片的話我們就用Glide來(lái)進(jìn)行實(shí)現(xiàn)把,其實(shí)自定義View實(shí)現(xiàn)也可以,但我們重點(diǎn)還是粒子特效。
首先定義一個(gè)ImageView
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/rootLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<ImageView
android:id="@+id/music_avatar"
android:layout_centerInParent="true"
android:layout_width="178dp"
android:layout_height="178dp"/>
</RelativeLayout>
現(xiàn)在我們?nèi)ctivity中,用Glide加載一張圓形圖片。
class DemoActivity : AppCompatActivity() {
private lateinit var demoBinding: ActivityDemoBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
demoBinding = ActivityDemoBinding.inflate(layoutInflater)
setContentView(demoBinding.root)
lifecycleScope.launch(Dispatchers.Main) {
loadImage()
}
}
private suspend fun loadImage() {
withContext(Dispatchers.IO) {
Glide.with(this@DemoActivity)
.load(R.drawable.ic_music)
.circleCrop()
.into(object : ImageViewTarget<Drawable>(demoBinding.musicAvatar) {
override fun setResource(resource: Drawable?) {
demoBinding.musicAvatar.setImageDrawable(resource)
}
})
}
}
}
這樣我們利用Glide就加載了一個(gè)圓形的圖片。
3.旋轉(zhuǎn)圖片
圖片有了,接下來(lái)就應(yīng)該是旋轉(zhuǎn)了。
那么我們開(kāi)始搞旋轉(zhuǎn)。
旋轉(zhuǎn)是如何實(shí)現(xiàn)的?我想不用我多說(shuō),很多小伙伴都知道,是動(dòng)畫(huà)嘛。
沒(méi)錯(cuò),就是動(dòng)畫(huà)。我們這里使用屬性動(dòng)畫(huà)來(lái)實(shí)現(xiàn)。
定義一個(gè)屬性動(dòng)畫(huà)并且給圖片設(shè)置一個(gè)點(diǎn)擊事件,讓它旋轉(zhuǎn)起來(lái)
lateinit var rotateAnimator: ObjectAnimator
override fun onCreate(savedInstanceState: Bundle?) {
...
setContentView(demoBinding.root)
rotateAnimator = ObjectAnimator.ofFloat(demoBinding.musicAvatar, View.ROTATION, 0f, 360f)
rotateAnimator.duration = 6000
rotateAnimator.repeatCount = -1
rotateAnimator.interpolator = LinearInterpolator()
lifecycleScope.launch(Dispatchers.Main) {
loadImage()
//添加點(diǎn)擊事件,并且啟動(dòng)動(dòng)畫(huà)
demoBinding.musicAvatar.setOnClickListener {
rotateAnimator.start()
}
}
}
這些都是小兒科了,相信面對(duì)電視機(jī)前的觀眾朋友們,啊不,口誤口誤。
相信小伙伴們都很熟悉了,那我們開(kāi)始今天的重頭戲,這個(gè)粒子動(dòng)畫(huà)。
4.粒子動(dòng)畫(huà)
其實(shí)我很久以前看粒子動(dòng)畫(huà)的時(shí)候,也很好奇,這些炫酷的粒子動(dòng)畫(huà)是怎么實(shí)現(xiàn)的,當(dāng)時(shí)的我完全沒(méi)有思路。
尤其是看到一些圖片,啪唧一下變成了一堆粒子,掉落,然后又呱唧從粒子變成了圖片,就覺(jué)得異常的牛X。
其實(shí)啊,一點(diǎn)都不神奇。
首先我們要知道bitmap是什么。bitmap是什么呀?
在數(shù)學(xué)上,有這么幾個(gè)概念,點(diǎn),線,面。點(diǎn)很好理解,就是一個(gè)點(diǎn)。線是由一堆點(diǎn)組成的,而面又類(lèi)似于一堆線組成的。本質(zhì)上,面就是由無(wú)數(shù)的點(diǎn)組成的。
可是這和bitmap以及今天的粒子動(dòng)畫(huà)有什么關(guān)系呢?
一個(gè)bitmap,我們可以簡(jiǎn)單地理解為一張圖片。這個(gè)圖片是不是一個(gè)平面呢?而平面又是一堆點(diǎn)組成的,這個(gè)點(diǎn)在這里稱為像素點(diǎn)。所以bitmap就是由一堆像素點(diǎn)所組成的,有趣的是,這些像素點(diǎn)是有顏色的,當(dāng)這些像素點(diǎn)足夠小,你離得足夠遠(yuǎn)你看起來(lái)就像一幅完整的畫(huà)了。
在現(xiàn)實(shí)中也不乏這樣的例子,舉辦一些活動(dòng)的時(shí)候,一個(gè)個(gè)人穿著不同顏色的衣服有序的站在廣場(chǎng)上,如果有一架無(wú)人機(jī)在空中看,就能看到是一幅畫(huà)。就像這樣

所以當(dāng)把一幅畫(huà)拆成一堆粒子的話,其實(shí)就是獲得bitmap中所有的像素點(diǎn),然后改變他們的位置就可以了。如果想要用一堆粒子拼湊出一幅畫(huà),只需要知道這些粒子的順序,排放整齊自然就是一幅畫(huà)了。
扯遠(yuǎn)了,說(shuō)這些呢其實(shí)和今天的效果沒(méi)有特別強(qiáng)的聯(lián)系,只是為了讓你能夠更好的理解粒子動(dòng)畫(huà)的本質(zhì)。

粒子動(dòng)畫(huà)分析
我們先觀察這個(gè)特效,你會(huì)發(fā)現(xiàn)有一個(gè)圓,這個(gè)圓上不斷的往外發(fā)散粒子,粒子在發(fā)散的過(guò)程中速度是不相同的。而且,在發(fā)散的過(guò)程中,透明度也在不斷變化,直到最后完全透明。
好,我們歸納一下。
圓形生產(chǎn)粒子
粒子速度不同,也就是隨機(jī)。
粒子透明度不斷降低,直到最后消散。
粒子沿著到圓心的反方向擴(kuò)散。
寫(xiě)自定義View的時(shí)候千萬(wàn)不要一上來(lái)就開(kāi)干,而是要逐漸分析,有的時(shí)候我們遇到一個(gè)復(fù)雜的效果,更是要逐幀的分析。
而且我寫(xiě)自定義View的時(shí)候有個(gè)習(xí)慣,就是一點(diǎn)點(diǎn)的實(shí)現(xiàn)效果,不會(huì)去一次性實(shí)現(xiàn)全部的效果。
所以我們第一步,生產(chǎn)粒子。
生產(chǎn)粒子
首先,我們可以知道,粒子是有顏色的,但是似乎這個(gè)效果粒子只有白色,那就指定粒子顏色為白色了。
然后我們可以得出,粒子是有位置的,位置肯定由x,y組成嘛。然后粒子還有個(gè)速度,以及透明度和半徑。
定義粒子
我們可以定義一個(gè)粒子類(lèi):
class Particle(
var x:Float,//X坐標(biāo)
var y:Float,//Y坐標(biāo)
var radius:Float,//半徑
var speed:Float,//速度
var alpha: Int//透明度
)
由于我們的這個(gè)效果看起來(lái)就像是水波一樣的漣漪,我給自定義View起名為漣漪,也就是dimple。
我們來(lái)定義這個(gè)自定義View吧。
定義自定義view
class DimpleView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
//定義一個(gè)粒子的集合
private var particleList = mutableListOf<Particle>()
//定義畫(huà)筆
var paint = Paint()
}
一開(kāi)始就直接圓形生產(chǎn)粒子著實(shí)有些難度,我先考慮考慮如何實(shí)現(xiàn)生產(chǎn)粒子吧。
先不斷生產(chǎn)粒子,然后再考慮圓形的事情。
而且生產(chǎn)一堆粒子比較麻煩,我先實(shí)現(xiàn)從上到下生產(chǎn)一個(gè)粒子。
那么如何生產(chǎn)一個(gè)粒子呢?前面也說(shuō)了,粒子就是個(gè)很小的點(diǎn),所以用canvas的drawCircle就可以。
那我們來(lái)吧
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
paint.color = Color.WHITE
paint.isAntiAlias = true
var particle=Particle(0f,0f,2f,2f,100)
canvas.drawCircle(particle.x, particle.y, particle.radius, paint)
}
畫(huà)畫(huà)嘛,就要在onDraw方法中進(jìn)行了。我們先new一個(gè)Particle,然后畫(huà)出來(lái)。
實(shí)際上這樣并沒(méi)有什么效果。為啥呢?
我們的背景是白色的,粒子默認(rèn)是白色的,你當(dāng)然看不到了。所以我們需要先做個(gè)測(cè)試,為了能看出效果。這里啊,我們把背景換成黑色。
同時(shí),為了方便測(cè)試,先把Imageview設(shè)置成不可見(jiàn)。然后我們看下效果

沒(méi)錯(cuò),就是沒(méi)什么效果。你什么都看不出來(lái)。
先不急,慢慢來(lái),且聽(tīng)我吹,啊不,且聽(tīng)我和你慢慢道來(lái)。
我們?cè)谶@里只花了一個(gè)圓,而且是在坐標(biāo)原點(diǎn)畫(huà)了一個(gè)半徑為2的點(diǎn),可以說(shuō)很小很小了。自然就看不到了。
什么,你不知道原點(diǎn)在哪?

棕色部分就是我們的屏幕,所以原點(diǎn)就是左上角。
現(xiàn)在我們需要做的事情只有兩個(gè),要么把點(diǎn)變大,要么改變點(diǎn)的位置。
粒子粒子的,當(dāng)然不能變大,所以我們把它放到屏幕中心去。
所以我們定義一個(gè)屏幕中心的坐標(biāo),centerX,centerY。并且在onSizeChanged方法中給它們賦值
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
centerX= (w/2).toFloat()
centerY= (h/2).toFloat()
}
那我們改一下上面的畫(huà)點(diǎn)的代碼:
override fun onDraw(canvas: Canvas) {
...
var particle=Particle(centerX,centerY,2f,2f,100)
canvas.drawCircle(particle.x, particle.y, particle.radius, paint)
}
如此,可以看到這個(gè)點(diǎn)了,雖然很小很小,但是也勝過(guò)沒(méi)有呀

可是這時(shí)候有人跳出來(lái)了,說(shuō)你這不對(duì)啊,一個(gè)點(diǎn)有啥用?還那么小,我本來(lái)就近視,你這搞得我更看不清了。你是不是眼睛店派來(lái)的叛徒!
添加多個(gè)粒子
那好吧,我們多加幾個(gè)。可是該怎么加?效果圖中是圓形的,可是我不會(huì)啊,我只能先試試一橫排添加??纯催@樣可不可以呢?我們知道,橫排的話就是y值不變,x變。好,但是為了避免我們畫(huà)出一條線,我們x值隨機(jī)增加,這樣的話看起來(lái)也比較不規(guī)則一些。
那么代碼就應(yīng)該是這樣了
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
paint.color = Color.WHITE
paint.isAntiAlias = true
for (i in 0..50){
var random= Random()
var nextX=random.nextInt((centerX*2).toInt())
var particle=Particle(nextX.toFloat(),centerY,2f,2f,100)
canvas.drawCircle(particle.x, particle.y, particle.radius, paint)
}
}
由于centerX是屏幕的中心,所以它的值是屏幕寬度的一半,這里的話X的值就是在屏幕寬度內(nèi)隨機(jī)選一個(gè)值。那么效果看起來(lái)是下面這樣

效果看起來(lái)不錯(cuò)了。
但是總有愛(ài)搞事的小伙伴又跳出來(lái)了,說(shuō)你會(huì)不會(huì)寫(xiě)代碼?onDraw方法一直被調(diào)用,不能定義對(duì)象你不知道么?很容易引發(fā)頻繁的GC,造成內(nèi)存抖動(dòng)的。而且你這還搞個(gè)循環(huán),性能能行不?
這個(gè)小伙伴你說(shuō)的非常對(duì),是我錯(cuò)了!
確實(shí),在ondraw方法中不適合定義對(duì)象,尤其是for循環(huán)中就更不能了。段時(shí)間看,我們50個(gè)粒子好像對(duì)性能的開(kāi)銷(xiāo)不是很大,但是一旦粒子數(shù)量很多,性能開(kāi)銷(xiāo)就會(huì)十分的大。而且,為了不掉幀,我們需要在16ms之內(nèi)完成繪制。
這個(gè)不明白的話我后續(xù)會(huì)有性能優(yōu)化的專題,可以關(guān)注一下我~
這里我們測(cè)量一下50個(gè)粒子的繪制時(shí)間和5000個(gè)粒子的繪制時(shí)間。
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
paint.color = Color.WHITE
paint.isAntiAlias = true
var time= measureTimeMillis {
for (i in 0..50){
var random= Random()
var nextX=random.nextInt((centerX*2).toInt())
var particle=Particle(nextX.toFloat(),centerY,2f,2f,100)
canvas.drawCircle(particle.x, particle.y, particle.radius, paint)
}
}
Log.i("dimple","繪制時(shí)間$time ms")
}
結(jié)果如下:50個(gè)粒子的繪制時(shí)間

5000個(gè)粒子的繪制時(shí)間:

可以看到,明顯超了16ms。所以我們需要優(yōu)化,怎么優(yōu)化?很簡(jiǎn)單,就是不在ondraw方法中創(chuàng)建對(duì)象就好了,那我們選擇在哪里呢?
構(gòu)造方法可以嗎?好像不可以呢,這個(gè)時(shí)候還沒(méi)辦法獲得屏幕寬高,嘿嘿嘿,onSizeChanged方法就決定是你了!
粒子添加到集合中
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
centerX= (w/2).toFloat()
centerY= (h/2).toFloat()
val random= Random()
var nextX=0
for (i in 0..5000){
nextX=random.nextInt((centerX*2).toInt())
particleList.add(Particle(nextX.toFloat(),centerY,2f,2f,100))
}
}
我們?cè)賮?lái)看看onDraw方法中繪制時(shí)間是多少:
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
paint.color = Color.WHITE
paint.isAntiAlias = true
var time= measureTimeMillis {
particleList.forEach {
canvas.drawCircle(it.x,it.y,it.radius,paint)
}
}
Log.i("dimple","繪制時(shí)間$time ms")
}

emmmm,好像是低于16ms了,可是這也太危險(xiǎn)了吧,你這分分鐘就超過(guò)了16ms啊。
確實(shí)是這樣子,但是實(shí)際情況下,我們并不需要5000個(gè)這么多的粒子。又有人問(wèn),,萬(wàn)一真的需要怎么辦?那就得看surfaceView了。這里就不講了
我們還是回過(guò)頭來(lái),先把粒子數(shù)量變成50個(gè)。
現(xiàn)在粒子也有了,該實(shí)現(xiàn)動(dòng)起來(lái)的效果了。
動(dòng)起來(lái),我們想想,應(yīng)該怎么做呢?效果圖是類(lèi)似圓一樣的擴(kuò)散,我現(xiàn)在做不到,我往下掉這應(yīng)該不難吧?
說(shuō)動(dòng)就動(dòng),搞起!至于怎么動(dòng),那肯定是屬性動(dòng)畫(huà)呀。
定義動(dòng)畫(huà)
private var animator = ValueAnimator.ofFloat(0f, 1f)
init {
animator.duration = 2000
animator.repeatCount = -1
animator.interpolator = LinearInterpolator()
animator.addUpdateListener {
updateParticle(it.animatedValue as Float)
invalidate()//重繪界面
}
}
我在這里啊,定義了一個(gè)方法updateParticle,每次動(dòng)畫(huà)更新的時(shí)候啊就去更新粒子的狀態(tài)。
updateParticle方法應(yīng)該去做什么事情呢?我們來(lái)開(kāi)動(dòng)小腦筋想想。
如果說(shuō)是粒子不斷往下掉的話,那應(yīng)該是y值不斷地增加就可以了,嗯,非常有道理。
我們來(lái)實(shí)現(xiàn)一下這個(gè)方法
更新粒子位置
private fun updateParticle(value: Float) {
particleList.forEach {
it.y += it.speed
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
...
animator.start()//別忘了啟動(dòng)動(dòng)畫(huà)
}
那我們現(xiàn)在來(lái)看一下效果如何

emmmm看起來(lái)有點(diǎn)雛形了,不過(guò)效果圖里的粒子速度似乎是隨機(jī)的,咱們這里是同步的呀。
沒(méi)關(guān)系,我們可以讓粒子的速度變成隨機(jī)的速度。我們修改添加粒子這里的代碼
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
centerX = (w / 2).toFloat()
centerY = (h / 2).toFloat()
val random = Random()
var nextX = 0
var speed=0 //定義一個(gè)速度
for (i in 0..50) {
nextX = random.nextInt((centerX * 2).toInt())
speed= random.nextInt(10)+5 //速度從5-15不等
particleList.add(
Particle(nextX.toFloat(), centerY, 2f, speed.toFloat(), 100)
)
}
animator.start()
}
這是效果,看起來(lái)有點(diǎn)樣子了。不過(guò)問(wèn)題又來(lái)了,人家的粒子是一直散發(fā)的,你這個(gè)粒子怎么
沒(méi)了就是沒(méi)了呢?

有道理,所以我覺(jué)得我們需要設(shè)置一個(gè)粒子移動(dòng)的最大距離,一旦超出這個(gè)最大距離,我們啊就讓它回到初始的位置。
修改粒子的定義
class Particle(
var x:Float,//X坐標(biāo)
var y:Float,//Y坐標(biāo)
var radius:Float,//半徑
var speed:Float,//速度
var alpha: Int, //透明度
var maxOffset:Float=300f//最大移動(dòng)距離
)
如上,我們添加了一個(gè)最大移動(dòng)距離。但是有時(shí)候我們往往最大移動(dòng)距離都是固定的,所以我們這里給設(shè)置了一個(gè)默認(rèn)值,如果哪個(gè)粒子想特立獨(dú)行也不是不可以。
有了最大的移動(dòng)距離,我們就得判定,一旦移動(dòng)的距離超過(guò)了這個(gè)值,我們就讓它回到起點(diǎn)。這個(gè)判定在哪里做呢?當(dāng)然是在更新位置的地方啦
粒子運(yùn)動(dòng)距離判定
private fun updateParticle(value: Float) {
particleList.forEach {
if(it.y - centerY >it.maxOffset){
it.y=centerY //重新設(shè)置Y值
it.x = random.nextInt((centerX * 2).toInt()).toFloat() //隨機(jī)設(shè)置X值
it.speed= (random.nextInt(10)+5).toFloat() //隨機(jī)設(shè)置速度
}
it.y += it.speed
}
}
本來(lái)呀,我想慢慢來(lái),先隨機(jī)Y,在隨機(jī)X和速度。
但是我覺(jué)得可以放在一起講,因?yàn)橐粋€(gè)粒子一旦超出這個(gè)最大距離,那么它就相當(dāng)于被回收重新生成一個(gè)新的粒子了,而一個(gè)新的粒子,必然X,Y,速度都是重新生成的,這樣才能看起來(lái)效果不錯(cuò)。
那我們運(yùn)行起來(lái)看看效果把。

emmm似乎還不錯(cuò)的樣子?不過(guò)人家的粒子看起來(lái)很多呀,沒(méi)關(guān)系,我們這里設(shè)置成300個(gè)粒子再試試?

看起來(lái)已經(jīng)不錯(cuò)了。那我們接下來(lái)該怎么辦呢?是不是還有個(gè)透明度沒(méi)搞呀。
透明度的話,我們想想該如何去設(shè)置呢?首先,應(yīng)該是越遠(yuǎn)越透明,直到最大值,完全透明。這就是了,透明度和移動(dòng)距離是息息相關(guān)的。
粒子移動(dòng)透明
private fun updateParticle(value: Float) {
particleList.forEach {
...
//設(shè)置粒子的透明度
it.alpha= ((1f - (it.y-centerY) / it.maxOffset) * 225f).toInt()
...
}
}
override fun onDraw(canvas: Canvas) {
...
var time = measureTimeMillis {
particleList.forEach {
//設(shè)置畫(huà)筆的透明度
paint.alpha=it.alpha
canvas.drawCircle(it.x, it.y, it.radius, paint)
}
}
...
}
再看一下效果。。。

看起來(lái)不錯(cuò)了,有點(diǎn)意思了哦~~不過(guò)好像不夠密集,我們把粒子數(shù)量調(diào)整到500就會(huì)好很多喲。
而且,不知道大家有沒(méi)有發(fā)現(xiàn)在動(dòng)畫(huà)剛剛加載的時(shí)候,那個(gè)效果是很不好的。因?yàn)樗械睦悠鹗键c(diǎn)是一樣的,速度也難免會(huì)有一樣的,所以效果不是很好,只需要在添加粒子的時(shí)候,Y值也初始化即可。
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
...
var nextY=0f
for (i in 0..500) {
...
//初始化Y值,這里是以起始點(diǎn)作為最低值,最大距離作為最大值
nextY= random.nextInt(400)+centerY
speed= random.nextInt(10)+5
particleList.add(
Particle(nextX.toFloat(), nextY, 2f, speed.toFloat(), 100)
)
}
animator.start()
}
這樣一來(lái),效果就會(huì)很好了,沒(méi)有一點(diǎn)問(wèn)題了?,F(xiàn)在看來(lái),似乎除了不是圓形以外,沒(méi)有什么太大的問(wèn)題了。那我們下一步就該思考如何讓它變成圓形那樣生成粒子呢?
定義圓形
首先這個(gè)圓形是圓,但又不能畫(huà)出來(lái)。
什么意思?
就是說(shuō),雖然是圓形生成粒子,但是不能夠畫(huà)出來(lái)這個(gè)圓,所以這個(gè)圓只是個(gè)路徑而已。
路徑是什么?沒(méi)錯(cuò),就是Path。
熟悉的小伙伴們就知道,Path可以添加各種各樣的路徑,由圓,線,曲線等。所以我們這里就需要一個(gè)圓的路徑。
定義一個(gè)Path,添加圓。注意,我們上面講的性能優(yōu)化,不要再onDraw中定義哦。
var path = Path()
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
...
path.addCircle(centerX, centerY, 280f, Path.Direction.CCW)
...
}
在onSizeChanged中我們添加了一個(gè)圓,參數(shù)的意思我就不講了,小伙伴應(yīng)該都明白。
現(xiàn)在我們已經(jīng)定義了這個(gè)Path,但是我們又不畫(huà),那我們?cè)撛趺崔k呢?
我們思考一下,我們?nèi)绻胍獔A形生產(chǎn)粒子的話,是不是得需要這個(gè)圓上的任意一點(diǎn)的X,Y值有了這個(gè)X,Y值,我們才能夠?qū)⒘W拥某跏嘉恢媒o確定呢?看看有沒(méi)人有知道怎么確定位置啊,知道的小伙伴舉手示意一下。
啊,等了十幾分鐘也沒(méi)見(jiàn)有小伙伴舉手,看來(lái)是沒(méi)人了。

好漢饒命!
我說(shuō),我說(shuō),其實(shí)就是PathMeasure這個(gè)類(lèi),它可以幫助我們得到在這個(gè)路徑上任意一點(diǎn)的位置和方向。不會(huì)用的小伙伴趕緊谷歌一下用法吧~或者看我代碼也很好理解的。
private val pathMeasure = PathMeasure()//路徑,用于測(cè)量擴(kuò)散圓某一處的X,Y值
private var pos = FloatArray(2) //擴(kuò)散圓上某一點(diǎn)的x,y
private val tan = FloatArray(2)//擴(kuò)散圓上某一點(diǎn)切線
這里我們定義了三個(gè)變量,首當(dāng)其沖的就是PathMeasure類(lèi),第二個(gè)和第三個(gè)變量是一個(gè)float數(shù)組,pos是用來(lái)保存圓上某一點(diǎn)的位置信息的,其中pos[0]是X值,pos[1]是Y值。
第二個(gè)變量tan是某一點(diǎn)的切線值,你可以暫且理解為是某一點(diǎn)的角度。不過(guò)我們這個(gè)效果用不到,只是個(gè)湊參數(shù)的。
PathMeasure有個(gè)很重要的方法就是getPosTan方法。
boolean getPosTan (float distance, float[] pos, float[] tan)
方法各個(gè)參數(shù)釋義:
參數(shù) 作用 備注 返回值(boolean) 判斷獲取是否成功 true表示成功,數(shù)據(jù)會(huì)存入 pos 和 tan 中, false 表示失敗,pos 和 tan 不會(huì)改變 distance 距離 Path 起點(diǎn)的長(zhǎng)度 取值范圍: 0 <= distance <= getLength pos 該點(diǎn)的坐標(biāo)值 當(dāng)前點(diǎn)在畫(huà)布上的位置,有兩個(gè)數(shù)值,分別為x,y坐標(biāo)。 tan 該點(diǎn)的正切值 當(dāng)前點(diǎn)在曲線上的方向,使用 Math.atan2(tan[1], tan[0]) 獲取到正切角的弧度值。
相信小伙伴還是能看明白的,我這里就不一一解釋了。
所以到了這里,我們已經(jīng)能夠獲取圓上某一點(diǎn)的位置了。還記得我們之前是怎么設(shè)置初始位置的嗎?就是Y值固定,X值隨機(jī),現(xiàn)在我們已經(jīng)能夠得到一個(gè)標(biāo)準(zhǔn)的圓的位置了。但是,很重要啊,但是如果我們按照?qǐng)A的標(biāo)準(zhǔn)位置去一個(gè)個(gè)放粒子的話,豈不就是一個(gè)圓了?而我們的效果圖,位置可看起來(lái)不怎么規(guī)律。
所以我們?cè)诘玫揭粋€(gè)標(biāo)準(zhǔn)的位置之后,需要對(duì)它進(jìn)行一個(gè)隨機(jī)的偏移,偏移的也不能太大,否則成不了一個(gè)圓形。
圓形添加粒子
所以我們要修改添加粒子的代碼了。
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
centerX = (w / 2).toFloat()
centerY = (h / 2).toFloat()
path.addCircle(centerX, centerY, 280f, Path.Direction.CCW)
pathMeasure.setPath(path, false) //添加path
var nextX = 0f
var speed=0
var nextY=0f
for (i in 0..500) {
//按比例測(cè)量路徑上每一點(diǎn)的值
pathMeasure.getPosTan(i / 500f * pathMeasure.length, pos, tan)
nextX = pos[0]+random.nextInt(6) - 3f //X值隨機(jī)偏移
nextY= pos[1]+random.nextInt(6) - 3f//Y值隨機(jī)偏移
speed= random.nextInt(10)+5
particleList.add(
Particle(nextX, nextY, 2f, speed.toFloat(), 100)
)
}
animator.start()
}
現(xiàn)在運(yùn)行起來(lái)就是這樣子了

咦,效果和我想象的不一樣啊。最初好像是個(gè)圓,可是不是應(yīng)該像漣漪一樣擴(kuò)散嗎,可你這還是往下落呀。
還記得我們之前定義的動(dòng)畫(huà)的效果嗎,就是X值不變,Y值不斷擴(kuò)大,那可不就是一直往下落嗎?所以這里我們需要修改動(dòng)畫(huà)規(guī)則。
修改動(dòng)畫(huà)
問(wèn)題是怎么修改動(dòng)畫(huà)呢?
思考一下,效果圖中的動(dòng)畫(huà)應(yīng)該是往外擴(kuò)散,擴(kuò)散是什么意思?就是沿著它到圓心的方向反向運(yùn)動(dòng),對(duì)不對(duì)?
上一張圖來(lái)理解一下

此時(shí)內(nèi)心圓是我們現(xiàn)在粒子所處的圓,假設(shè)有一個(gè)粒子此時(shí)在B點(diǎn),那么如果要擴(kuò)散的話,它應(yīng)該到H點(diǎn)位置。
這個(gè)H點(diǎn)的位置應(yīng)該如何獲取呢?
如果以A點(diǎn)為原點(diǎn)的話,此時(shí)B點(diǎn)的位置我們是知道的,它分別是X和Y。X=AG,Y=BG。我們也應(yīng)該能發(fā)現(xiàn),由AB延申至AH的過(guò)程中,∠Z是始終不變的。
同時(shí)我們應(yīng)該能發(fā)現(xiàn),擴(kuò)散這個(gè)過(guò)程實(shí)際上是圓變大了,所以B變挪到了H點(diǎn)上。而這個(gè)擴(kuò)大的值的意思就是圓的半徑變大了,即半徑R = AB,現(xiàn)在半徑R=AH。
AB的值我們是知道的,就是我們一開(kāi)始畫(huà)的圓的半徑嘛??墒茿H是多少呢?
不妨令移動(dòng)距離offset=AH-AB,那么這個(gè)運(yùn)動(dòng)距離offset是多少呢?我們想一下,在之前的下落中,距離是不是等于速度乘以時(shí)間呢?而我們這里沒(méi)有時(shí)間這個(gè)變量,有的只是一次次循環(huán),循環(huán)中粒子的Y值不斷加速度。所以我們需要一個(gè)變量offset值來(lái)記錄移動(dòng)的距離,
所以這個(gè)offset += speed
那我們現(xiàn)在offset知道了,也就是說(shuō)AH-AB的值知道了,AB我們也知道,我們就能求出AH的值
AH=AB +offset
AH知道了,∠Z也知道了,利用三角函數(shù)我們可以得到H點(diǎn)的坐標(biāo)了。設(shè)初始半徑為R=AB
A點(diǎn)為原點(diǎn),

所以AD:

HD:

按理說(shuō)沒(méi)問(wèn)題了,這個(gè)時(shí)候H的值我們已經(jīng)得到了。但是,注意此時(shí)我們是以A點(diǎn)為原點(diǎn)得出來(lái)的值,而我們的手機(jī)屏幕中是以左上角為原點(diǎn)的。A點(diǎn)的值我們此時(shí)在程序中寫(xiě)死了是centerX和centerY,所以上面的公式還得改一下

注意哦,此時(shí)只是AD和HD的值,只是這兩個(gè)線段的長(zhǎng)度而不是真正H點(diǎn)的坐標(biāo)。H點(diǎn)的坐標(biāo)應(yīng)該在A點(diǎn)的基礎(chǔ)上增加,即

而且這只是在右上半?yún)^(qū)也就是第一象限是這樣計(jì)算的,左半?yún)^(qū)和右下半?yún)^(qū)的計(jì)算規(guī)則也不一樣。

兄臺(tái),不要急,先聽(tīng)我說(shuō),一會(huì)還不懂的話我親自幫你踢板凳。
小伙伴紛紛表示,上邊的公式也太復(fù)雜了,每個(gè)象限都計(jì)算一遍,自定義View就這么復(fù)雜嗎?
哈哈哈哈,其實(shí)不是。我晃點(diǎn)你的。
規(guī)則也確實(shí)是上面所說(shuō)的,但是我們是什么人?程序員啊,最不應(yīng)該怕的就是計(jì)算了。反正CPU算不是我算。
我們?cè)诜治鲆槐?,這次一定很簡(jiǎn)單,你不要跑!
首先有個(gè)角度Z,我們需要記下每個(gè)粒子的角度,可是這個(gè)角度的計(jì)算就有的說(shuō)道了。
我們先以左右兩個(gè)半?yún)^(qū)計(jì)算,在右半?yún)^(qū)的時(shí)候角度

假設(shè)這個(gè)時(shí)候∠Z是30°,那么也就是說(shuō)

那么H的X值也就是

可是如果在左半?yún)^(qū)的話角度Z

那么此時(shí)H的X值應(yīng)該是這么算

其實(shí)本質(zhì)上

我們根本不需要考慮左右的問(wèn)題,因?yàn)槿绻谟疫卌os∠Z是正,在左邊為負(fù)數(shù),所以直接加就可以。
而我們需要考慮的是上下問(wèn)題,也就是Y的問(wèn)題。畢竟這個(gè)正負(fù)是基于X的值算出來(lái)的。當(dāng)我們轉(zhuǎn)換成角度以后需要根據(jù)此時(shí)H的Y值是否大于centerY來(lái)分別計(jì)算。
當(dāng)H的Y值在centerY之上,也就是H.Y<centerY

反之

這樣的話隨著offset的增長(zhǎng),H點(diǎn)的坐標(biāo)也能夠隨時(shí)的計(jì)算出來(lái)了。
話說(shuō)再多也沒(méi)用,還是代碼更為直觀。
根據(jù)上面的描述,我們需要給粒子添加兩個(gè)屬性,一個(gè)是移動(dòng)距離,一個(gè)是粒子的角度
class Particle(
...
var offset:Int,//當(dāng)前移動(dòng)距離
var angle:Double,//粒子角度
...
)
在添加粒子的地方修改:
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
...
var angle=0.0
for (i in 0..500) {
...
//反余弦函數(shù)可以得到角度值,是弧度
angle=acos(((pos[0] - centerX) / 280f).toDouble())
speed= random.nextInt(10)+5
particleList.add(
Particle(nextX, nextY, 2f, speed.toFloat(), 100,0,angle)
)
}
animator.start()
}
在更新粒子動(dòng)畫(huà)的地方修改:
private fun updateParticle(value: Float) {
particleList.forEach {particle->
if(particle.offset >particle.maxOffset){
particle.offset=0
particle.speed= (random.nextInt(10)+5).toFloat()
}
particle.alpha= ((1f - particle.offset / particle.maxOffset) * 225f).toInt()
particle.x = (centerX+ cos(particle.angle) * (280f + particle.offset)).toFloat()
if (particle.y > centerY) {
particle.y = (sin(particle.angle) * (280f + particle.offset) + centerY).toFloat()
} else {
particle.y = (centerY - sin(particle.angle) * (280f + particle.offset)).toFloat()
}
particle.offset += particle.speed.toInt()
}
}
添加粒子的地方angle是角度值,用了Kotlin的acos反余弦函數(shù),這個(gè)函數(shù)返回的是0-PI的弧度制,0-PI的取值范圍也就意味著sin∠Z始終是正值,上面公式中的絕對(duì)值就不需要了。
更新的代碼很簡(jiǎn)單,對(duì)照公式一看便知。此時(shí)我們運(yùn)行一下,效果就已經(jīng)很好了,很接近了。

現(xiàn)在感覺(jué)是不是好多了?就是速度有點(diǎn)快,粒子有點(diǎn)少~沒(méi)關(guān)系,我們做一些優(yōu)化工作,比如說(shuō),粒子的初始移動(dòng)距離也隨機(jī)取一個(gè)值,粒子的最大距離也隨機(jī)。這樣下來(lái),我們的效果就十分的好了
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
centerX = (w / 2).toFloat()
centerY = (h / 2).toFloat()
path.addCircle(centerX, centerY, 280f, Path.Direction.CCW)
pathMeasure.setPath(path, false)
var nextX = 0f
var speed=0f
var nextY=0f
var angle=0.0
var offSet=0
var maxOffset=0
for (i in 0..2000) {
pathMeasure.getPosTan(i / 2000f * pathMeasure.length, pos, tan)
nextX = pos[0]+random.nextInt(6) - 3f
nextY= pos[1]+random.nextInt(6) - 3f
angle=acos(((pos[0] - centerX) / 280f).toDouble())
speed= random.nextInt(2) + 2f
offSet = random.nextInt(200)
maxOffset = random.nextInt(200)
particleList.add(
Particle(nextX, nextY, 2f, speed, 100,offSet.toFloat(),angle, maxOffset.toFloat())
)
}
animator.start()
}
其實(shí)還有很多可以優(yōu)化的地方,比如說(shuō),粒子的數(shù)量抽取為一個(gè)常量,中間圓的半徑也可以定為一個(gè)屬性值去手動(dòng)設(shè)置等等。。不過(guò)這些都是小意思,相信小伙伴們一定可以自己搞定的。我就不班門(mén)弄斧了。
最后這是優(yōu)化過(guò)后的效果,接下來(lái)的與圖片結(jié)合,就希望小伙伴們自己實(shí)現(xiàn)一下啦~很簡(jiǎn)單的??梢栽谠u(píng)論區(qū)交作業(yè)哦~

這是我的成品地址,做了一些額外的優(yōu)化,比如說(shuō)x和y的隨機(jī)偏移等。小伙伴們可以手動(dòng)去實(shí)現(xiàn)一下或者看我代碼,當(dāng)然我代碼只是為了實(shí)現(xiàn)效果,并沒(méi)有做更多的優(yōu)化,不要噴我哦:
DimpleView
https://github.com/MlxChange/ParticleView
·················END················· 推薦閱讀
? 耗時(shí)2年,Android進(jìn)階三部曲第三部《Android進(jìn)階指北》出版!
推薦我的技術(shù)博客
推薦一下我的獨(dú)立博客: liuwangshu.cn ,內(nèi)含Android最強(qiáng)原創(chuàng)知識(shí)體系,一直在更新,歡迎體驗(yàn)和收藏!
歡迎加入BATcoder技術(shù)交流群
你好,我是劉望舒,被百度百科收錄的騰訊云TVP專家,著有三本技術(shù)暢銷(xiāo)書(shū),蟬聯(lián)四屆電子工業(yè)出版社年度優(yōu)秀作者,谷歌開(kāi)發(fā)者社區(qū)特邀講師。
前華為面試官,現(xiàn)大廠技術(shù)負(fù)責(zé)人。
歡迎添加我的微信 henglimogan ,備注:BATcoder,加入BATcoder交流群。
明天見(jiàn)(??ω??)
瀏覽
67
作者:Mlx
鏈接:https://juejin.im/post/6871049441546567688
1.前言
前段時(shí)間,女朋友用網(wǎng)易云音樂(lè)的時(shí)候看到一個(gè)宇宙塵埃特效,說(shuō)很好看,想要讓我給她開(kāi)VIP用。
笑話,作為一個(gè)程序員為什么不能自己實(shí)現(xiàn)!開(kāi)什么VIP!!
0202年了,Android開(kāi)發(fā)大都應(yīng)該是老油條了吧。如果你自定義View還是掌握得不夠熟練的話,那可就說(shuō)不過(guò)去了哦。自定義View可以說(shuō)是Android開(kāi)發(fā)中,無(wú)論是初級(jí),中級(jí)還是高級(jí)都必須掌握的一個(gè)點(diǎn)。
不然的話,UI一不小心設(shè)計(jì)的太炫酷,那你豈不是要和他打起來(lái)了?難道你不想成為下圖中的男人嗎?

所以,自定義View的重要性已經(jīng)不用我多說(shuō)了。本篇是針對(duì)有自定義View基礎(chǔ)知識(shí),但是苦于沒(méi)有好的項(xiàng)目模仿,或者說(shuō)看到了酷炫效果沒(méi)有思路不知道該如何下手的人。恭喜你,我將一步步手把手的帶你分析效果,然后代碼實(shí)現(xiàn)它。
我就知道沒(méi)圖是騙不到人的。先放圖,大家看一下最終實(shí)現(xiàn)的效果。
ps:為了能更快加載出來(lái),gif是壓縮了又壓縮,大家可以腦部清晰度。
ps2:小伙伴如果有好的gif壓縮網(wǎng)站可以推薦一波

咳咳,雖然畫(huà)質(zhì)不行,但是還是能看的出來(lái)效果是非常不錯(cuò)的。那么今天我就帶小伙伴們一起從頭到尾的實(shí)現(xiàn)一下這個(gè)效果吧。
2.特效分析
首先看動(dòng)圖,我們可以拆成兩部分完成,一個(gè)是里面不斷旋轉(zhuǎn)的圓形圖片,一個(gè)是外面不斷擴(kuò)散的粒子動(dòng)效。
我們由易到難來(lái)完成,畢竟柿子要挑軟的捏嘛。
另外由于本篇重點(diǎn)是講自定義View的,所以就不采用ViewGroup的方式來(lái)實(shí)現(xiàn)圖片和粒子動(dòng)效的結(jié)合了。而是采用分開(kāi)布局的方式。這樣做的好處是可以只專注于粒子動(dòng)效的實(shí)現(xiàn),而不需要去考慮測(cè)量,布局等。
至于自定義ViewGroup,下一篇文章我將會(huì)帶領(lǐng)大家實(shí)現(xiàn)一個(gè)非常非常非??犰诺男Ч?/span>
加載圖片
我們先觀察,首先這是一個(gè)圓形圖片。其次,它在不停的轉(zhuǎn)。

咳咳,先別罵,容我說(shuō)完嘛。
圓形圖片的話我們就用Glide來(lái)進(jìn)行實(shí)現(xiàn)把,其實(shí)自定義View實(shí)現(xiàn)也可以,但我們重點(diǎn)還是粒子特效。
首先定義一個(gè)ImageView
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/rootLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<ImageView
android:id="@+id/music_avatar"
android:layout_centerInParent="true"
android:layout_width="178dp"
android:layout_height="178dp"/>
</RelativeLayout>
現(xiàn)在我們?nèi)ctivity中,用Glide加載一張圓形圖片。
class DemoActivity : AppCompatActivity() {
private lateinit var demoBinding: ActivityDemoBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
demoBinding = ActivityDemoBinding.inflate(layoutInflater)
setContentView(demoBinding.root)
lifecycleScope.launch(Dispatchers.Main) {
loadImage()
}
}
private suspend fun loadImage() {
withContext(Dispatchers.IO) {
Glide.with(this@DemoActivity)
.load(R.drawable.ic_music)
.circleCrop()
.into(object : ImageViewTarget<Drawable>(demoBinding.musicAvatar) {
override fun setResource(resource: Drawable?) {
demoBinding.musicAvatar.setImageDrawable(resource)
}
})
}
}
}
這樣我們利用Glide就加載了一個(gè)圓形的圖片。
3.旋轉(zhuǎn)圖片
圖片有了,接下來(lái)就應(yīng)該是旋轉(zhuǎn)了。
那么我們開(kāi)始搞旋轉(zhuǎn)。
旋轉(zhuǎn)是如何實(shí)現(xiàn)的?我想不用我多說(shuō),很多小伙伴都知道,是動(dòng)畫(huà)嘛。
沒(méi)錯(cuò),就是動(dòng)畫(huà)。我們這里使用屬性動(dòng)畫(huà)來(lái)實(shí)現(xiàn)。
定義一個(gè)屬性動(dòng)畫(huà)并且給圖片設(shè)置一個(gè)點(diǎn)擊事件,讓它旋轉(zhuǎn)起來(lái)
lateinit var rotateAnimator: ObjectAnimator
override fun onCreate(savedInstanceState: Bundle?) {
...
setContentView(demoBinding.root)
rotateAnimator = ObjectAnimator.ofFloat(demoBinding.musicAvatar, View.ROTATION, 0f, 360f)
rotateAnimator.duration = 6000
rotateAnimator.repeatCount = -1
rotateAnimator.interpolator = LinearInterpolator()
lifecycleScope.launch(Dispatchers.Main) {
loadImage()
//添加點(diǎn)擊事件,并且啟動(dòng)動(dòng)畫(huà)
demoBinding.musicAvatar.setOnClickListener {
rotateAnimator.start()
}
}
}
這些都是小兒科了,相信面對(duì)電視機(jī)前的觀眾朋友們,啊不,口誤口誤。
相信小伙伴們都很熟悉了,那我們開(kāi)始今天的重頭戲,這個(gè)粒子動(dòng)畫(huà)。
4.粒子動(dòng)畫(huà)
其實(shí)我很久以前看粒子動(dòng)畫(huà)的時(shí)候,也很好奇,這些炫酷的粒子動(dòng)畫(huà)是怎么實(shí)現(xiàn)的,當(dāng)時(shí)的我完全沒(méi)有思路。
尤其是看到一些圖片,啪唧一下變成了一堆粒子,掉落,然后又呱唧從粒子變成了圖片,就覺(jué)得異常的牛X。
其實(shí)啊,一點(diǎn)都不神奇。
首先我們要知道bitmap是什么。bitmap是什么呀?
在數(shù)學(xué)上,有這么幾個(gè)概念,點(diǎn),線,面。點(diǎn)很好理解,就是一個(gè)點(diǎn)。線是由一堆點(diǎn)組成的,而面又類(lèi)似于一堆線組成的。本質(zhì)上,面就是由無(wú)數(shù)的點(diǎn)組成的。
可是這和bitmap以及今天的粒子動(dòng)畫(huà)有什么關(guān)系呢?
一個(gè)bitmap,我們可以簡(jiǎn)單地理解為一張圖片。這個(gè)圖片是不是一個(gè)平面呢?而平面又是一堆點(diǎn)組成的,這個(gè)點(diǎn)在這里稱為像素點(diǎn)。所以bitmap就是由一堆像素點(diǎn)所組成的,有趣的是,這些像素點(diǎn)是有顏色的,當(dāng)這些像素點(diǎn)足夠小,你離得足夠遠(yuǎn)你看起來(lái)就像一幅完整的畫(huà)了。
在現(xiàn)實(shí)中也不乏這樣的例子,舉辦一些活動(dòng)的時(shí)候,一個(gè)個(gè)人穿著不同顏色的衣服有序的站在廣場(chǎng)上,如果有一架無(wú)人機(jī)在空中看,就能看到是一幅畫(huà)。就像這樣

所以當(dāng)把一幅畫(huà)拆成一堆粒子的話,其實(shí)就是獲得bitmap中所有的像素點(diǎn),然后改變他們的位置就可以了。如果想要用一堆粒子拼湊出一幅畫(huà),只需要知道這些粒子的順序,排放整齊自然就是一幅畫(huà)了。
扯遠(yuǎn)了,說(shuō)這些呢其實(shí)和今天的效果沒(méi)有特別強(qiáng)的聯(lián)系,只是為了讓你能夠更好的理解粒子動(dòng)畫(huà)的本質(zhì)。

粒子動(dòng)畫(huà)分析
我們先觀察這個(gè)特效,你會(huì)發(fā)現(xiàn)有一個(gè)圓,這個(gè)圓上不斷的往外發(fā)散粒子,粒子在發(fā)散的過(guò)程中速度是不相同的。而且,在發(fā)散的過(guò)程中,透明度也在不斷變化,直到最后完全透明。
好,我們歸納一下。
圓形生產(chǎn)粒子
粒子速度不同,也就是隨機(jī)。
粒子透明度不斷降低,直到最后消散。
粒子沿著到圓心的反方向擴(kuò)散。
寫(xiě)自定義View的時(shí)候千萬(wàn)不要一上來(lái)就開(kāi)干,而是要逐漸分析,有的時(shí)候我們遇到一個(gè)復(fù)雜的效果,更是要逐幀的分析。
而且我寫(xiě)自定義View的時(shí)候有個(gè)習(xí)慣,就是一點(diǎn)點(diǎn)的實(shí)現(xiàn)效果,不會(huì)去一次性實(shí)現(xiàn)全部的效果。
所以我們第一步,生產(chǎn)粒子。
生產(chǎn)粒子
首先,我們可以知道,粒子是有顏色的,但是似乎這個(gè)效果粒子只有白色,那就指定粒子顏色為白色了。
然后我們可以得出,粒子是有位置的,位置肯定由x,y組成嘛。然后粒子還有個(gè)速度,以及透明度和半徑。
定義粒子
我們可以定義一個(gè)粒子類(lèi):
class Particle(
var x:Float,//X坐標(biāo)
var y:Float,//Y坐標(biāo)
var radius:Float,//半徑
var speed:Float,//速度
var alpha: Int//透明度
)
由于我們的這個(gè)效果看起來(lái)就像是水波一樣的漣漪,我給自定義View起名為漣漪,也就是dimple。
我們來(lái)定義這個(gè)自定義View吧。
定義自定義view
class DimpleView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
//定義一個(gè)粒子的集合
private var particleList = mutableListOf<Particle>()
//定義畫(huà)筆
var paint = Paint()
}
一開(kāi)始就直接圓形生產(chǎn)粒子著實(shí)有些難度,我先考慮考慮如何實(shí)現(xiàn)生產(chǎn)粒子吧。
先不斷生產(chǎn)粒子,然后再考慮圓形的事情。
而且生產(chǎn)一堆粒子比較麻煩,我先實(shí)現(xiàn)從上到下生產(chǎn)一個(gè)粒子。
那么如何生產(chǎn)一個(gè)粒子呢?前面也說(shuō)了,粒子就是個(gè)很小的點(diǎn),所以用canvas的drawCircle就可以。
那我們來(lái)吧
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
paint.color = Color.WHITE
paint.isAntiAlias = true
var particle=Particle(0f,0f,2f,2f,100)
canvas.drawCircle(particle.x, particle.y, particle.radius, paint)
}
畫(huà)畫(huà)嘛,就要在onDraw方法中進(jìn)行了。我們先new一個(gè)Particle,然后畫(huà)出來(lái)。
實(shí)際上這樣并沒(méi)有什么效果。為啥呢?
我們的背景是白色的,粒子默認(rèn)是白色的,你當(dāng)然看不到了。所以我們需要先做個(gè)測(cè)試,為了能看出效果。這里啊,我們把背景換成黑色。
同時(shí),為了方便測(cè)試,先把Imageview設(shè)置成不可見(jiàn)。然后我們看下效果

沒(méi)錯(cuò),就是沒(méi)什么效果。你什么都看不出來(lái)。
先不急,慢慢來(lái),且聽(tīng)我吹,啊不,且聽(tīng)我和你慢慢道來(lái)。
我們?cè)谶@里只花了一個(gè)圓,而且是在坐標(biāo)原點(diǎn)畫(huà)了一個(gè)半徑為2的點(diǎn),可以說(shuō)很小很小了。自然就看不到了。
什么,你不知道原點(diǎn)在哪?

棕色部分就是我們的屏幕,所以原點(diǎn)就是左上角。
現(xiàn)在我們需要做的事情只有兩個(gè),要么把點(diǎn)變大,要么改變點(diǎn)的位置。
粒子粒子的,當(dāng)然不能變大,所以我們把它放到屏幕中心去。
所以我們定義一個(gè)屏幕中心的坐標(biāo),centerX,centerY。并且在onSizeChanged方法中給它們賦值
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
centerX= (w/2).toFloat()
centerY= (h/2).toFloat()
}
那我們改一下上面的畫(huà)點(diǎn)的代碼:
override fun onDraw(canvas: Canvas) {
...
var particle=Particle(centerX,centerY,2f,2f,100)
canvas.drawCircle(particle.x, particle.y, particle.radius, paint)
}如此,可以看到這個(gè)點(diǎn)了,雖然很小很小,但是也勝過(guò)沒(méi)有呀

可是這時(shí)候有人跳出來(lái)了,說(shuō)你這不對(duì)啊,一個(gè)點(diǎn)有啥用?還那么小,我本來(lái)就近視,你這搞得我更看不清了。你是不是眼睛店派來(lái)的叛徒!
添加多個(gè)粒子
那好吧,我們多加幾個(gè)。可是該怎么加?效果圖中是圓形的,可是我不會(huì)啊,我只能先試試一橫排添加??纯催@樣可不可以呢?我們知道,橫排的話就是y值不變,x變。好,但是為了避免我們畫(huà)出一條線,我們x值隨機(jī)增加,這樣的話看起來(lái)也比較不規(guī)則一些。
那么代碼就應(yīng)該是這樣了
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
paint.color = Color.WHITE
paint.isAntiAlias = true
for (i in 0..50){
var random= Random()
var nextX=random.nextInt((centerX*2).toInt())
var particle=Particle(nextX.toFloat(),centerY,2f,2f,100)
canvas.drawCircle(particle.x, particle.y, particle.radius, paint)
}
}
由于centerX是屏幕的中心,所以它的值是屏幕寬度的一半,這里的話X的值就是在屏幕寬度內(nèi)隨機(jī)選一個(gè)值。那么效果看起來(lái)是下面這樣

效果看起來(lái)不錯(cuò)了。
但是總有愛(ài)搞事的小伙伴又跳出來(lái)了,說(shuō)你會(huì)不會(huì)寫(xiě)代碼?onDraw方法一直被調(diào)用,不能定義對(duì)象你不知道么?很容易引發(fā)頻繁的GC,造成內(nèi)存抖動(dòng)的。而且你這還搞個(gè)循環(huán),性能能行不?
這個(gè)小伙伴你說(shuō)的非常對(duì),是我錯(cuò)了!
確實(shí),在ondraw方法中不適合定義對(duì)象,尤其是for循環(huán)中就更不能了。段時(shí)間看,我們50個(gè)粒子好像對(duì)性能的開(kāi)銷(xiāo)不是很大,但是一旦粒子數(shù)量很多,性能開(kāi)銷(xiāo)就會(huì)十分的大。而且,為了不掉幀,我們需要在16ms之內(nèi)完成繪制。
這個(gè)不明白的話我后續(xù)會(huì)有性能優(yōu)化的專題,可以關(guān)注一下我~
這里我們測(cè)量一下50個(gè)粒子的繪制時(shí)間和5000個(gè)粒子的繪制時(shí)間。
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
paint.color = Color.WHITE
paint.isAntiAlias = true
var time= measureTimeMillis {
for (i in 0..50){
var random= Random()
var nextX=random.nextInt((centerX*2).toInt())
var particle=Particle(nextX.toFloat(),centerY,2f,2f,100)
canvas.drawCircle(particle.x, particle.y, particle.radius, paint)
}
}
Log.i("dimple","繪制時(shí)間$time ms")
}
結(jié)果如下:50個(gè)粒子的繪制時(shí)間

5000個(gè)粒子的繪制時(shí)間:

可以看到,明顯超了16ms。所以我們需要優(yōu)化,怎么優(yōu)化?很簡(jiǎn)單,就是不在ondraw方法中創(chuàng)建對(duì)象就好了,那我們選擇在哪里呢?
構(gòu)造方法可以嗎?好像不可以呢,這個(gè)時(shí)候還沒(méi)辦法獲得屏幕寬高,嘿嘿嘿,onSizeChanged方法就決定是你了!
粒子添加到集合中
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
centerX= (w/2).toFloat()
centerY= (h/2).toFloat()
val random= Random()
var nextX=0
for (i in 0..5000){
nextX=random.nextInt((centerX*2).toInt())
particleList.add(Particle(nextX.toFloat(),centerY,2f,2f,100))
}
}我們?cè)賮?lái)看看onDraw方法中繪制時(shí)間是多少:
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
paint.color = Color.WHITE
paint.isAntiAlias = true
var time= measureTimeMillis {
particleList.forEach {
canvas.drawCircle(it.x,it.y,it.radius,paint)
}
}
Log.i("dimple","繪制時(shí)間$time ms")
}

emmmm,好像是低于16ms了,可是這也太危險(xiǎn)了吧,你這分分鐘就超過(guò)了16ms啊。
確實(shí)是這樣子,但是實(shí)際情況下,我們并不需要5000個(gè)這么多的粒子。又有人問(wèn),,萬(wàn)一真的需要怎么辦?那就得看surfaceView了。這里就不講了
我們還是回過(guò)頭來(lái),先把粒子數(shù)量變成50個(gè)。
現(xiàn)在粒子也有了,該實(shí)現(xiàn)動(dòng)起來(lái)的效果了。
動(dòng)起來(lái),我們想想,應(yīng)該怎么做呢?效果圖是類(lèi)似圓一樣的擴(kuò)散,我現(xiàn)在做不到,我往下掉這應(yīng)該不難吧?
說(shuō)動(dòng)就動(dòng),搞起!至于怎么動(dòng),那肯定是屬性動(dòng)畫(huà)呀。
定義動(dòng)畫(huà)
private var animator = ValueAnimator.ofFloat(0f, 1f)
init {
animator.duration = 2000
animator.repeatCount = -1
animator.interpolator = LinearInterpolator()
animator.addUpdateListener {
updateParticle(it.animatedValue as Float)
invalidate()//重繪界面
}
}我在這里啊,定義了一個(gè)方法updateParticle,每次動(dòng)畫(huà)更新的時(shí)候啊就去更新粒子的狀態(tài)。
updateParticle方法應(yīng)該去做什么事情呢?我們來(lái)開(kāi)動(dòng)小腦筋想想。
如果說(shuō)是粒子不斷往下掉的話,那應(yīng)該是y值不斷地增加就可以了,嗯,非常有道理。
我們來(lái)實(shí)現(xiàn)一下這個(gè)方法
更新粒子位置
private fun updateParticle(value: Float) {
particleList.forEach {
it.y += it.speed
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
...
animator.start()//別忘了啟動(dòng)動(dòng)畫(huà)
}那我們現(xiàn)在來(lái)看一下效果如何

emmmm看起來(lái)有點(diǎn)雛形了,不過(guò)效果圖里的粒子速度似乎是隨機(jī)的,咱們這里是同步的呀。
沒(méi)關(guān)系,我們可以讓粒子的速度變成隨機(jī)的速度。我們修改添加粒子這里的代碼
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
centerX = (w / 2).toFloat()
centerY = (h / 2).toFloat()
val random = Random()
var nextX = 0
var speed=0 //定義一個(gè)速度
for (i in 0..50) {
nextX = random.nextInt((centerX * 2).toInt())
speed= random.nextInt(10)+5 //速度從5-15不等
particleList.add(
Particle(nextX.toFloat(), centerY, 2f, speed.toFloat(), 100)
)
}
animator.start()
}這是效果,看起來(lái)有點(diǎn)樣子了。不過(guò)問(wèn)題又來(lái)了,人家的粒子是一直散發(fā)的,你這個(gè)粒子怎么
沒(méi)了就是沒(méi)了呢?

有道理,所以我覺(jué)得我們需要設(shè)置一個(gè)粒子移動(dòng)的最大距離,一旦超出這個(gè)最大距離,我們啊就讓它回到初始的位置。
修改粒子的定義
class Particle(
var x:Float,//X坐標(biāo)
var y:Float,//Y坐標(biāo)
var radius:Float,//半徑
var speed:Float,//速度
var alpha: Int, //透明度
var maxOffset:Float=300f//最大移動(dòng)距離
)如上,我們添加了一個(gè)最大移動(dòng)距離。但是有時(shí)候我們往往最大移動(dòng)距離都是固定的,所以我們這里給設(shè)置了一個(gè)默認(rèn)值,如果哪個(gè)粒子想特立獨(dú)行也不是不可以。
有了最大的移動(dòng)距離,我們就得判定,一旦移動(dòng)的距離超過(guò)了這個(gè)值,我們就讓它回到起點(diǎn)。這個(gè)判定在哪里做呢?當(dāng)然是在更新位置的地方啦
粒子運(yùn)動(dòng)距離判定
private fun updateParticle(value: Float) {
particleList.forEach {
if(it.y - centerY >it.maxOffset){
it.y=centerY //重新設(shè)置Y值
it.x = random.nextInt((centerX * 2).toInt()).toFloat() //隨機(jī)設(shè)置X值
it.speed= (random.nextInt(10)+5).toFloat() //隨機(jī)設(shè)置速度
}
it.y += it.speed
}
}
本來(lái)呀,我想慢慢來(lái),先隨機(jī)Y,在隨機(jī)X和速度。
但是我覺(jué)得可以放在一起講,因?yàn)橐粋€(gè)粒子一旦超出這個(gè)最大距離,那么它就相當(dāng)于被回收重新生成一個(gè)新的粒子了,而一個(gè)新的粒子,必然X,Y,速度都是重新生成的,這樣才能看起來(lái)效果不錯(cuò)。
那我們運(yùn)行起來(lái)看看效果把。

emmm似乎還不錯(cuò)的樣子?不過(guò)人家的粒子看起來(lái)很多呀,沒(méi)關(guān)系,我們這里設(shè)置成300個(gè)粒子再試試?

看起來(lái)已經(jīng)不錯(cuò)了。那我們接下來(lái)該怎么辦呢?是不是還有個(gè)透明度沒(méi)搞呀。
透明度的話,我們想想該如何去設(shè)置呢?首先,應(yīng)該是越遠(yuǎn)越透明,直到最大值,完全透明。這就是了,透明度和移動(dòng)距離是息息相關(guān)的。
粒子移動(dòng)透明
private fun updateParticle(value: Float) {
particleList.forEach {
...
//設(shè)置粒子的透明度
it.alpha= ((1f - (it.y-centerY) / it.maxOffset) * 225f).toInt()
...
}
}
override fun onDraw(canvas: Canvas) {
...
var time = measureTimeMillis {
particleList.forEach {
//設(shè)置畫(huà)筆的透明度
paint.alpha=it.alpha
canvas.drawCircle(it.x, it.y, it.radius, paint)
}
}
...
}
再看一下效果。。。

看起來(lái)不錯(cuò)了,有點(diǎn)意思了哦~~不過(guò)好像不夠密集,我們把粒子數(shù)量調(diào)整到500就會(huì)好很多喲。
而且,不知道大家有沒(méi)有發(fā)現(xiàn)在動(dòng)畫(huà)剛剛加載的時(shí)候,那個(gè)效果是很不好的。因?yàn)樗械睦悠鹗键c(diǎn)是一樣的,速度也難免會(huì)有一樣的,所以效果不是很好,只需要在添加粒子的時(shí)候,Y值也初始化即可。
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
...
var nextY=0f
for (i in 0..500) {
...
//初始化Y值,這里是以起始點(diǎn)作為最低值,最大距離作為最大值
nextY= random.nextInt(400)+centerY
speed= random.nextInt(10)+5
particleList.add(
Particle(nextX.toFloat(), nextY, 2f, speed.toFloat(), 100)
)
}
animator.start()
}
這樣一來(lái),效果就會(huì)很好了,沒(méi)有一點(diǎn)問(wèn)題了?,F(xiàn)在看來(lái),似乎除了不是圓形以外,沒(méi)有什么太大的問(wèn)題了。那我們下一步就該思考如何讓它變成圓形那樣生成粒子呢?
定義圓形
首先這個(gè)圓形是圓,但又不能畫(huà)出來(lái)。
什么意思?
就是說(shuō),雖然是圓形生成粒子,但是不能夠畫(huà)出來(lái)這個(gè)圓,所以這個(gè)圓只是個(gè)路徑而已。
路徑是什么?沒(méi)錯(cuò),就是Path。
熟悉的小伙伴們就知道,Path可以添加各種各樣的路徑,由圓,線,曲線等。所以我們這里就需要一個(gè)圓的路徑。
定義一個(gè)Path,添加圓。注意,我們上面講的性能優(yōu)化,不要再onDraw中定義哦。
var path = Path()
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
...
path.addCircle(centerX, centerY, 280f, Path.Direction.CCW)
...
}
在onSizeChanged中我們添加了一個(gè)圓,參數(shù)的意思我就不講了,小伙伴應(yīng)該都明白。
現(xiàn)在我們已經(jīng)定義了這個(gè)Path,但是我們又不畫(huà),那我們?cè)撛趺崔k呢?
我們思考一下,我們?nèi)绻胍獔A形生產(chǎn)粒子的話,是不是得需要這個(gè)圓上的任意一點(diǎn)的X,Y值有了這個(gè)X,Y值,我們才能夠?qū)⒘W拥某跏嘉恢媒o確定呢?看看有沒(méi)人有知道怎么確定位置啊,知道的小伙伴舉手示意一下。
啊,等了十幾分鐘也沒(méi)見(jiàn)有小伙伴舉手,看來(lái)是沒(méi)人了。

好漢饒命!
我說(shuō),我說(shuō),其實(shí)就是PathMeasure這個(gè)類(lèi),它可以幫助我們得到在這個(gè)路徑上任意一點(diǎn)的位置和方向。不會(huì)用的小伙伴趕緊谷歌一下用法吧~或者看我代碼也很好理解的。
private val pathMeasure = PathMeasure()//路徑,用于測(cè)量擴(kuò)散圓某一處的X,Y值
private var pos = FloatArray(2) //擴(kuò)散圓上某一點(diǎn)的x,y
private val tan = FloatArray(2)//擴(kuò)散圓上某一點(diǎn)切線這里我們定義了三個(gè)變量,首當(dāng)其沖的就是PathMeasure類(lèi),第二個(gè)和第三個(gè)變量是一個(gè)float數(shù)組,pos是用來(lái)保存圓上某一點(diǎn)的位置信息的,其中pos[0]是X值,pos[1]是Y值。
第二個(gè)變量tan是某一點(diǎn)的切線值,你可以暫且理解為是某一點(diǎn)的角度。不過(guò)我們這個(gè)效果用不到,只是個(gè)湊參數(shù)的。
PathMeasure有個(gè)很重要的方法就是getPosTan方法。
boolean getPosTan (float distance, float[] pos, float[] tan)
方法各個(gè)參數(shù)釋義:
| 參數(shù) | 作用 | 備注 |
|---|---|---|
| 返回值(boolean) | 判斷獲取是否成功 | true表示成功,數(shù)據(jù)會(huì)存入 pos 和 tan 中, false 表示失敗,pos 和 tan 不會(huì)改變 |
| distance | 距離 Path 起點(diǎn)的長(zhǎng)度 | 取值范圍: 0 <= distance <= getLength |
| pos | 該點(diǎn)的坐標(biāo)值 | 當(dāng)前點(diǎn)在畫(huà)布上的位置,有兩個(gè)數(shù)值,分別為x,y坐標(biāo)。 |
| tan | 該點(diǎn)的正切值 | 當(dāng)前點(diǎn)在曲線上的方向,使用 Math.atan2(tan[1], tan[0]) 獲取到正切角的弧度值。 |
相信小伙伴還是能看明白的,我這里就不一一解釋了。
所以到了這里,我們已經(jīng)能夠獲取圓上某一點(diǎn)的位置了。還記得我們之前是怎么設(shè)置初始位置的嗎?就是Y值固定,X值隨機(jī),現(xiàn)在我們已經(jīng)能夠得到一個(gè)標(biāo)準(zhǔn)的圓的位置了。但是,很重要啊,但是如果我們按照?qǐng)A的標(biāo)準(zhǔn)位置去一個(gè)個(gè)放粒子的話,豈不就是一個(gè)圓了?而我們的效果圖,位置可看起來(lái)不怎么規(guī)律。
所以我們?cè)诘玫揭粋€(gè)標(biāo)準(zhǔn)的位置之后,需要對(duì)它進(jìn)行一個(gè)隨機(jī)的偏移,偏移的也不能太大,否則成不了一個(gè)圓形。
圓形添加粒子
所以我們要修改添加粒子的代碼了。
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
centerX = (w / 2).toFloat()
centerY = (h / 2).toFloat()
path.addCircle(centerX, centerY, 280f, Path.Direction.CCW)
pathMeasure.setPath(path, false) //添加path
var nextX = 0f
var speed=0
var nextY=0f
for (i in 0..500) {
//按比例測(cè)量路徑上每一點(diǎn)的值
pathMeasure.getPosTan(i / 500f * pathMeasure.length, pos, tan)
nextX = pos[0]+random.nextInt(6) - 3f //X值隨機(jī)偏移
nextY= pos[1]+random.nextInt(6) - 3f//Y值隨機(jī)偏移
speed= random.nextInt(10)+5
particleList.add(
Particle(nextX, nextY, 2f, speed.toFloat(), 100)
)
}
animator.start()
}
現(xiàn)在運(yùn)行起來(lái)就是這樣子了

咦,效果和我想象的不一樣啊。最初好像是個(gè)圓,可是不是應(yīng)該像漣漪一樣擴(kuò)散嗎,可你這還是往下落呀。
還記得我們之前定義的動(dòng)畫(huà)的效果嗎,就是X值不變,Y值不斷擴(kuò)大,那可不就是一直往下落嗎?所以這里我們需要修改動(dòng)畫(huà)規(guī)則。
修改動(dòng)畫(huà)
問(wèn)題是怎么修改動(dòng)畫(huà)呢?
思考一下,效果圖中的動(dòng)畫(huà)應(yīng)該是往外擴(kuò)散,擴(kuò)散是什么意思?就是沿著它到圓心的方向反向運(yùn)動(dòng),對(duì)不對(duì)?
上一張圖來(lái)理解一下

此時(shí)內(nèi)心圓是我們現(xiàn)在粒子所處的圓,假設(shè)有一個(gè)粒子此時(shí)在B點(diǎn),那么如果要擴(kuò)散的話,它應(yīng)該到H點(diǎn)位置。
這個(gè)H點(diǎn)的位置應(yīng)該如何獲取呢?
如果以A點(diǎn)為原點(diǎn)的話,此時(shí)B點(diǎn)的位置我們是知道的,它分別是X和Y。X=AG,Y=BG。我們也應(yīng)該能發(fā)現(xiàn),由AB延申至AH的過(guò)程中,∠Z是始終不變的。
同時(shí)我們應(yīng)該能發(fā)現(xiàn),擴(kuò)散這個(gè)過(guò)程實(shí)際上是圓變大了,所以B變挪到了H點(diǎn)上。而這個(gè)擴(kuò)大的值的意思就是圓的半徑變大了,即半徑R = AB,現(xiàn)在半徑R=AH。
AB的值我們是知道的,就是我們一開(kāi)始畫(huà)的圓的半徑嘛??墒茿H是多少呢?
不妨令移動(dòng)距離offset=AH-AB,那么這個(gè)運(yùn)動(dòng)距離offset是多少呢?我們想一下,在之前的下落中,距離是不是等于速度乘以時(shí)間呢?而我們這里沒(méi)有時(shí)間這個(gè)變量,有的只是一次次循環(huán),循環(huán)中粒子的Y值不斷加速度。所以我們需要一個(gè)變量offset值來(lái)記錄移動(dòng)的距離,
所以這個(gè)offset += speed
那我們現(xiàn)在offset知道了,也就是說(shuō)AH-AB的值知道了,AB我們也知道,我們就能求出AH的值
AH=AB +offset
AH知道了,∠Z也知道了,利用三角函數(shù)我們可以得到H點(diǎn)的坐標(biāo)了。設(shè)初始半徑為R=AB
A點(diǎn)為原點(diǎn),

所以AD:

HD:

按理說(shuō)沒(méi)問(wèn)題了,這個(gè)時(shí)候H的值我們已經(jīng)得到了。但是,注意此時(shí)我們是以A點(diǎn)為原點(diǎn)得出來(lái)的值,而我們的手機(jī)屏幕中是以左上角為原點(diǎn)的。A點(diǎn)的值我們此時(shí)在程序中寫(xiě)死了是centerX和centerY,所以上面的公式還得改一下

注意哦,此時(shí)只是AD和HD的值,只是這兩個(gè)線段的長(zhǎng)度而不是真正H點(diǎn)的坐標(biāo)。H點(diǎn)的坐標(biāo)應(yīng)該在A點(diǎn)的基礎(chǔ)上增加,即

而且這只是在右上半?yún)^(qū)也就是第一象限是這樣計(jì)算的,左半?yún)^(qū)和右下半?yún)^(qū)的計(jì)算規(guī)則也不一樣。

兄臺(tái),不要急,先聽(tīng)我說(shuō),一會(huì)還不懂的話我親自幫你踢板凳。
小伙伴紛紛表示,上邊的公式也太復(fù)雜了,每個(gè)象限都計(jì)算一遍,自定義View就這么復(fù)雜嗎?
哈哈哈哈,其實(shí)不是。我晃點(diǎn)你的。
規(guī)則也確實(shí)是上面所說(shuō)的,但是我們是什么人?程序員啊,最不應(yīng)該怕的就是計(jì)算了。反正CPU算不是我算。
我們?cè)诜治鲆槐?,這次一定很簡(jiǎn)單,你不要跑!
首先有個(gè)角度Z,我們需要記下每個(gè)粒子的角度,可是這個(gè)角度的計(jì)算就有的說(shuō)道了。
我們先以左右兩個(gè)半?yún)^(qū)計(jì)算,在右半?yún)^(qū)的時(shí)候角度

假設(shè)這個(gè)時(shí)候∠Z是30°,那么也就是說(shuō)

那么H的X值也就是

可是如果在左半?yún)^(qū)的話角度Z

那么此時(shí)H的X值應(yīng)該是這么算

其實(shí)本質(zhì)上

我們根本不需要考慮左右的問(wèn)題,因?yàn)槿绻谟疫卌os∠Z是正,在左邊為負(fù)數(shù),所以直接加就可以。
而我們需要考慮的是上下問(wèn)題,也就是Y的問(wèn)題。畢竟這個(gè)正負(fù)是基于X的值算出來(lái)的。當(dāng)我們轉(zhuǎn)換成角度以后需要根據(jù)此時(shí)H的Y值是否大于centerY來(lái)分別計(jì)算。
當(dāng)H的Y值在centerY之上,也就是H.Y<centerY

反之

這樣的話隨著offset的增長(zhǎng),H點(diǎn)的坐標(biāo)也能夠隨時(shí)的計(jì)算出來(lái)了。
話說(shuō)再多也沒(méi)用,還是代碼更為直觀。
根據(jù)上面的描述,我們需要給粒子添加兩個(gè)屬性,一個(gè)是移動(dòng)距離,一個(gè)是粒子的角度
class Particle(
...
var offset:Int,//當(dāng)前移動(dòng)距離
var angle:Double,//粒子角度
...
)在添加粒子的地方修改:
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
...
var angle=0.0
for (i in 0..500) {
...
//反余弦函數(shù)可以得到角度值,是弧度
angle=acos(((pos[0] - centerX) / 280f).toDouble())
speed= random.nextInt(10)+5
particleList.add(
Particle(nextX, nextY, 2f, speed.toFloat(), 100,0,angle)
)
}
animator.start()
}在更新粒子動(dòng)畫(huà)的地方修改:
private fun updateParticle(value: Float) {
particleList.forEach {particle->
if(particle.offset >particle.maxOffset){
particle.offset=0
particle.speed= (random.nextInt(10)+5).toFloat()
}
particle.alpha= ((1f - particle.offset / particle.maxOffset) * 225f).toInt()
particle.x = (centerX+ cos(particle.angle) * (280f + particle.offset)).toFloat()
if (particle.y > centerY) {
particle.y = (sin(particle.angle) * (280f + particle.offset) + centerY).toFloat()
} else {
particle.y = (centerY - sin(particle.angle) * (280f + particle.offset)).toFloat()
}
particle.offset += particle.speed.toInt()
}
}
添加粒子的地方angle是角度值,用了Kotlin的acos反余弦函數(shù),這個(gè)函數(shù)返回的是0-PI的弧度制,0-PI的取值范圍也就意味著sin∠Z始終是正值,上面公式中的絕對(duì)值就不需要了。
更新的代碼很簡(jiǎn)單,對(duì)照公式一看便知。此時(shí)我們運(yùn)行一下,效果就已經(jīng)很好了,很接近了。

現(xiàn)在感覺(jué)是不是好多了?就是速度有點(diǎn)快,粒子有點(diǎn)少~沒(méi)關(guān)系,我們做一些優(yōu)化工作,比如說(shuō),粒子的初始移動(dòng)距離也隨機(jī)取一個(gè)值,粒子的最大距離也隨機(jī)。這樣下來(lái),我們的效果就十分的好了
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
centerX = (w / 2).toFloat()
centerY = (h / 2).toFloat()
path.addCircle(centerX, centerY, 280f, Path.Direction.CCW)
pathMeasure.setPath(path, false)
var nextX = 0f
var speed=0f
var nextY=0f
var angle=0.0
var offSet=0
var maxOffset=0
for (i in 0..2000) {
pathMeasure.getPosTan(i / 2000f * pathMeasure.length, pos, tan)
nextX = pos[0]+random.nextInt(6) - 3f
nextY= pos[1]+random.nextInt(6) - 3f
angle=acos(((pos[0] - centerX) / 280f).toDouble())
speed= random.nextInt(2) + 2f
offSet = random.nextInt(200)
maxOffset = random.nextInt(200)
particleList.add(
Particle(nextX, nextY, 2f, speed, 100,offSet.toFloat(),angle, maxOffset.toFloat())
)
}
animator.start()
}
其實(shí)還有很多可以優(yōu)化的地方,比如說(shuō),粒子的數(shù)量抽取為一個(gè)常量,中間圓的半徑也可以定為一個(gè)屬性值去手動(dòng)設(shè)置等等。。不過(guò)這些都是小意思,相信小伙伴們一定可以自己搞定的。我就不班門(mén)弄斧了。
最后這是優(yōu)化過(guò)后的效果,接下來(lái)的與圖片結(jié)合,就希望小伙伴們自己實(shí)現(xiàn)一下啦~很簡(jiǎn)單的??梢栽谠u(píng)論區(qū)交作業(yè)哦~

這是我的成品地址,做了一些額外的優(yōu)化,比如說(shuō)x和y的隨機(jī)偏移等。小伙伴們可以手動(dòng)去實(shí)現(xiàn)一下或者看我代碼,當(dāng)然我代碼只是為了實(shí)現(xiàn)效果,并沒(méi)有做更多的優(yōu)化,不要噴我哦:
DimpleView
https://github.com/MlxChange/ParticleView
推薦閱讀
? 耗時(shí)2年,Android進(jìn)階三部曲第三部《Android進(jìn)階指北》出版!
推薦我的技術(shù)博客
推薦一下我的獨(dú)立博客: liuwangshu.cn ,內(nèi)含Android最強(qiáng)原創(chuàng)知識(shí)體系,一直在更新,歡迎體驗(yàn)和收藏!
歡迎加入BATcoder技術(shù)交流群
你好,我是劉望舒,被百度百科收錄的騰訊云TVP專家,著有三本技術(shù)暢銷(xiāo)書(shū),蟬聯(lián)四屆電子工業(yè)出版社年度優(yōu)秀作者,谷歌開(kāi)發(fā)者社區(qū)特邀講師。
前華為面試官,現(xiàn)大廠技術(shù)負(fù)責(zé)人。
歡迎添加我的微信 henglimogan ,備注:BATcoder,加入BATcoder交流群。
明天見(jiàn)(??ω??)
