這個系列是老外寫的,幹貨!翻譯出來一起學習。如有不妥,不吝賜教!
- Android自定義視圖一:擴充現有的視圖,添加新的XML屬性
- Android自定義視圖二:如何繪制内容
- Android自定義視圖三:給自定義視圖添加“流暢”的動畫
- Android自定義視圖四:定制onMeasure強制顯示為方形
在第二部分我們實作了一個簡單的折線圖。這裡假設你已經讀了前篇。下面我們将繼續為這個折線圖添磚加瓦。
我在想給這個圖的上方添加三個按鈕,這樣使用者可以點選不同的按鈕來檢視不同類别的資料。比如,使用者可以檢視走路的、跑步的和騎車的。使用者點不同的按鈕,我們就跟還不同的運動資料顯示在圖形裡。
我們實作了按鈕點選後,設定不同的坐标點資料,然後運作APP。你會發現,雖然方法
setChartData()
已經被調用了,但是圖形一點變化都沒有。為什麼呢?因為我們沒有通知折線圖“重繪”。這可以通過調用
invalidate()
方法實作。但是,這樣的不同類别資料切換顯得非常突兀,如果有一個過渡的動畫就會好很多。
如果我們要給折線圖添加不同類别資料的過渡動畫,有兩個問題需要解決:
1. 我們需要折線圖的值從舊到新一步一步的修改。
2. 我們需要在上一步的值修改的時候,每一步的修改完成以後更新一次視圖。
我們先來着手解決第一個問題。有很多的方法可以改變點值。最簡單的一個就是簡單的線性插值器,然後輔以一些進階的插值器。我們這裡要做的雖然會略有不同。
如何動起來
我們把上面說到的邏輯都放在一個叫做
Dynamics
的類裡。一個
Dynamics
對象包含一個點的位置,以及這個點的速度,還有這個點的目标位置。使用這個對象的
update()
方法可以更新目前點的位置和速度。
update()
方法看起來是這樣的:
fun update(now: Long) {
val dt = Math.min(now - lastTime, 50)
velocity += (targetPosition - position) * springiness
velocity *= 1 - damping
position += velocity * dt / 1000
lastTime = now
}
我們在這個方法裡首先要做的就是計算時間步長,基本上從上次更新之後到現在的時間。并且保證最長的時間不長為50毫秒。這麼做是因為避免動畫過程中發生什麼異常而過渡延遲了動畫的更新時間。
然後我們根據目前點到目标點的距離來更新速度。同時,這個動畫要實作一種彈簧的效果,是以在更新速度的時候會考慮彈簧的“彈力常量”。速度會根據一個“阻尼系數(大于0,小于1)”常量不斷減小最後變為0。
然後我們使用速度來更新點的位置,并記錄目前更新的時間以便于計算下一個時間步長。
這樣,點的運動軌迹就像是綁在彈簧上一樣。這個點會急速奔向目标位置,并在該位置附近震蕩。如果我們增大阻尼系數,點的加速度會變小,如果阻尼系數足夠大的話,點将不會在目标位置震蕩。
如此的動畫和插值器的使用略有不同。插值器在使用的時候需要設定一個持續時間(duration)。插值操作在指定的時間内執行。但是,我們隻關心動畫執行的最後結束時間,或者在什麼條件下算是結束了。是以,我們添加下面的方法:
fun isAtRest(): Boolean {
val standingStill = Math.abs(velocity) < TOLERANCE
val isAtTarget = targetPosition - position < TOLERANCE
return standingStill && isAtTarget
}
如果點已經在目标位置,而且速度為0的時候傳回true。和浮點數比較相等并不是什麼好主意,是以我們檢測速度值是否足夠接近0.是以
TOLERANCE
的值是0.01,這在在我們的例子中是一個合理的閥值了。
使用Dynamics
更新之前的
LineChartView
的代碼,把
Dynamics
的代碼使用進去非常的容易。不過,我還是打算另外在建立一個折線圖的試圖,雖然這個折線圖的代碼和前一部分的代碼是完全一樣的。這樣主要是友善讀者檢視不同章節的代碼。這個心的自定義試圖就叫做
AnimLineChartView
了。是以,這次動畫的功能各位就主要關注
AnimLineChartView
這個類了。
在前一部分,我們最後繪制的代碼是這樣的:
var maxValue = getMax(this.points)
var path = Path()
path.moveTo(getXPos(), getYPos(this.points[], maxValue))
for (i: Int in .(points.count() - )) {
path.lineTo(getXPos(i), getYPos(points[i], maxValue))
}
使用了
Dynamics
之後是這樣的:
var maxValue = getMax(this.points)
var path = Path()
path.moveTo(getXPos(), getYPos(this.points[], maxValue))
for (i: Int in .(points.count() - )) {
path.lineTo(getXPos(i), getYPos(points[i].position, maxValue))
}
之是以會這樣,主要是點不再是用
float
數組表示,而是用
Dynamics
類型的數組表示:
private var _dynamicPoints: ArrayList<Dynamics>? = null
// private var _points: List<Dynamics>? = null
// var points: List<Dynamics>
// get() = if (_points == null) listOf<Dynamics>() else _points!!
// set(value) {
// _points = value
// }
_dynamicPoints: ArrayList<Dynamics>?
代替了
var points: List<Dynamics>
。之前直接使用float類型的點值的地方都需要換成取
Dynamics
對象的
position
屬性值。
開始處理動畫
我們現在需要做的就是不斷調用
upate()
方法來更新
_dynamicPoints
并觸發視圖的重繪。我們使用
Runnable
來實作上述的功能。一個runnable示例就是一個可執行的指令,通常是用來在另一個線程執行一些任務。但是我們把它用在UI線程上來更新視圖。
我們要用的runnable是這樣的:
private var animator: Runnable = object : Runnable {
override fun run() {
var needNewFrame = false
var now = AnimationUtils.currentAnimationTimeMillis()
for (d in this@AnimLineChartView._dynamicPoints!!) {
d.update(now)
if (d.isAtRest()) {
needNewFrame = true
}
}
if (needNewFrame) {
postDelayed(this, )
}
invalidate()
}
}
在
Runnable
唯一的方法
run()
裡,我們周遊
_dynamicPoints
的全部的點(現在都是
Dynamics
類型的),并調用
update()
方法。如果存在一個“點”沒有停下來,我們就設定一個新的動畫(scheduleNewFrame)。設定一個新動畫就是通過這一句:
postDelayed(this, 20)
來實作的。也就是隻要需要設定新的動畫,那麼就隔一段時間之後調用
Runnable
本身。最後調用
invalidate()
方法來觸發重繪。
那麼,如果
animator
在下次繪制之前又執行了一次怎麼辦?畢竟是大于15ms之後才開始下次繪制,我們無法控制。很有意思的一點是:
Runnable
對象是包裝在一個消息裡,并添加在
MessageQueue
(消息隊列)裡的,我們這裡的消息隊列是在UI線程的
Looper
中的。
invalidate()
方法也是這樣。UI線程的
Looper
之後會分發各路消息,并確定重繪和runnable對象的執行時按順序執行的。實質上是,在UI線程裡,
Looper
是順序分發執行所有的
Message
的,是以各個
Message
對象都是按照post的時機不同順序執行的。
Dynamics
和
Runnable
的結合是處理動畫的非常好的選擇。很容易給之前木有動畫的自定義視圖添加動畫。我總是先把繪制和互動的代碼全部完成之後,添加
Dynamic
屬性,并用
Runnable
讓視圖實作動畫。
來看看
setChartData()
方法:
fun setChartData(newPoints: List<Float>) {
var now = AnimationUtils.currentAnimationTimeMillis()
if (this._dynamicPoints == null || this._dynamicPoints?.count() != newPoints.count()) {
this._dynamicPoints = null
this._dynamicPoints = ArrayList<Dynamics>()
for (i: Int in .(newPoints.count() - )) {
var dynamicPoint = Dynamics(f, f)
dynamicPoint.setPosition(newPoints[i], now)
dynamicPoint.setTargetPosition(newPoints[i], now)
this._dynamicPoints?.add(dynamicPoint)
}
invalidate()
} else {
for (i: Int in .(newPoints.count() - )) {
this._dynamicPoints?.get(i)?.setTargetPosition(newPoints[i], now)
removeCallbacks(animator)
post(animator)
}
}
}
有兩種情況需要我們處理:
1. 如果我們沒有之前就沒有資料,或者以前的資料已經過期(和現在的新資料的數量不同)。這個時候我們就建立一個新的
Dynamics
數組并初始化他們。我們把
position
值指定為點的y值,并把
velocity
指定為0(預設)。然後我們把
targetPosition
指定為相同的值。最後調用
invalidate()
方法觸發重繪。
2. 另外一種情況是,我們已經有了點資料。我們需要做的就是把
targetPosition
更換為新的值,然後開始動畫。我們調用
post(r: Runnable)
方法就可以開始動畫。但是動畫可能已經在運作中了,是以在post一個runnable做動畫之前先remove掉之前可能已經添加的runnable。這樣還容易調試一些。這個方法裡修改了的唯一的值就是
targetPosition
。目前position直到
update()
方法被調用的時候才會改變。
運作效果如下:
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsICMxQzM0EjMwEzNwUDM2EDMy8CX0Vmbu4GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.jpg)
如絲般順滑
還有一件事需要處理的,那就是這個圖顯得太過棱角分明。我們把繪制折線圖的
path.lineTo(x, y)
用
cublicTo()
方法替換了。這樣從一點到另一點會使用貝塞爾曲線繪制。當然,我們也還需要計算貝塞爾曲線需要的另外的兩個控制點的坐标。
控制點坐标的計算方式。主要計算的是目前點和下一點的控制點。那麼假設目前點為i點,i點的下一點就是(i+i)點,i點的前一點就是(i-1)點。這個很容易了解。計算的時候,i點的控制點為i點的X+(點(i+1)的X - 點(i-1)的X) * 順滑常量,y值類似。點(i+i)的控制點為:點(i+1)的X - (點(i+2)的X - 點(i)的X) * 順滑常量。點(i+1)的控制點的Y值同理可得。
下面再次回到動畫部分,假設你有一個應用,裡面有一個按鈕和一個圖檔。點了這個按鈕之後,圖檔就會模糊直到不見(fade out)。之後點選按鈕圖檔在由模糊到完全顯示(fade in)。這個完全可以使用alpha animation來實作。但是如果先點選按鈕來讓圖檔fade in,然後不等這個動畫執行完全就立馬點選按鈕fade out會發生什麼呢?這個圖檔會立馬alpha=1的顯示出來,然後再執行fade out 動畫。
然後看我們自定義折線圖的動畫,随意的切換不同的類别,各個資料的連線并不會突然就改變了,而是非常順滑的動畫到下一個類别的資料中。