Android自定義視圖三:給自定義視圖添加“流暢”的動(dòng)畫

這個(gè)系列是老外寫的,干貨!翻譯出來(lái)一起學(xué)習(xí)。如有不妥,不吝賜教!

  1. Android自定義視圖一:擴(kuò)展現(xiàn)有的視圖,添加新的XML屬性
  2. Android自定義視圖二:如何繪制內(nèi)容
  3. Android自定義視圖三:給自定義視圖添加“流暢”的動(dòng)畫
  4. Android自定義視圖四:定制onMeasure強(qiáng)制顯示為方形

第二部分我們實(shí)現(xiàn)了一個(gè)簡(jiǎn)單的折線圖。這里假設(shè)你已經(jīng)讀了前篇。下面我們將繼續(xù)為這個(gè)折線圖添磚加瓦。

我在想給這個(gè)圖的上方添加三個(gè)按鈕,這樣用戶可以點(diǎn)選不同的按鈕來(lái)查看不同類別的數(shù)據(jù)。比如,用戶可以查看走路的、跑步的和騎車的。用戶點(diǎn)不同的按鈕,我們就跟還不同的運(yùn)動(dòng)數(shù)據(jù)顯示在圖形里。

我們實(shí)現(xiàn)了按鈕點(diǎn)擊后,設(shè)置不同的坐標(biāo)點(diǎn)數(shù)據(jù),然后運(yùn)行APP。你會(huì)發(fā)現(xiàn),雖然方法setChartData()已經(jīng)被調(diào)用了,但是圖形一點(diǎn)變化都沒有。為什么呢?因?yàn)槲覀儧]有通知折線圖“重繪”。這可以通過調(diào)用invalidate()方法實(shí)現(xiàn)。但是,這樣的不同類別數(shù)據(jù)切換顯得非常突兀,如果有一個(gè)過渡的動(dòng)畫就會(huì)好很多。

如果我們要給折線圖添加不同類別數(shù)據(jù)的過渡動(dòng)畫,有兩個(gè)問題需要解決:

  1. 我們需要折線圖的值從舊到新一步一步的修改。
  2. 我們需要在上一步的值修改的時(shí)候,每一步的修改完成以后更新一次視圖。

我們先來(lái)著手解決第一個(gè)問題。有很多的方法可以改變點(diǎn)值。最簡(jiǎn)單的一個(gè)就是簡(jiǎn)單的線性插值器,然后輔以一些高級(jí)的插值器。我們這里要做的雖然會(huì)略有不同。

如何動(dòng)起來(lái)

我們把上面說(shuō)到的邏輯都放在一個(gè)叫做Dynamics的類里。一個(gè)Dynamics對(duì)象包含一個(gè)點(diǎn)的位置,以及這個(gè)點(diǎn)的速度,還有這個(gè)點(diǎn)的目標(biāo)位置。使用這個(gè)對(duì)象的update()方法可以更新當(dāng)前點(diǎn)的位置和速度。update()方法看起來(lái)是這樣的:

fun update(now: Long) {
    val dt = Math.min(now - lastTime, 50)
    velocity += (targetPosition - position) * springiness
    velocity *= 1 - damping
    position += velocity * dt / 1000
    lastTime = now
}

我們?cè)谶@個(gè)方法里首先要做的就是計(jì)算時(shí)間步長(zhǎng),基本上從上次更新之后到現(xiàn)在的時(shí)間。并且保證最長(zhǎng)的時(shí)間不長(zhǎng)為50毫秒。這么做是因?yàn)楸苊鈩?dòng)畫過程中發(fā)生什么異常而過渡延遲了動(dòng)畫的更新時(shí)間。

然后我們根據(jù)當(dāng)前點(diǎn)到目標(biāo)點(diǎn)的距離來(lái)更新速度。同時(shí),這個(gè)動(dòng)畫要實(shí)現(xiàn)一種彈簧的效果,所以在更新速度的時(shí)候會(huì)考慮彈簧的“彈力常量”。速度會(huì)根據(jù)一個(gè)“阻尼系數(shù)(大于0,小于1)”常量不斷減小最后變?yōu)?。

然后我們使用速度來(lái)更新點(diǎn)的位置,并記錄當(dāng)前更新的時(shí)間以便于計(jì)算下一個(gè)時(shí)間步長(zhǎng)。

這樣,點(diǎn)的運(yùn)動(dòng)軌跡就像是綁在彈簧上一樣。這個(gè)點(diǎn)會(huì)急速奔向目標(biāo)位置,并在該位置附近震蕩。如果我們?cè)龃?em>阻尼系數(shù),點(diǎn)的加速度會(huì)變小,如果阻尼系數(shù)足夠大的話,點(diǎn)將不會(huì)在目標(biāo)位置震蕩。

如此的動(dòng)畫和插值器的使用略有不同。插值器在使用的時(shí)候需要設(shè)置一個(gè)持續(xù)時(shí)間(duration)。插值操作在指定的時(shí)間內(nèi)執(zhí)行。但是,我們只關(guān)心動(dòng)畫執(zhí)行的最后結(jié)束時(shí)間,或者在什么條件下算是結(jié)束了。因此,我們添加下面的方法:

fun isAtRest(): Boolean {
    val standingStill = Math.abs(velocity) < TOLERANCE
    val isAtTarget = targetPosition - position < TOLERANCE
    return standingStill && isAtTarget
}

如果點(diǎn)已經(jīng)在目標(biāo)位置,而且速度為0的時(shí)候返回true。和浮點(diǎn)數(shù)比較相等并不是什么好主意,所以我們檢測(cè)速度值是否足夠接近0.所以TOLERANCE的值是0.01,這在在我們的例子中是一個(gè)合理的閥值了。

使用Dynamics

更新之前的LineChartView的代碼,把Dynamics的代碼使用進(jìn)去非常的容易。不過,我還是打算另外在創(chuàng)建一個(gè)折線圖的試圖,雖然這個(gè)折線圖的代碼和前一部分的代碼是完全一樣的。這樣主要是方便讀者查看不同章節(jié)的代碼。這個(gè)心的自定義試圖就叫做AnimLineChartView了。所以,這次動(dòng)畫的功能各位就主要關(guān)注AnimLineChartView這個(gè)類了。

在前一部分,我們最后繪制的代碼是這樣的:

var maxValue = getMax(this.points)
var path = Path()
path.moveTo(getXPos(0), getYPos(this.points[0], maxValue))
for (i: Int in 1..(points.count() - 1)) {
    path.lineTo(getXPos(i), getYPos(points[i], maxValue))
}

使用了Dynamics之后是這樣的:

var maxValue = getMax(this.points)
var path = Path()
path.moveTo(getXPos(0), getYPos(this.points[0], maxValue))
for (i: Int in 1..(points.count() - 1)) {
    path.lineTo(getXPos(i), getYPos(points[i].position, maxValue))
}

之所以會(huì)這樣,主要是點(diǎn)不再是用float數(shù)組表示,而是用Dynamics類型的數(shù)組表示:

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類型的點(diǎn)值的地方都需要換成取Dynamics對(duì)象的position屬性值。

開始處理動(dòng)畫

我們現(xiàn)在需要做的就是不斷調(diào)用upate()方法來(lái)更新_dynamicPoints并觸發(fā)視圖的重繪。我們使用Runnable來(lái)實(shí)現(xiàn)上述的功能。一個(gè)runnable示例就是一個(gè)可執(zhí)行的命令,通常是用來(lái)在另一個(gè)線程執(zhí)行一些任務(wù)。但是我們把它用在UI線程上來(lái)更新視圖。

我們要用的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, 20)
        }

        invalidate()
    }
}

Runnable唯一的方法run()里,我們遍歷_dynamicPoints的全部的點(diǎn)(現(xiàn)在都是Dynamics類型的),并調(diào)用update()方法。如果存在一個(gè)“點(diǎn)”沒有停下來(lái),我們就設(shè)置一個(gè)新的動(dòng)畫(scheduleNewFrame)。設(shè)置一個(gè)新動(dòng)畫就是通過這一句:postDelayed(this, 20)來(lái)實(shí)現(xiàn)的。也就是只要需要設(shè)定新的動(dòng)畫,那么就隔一段時(shí)間之后調(diào)用Runnable本身。最后調(diào)用invalidate()方法來(lái)觸發(fā)重繪。

那么,如果animator在下次繪制之前又執(zhí)行了一次怎么辦?畢竟是大于15ms之后才開始下次繪制,我們無(wú)法控制。很有意思的一點(diǎn)是:Runnable對(duì)象是包裝在一個(gè)消息里,并添加在MessageQueue(消息隊(duì)列)里的,我們這里的消息隊(duì)列是在UI線程的Looper中的。invalidate()方法也是這樣。UI線程的Looper之后會(huì)分發(fā)各路消息,并確保重繪和runnable對(duì)象的執(zhí)行時(shí)按順序執(zhí)行的。實(shí)質(zhì)上是,在UI線程里,Looper是順序分發(fā)執(zhí)行所有的Message的,所以各個(gè)Message對(duì)象都是按照post的時(shí)機(jī)不同順序執(zhí)行的。

DynamicsRunnable的結(jié)合是處理動(dòng)畫的非常好的選擇。很容易給之前木有動(dòng)畫的自定義視圖添加動(dòng)畫。我總是先把繪制和交互的代碼全部完成之后,添加Dynamic屬性,并用Runnable讓視圖實(shí)現(xiàn)動(dòng)畫。

來(lái)看看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 0..(newPoints.count() - 1)) {
            var dynamicPoint = Dynamics(70f, 0.30f)
            dynamicPoint.setPosition(newPoints[i], now)
            dynamicPoint.setTargetPosition(newPoints[i], now)
            this._dynamicPoints?.add(dynamicPoint)
        }

        invalidate()
    } else {
        for (i: Int in 0..(newPoints.count() - 1)) {
            this._dynamicPoints?.get(i)?.setTargetPosition(newPoints[i], now)
            removeCallbacks(animator)
            post(animator)
        }
    }
}

有兩種情況需要我們處理:

  1. 如果我們沒有之前就沒有數(shù)據(jù),或者以前的數(shù)據(jù)已經(jīng)過期(和現(xiàn)在的新數(shù)據(jù)的數(shù)量不同)。這個(gè)時(shí)候我們就創(chuàng)建一個(gè)新的Dynamics數(shù)組并初始化他們。我們把position值指定為點(diǎn)的y值,并把velocity指定為0(默認(rèn))。然后我們把targetPosition指定為相同的值。最后調(diào)用invalidate()方法觸發(fā)重繪。
  2. 另外一種情況是,我們已經(jīng)有了點(diǎn)數(shù)據(jù)。我們需要做的就是把targetPosition更換為新的值,然后開始動(dòng)畫。我們調(diào)用post(r: Runnable)方法就可以開始動(dòng)畫。但是動(dòng)畫可能已經(jīng)在運(yùn)行中了,所以在post一個(gè)runnable做動(dòng)畫之前先remove掉之前可能已經(jīng)添加的runnable。這樣還容易調(diào)試一些。這個(gè)方法里修改了的唯一的值就是targetPosition。當(dāng)前position直到update()方法被調(diào)用的時(shí)候才會(huì)改變。

運(yùn)行效果如下:


QQ圖片20160506124253.gif

如絲般順滑

還有一件事需要處理的,那就是這個(gè)圖顯得太過棱角分明。我們把繪制折線圖的path.lineTo(x, y)cublicTo()方法替換了。這樣從一點(diǎn)到另一點(diǎn)會(huì)使用貝塞爾曲線繪制。當(dāng)然,我們也還需要計(jì)算貝塞爾曲線需要的另外的兩個(gè)控制點(diǎn)的坐標(biāo)。

控制點(diǎn)坐標(biāo)的計(jì)算方式。主要計(jì)算的是當(dāng)前點(diǎn)和下一點(diǎn)的控制點(diǎn)。那么假設(shè)當(dāng)前點(diǎn)為i點(diǎn),i點(diǎn)的下一點(diǎn)就是(i+i)點(diǎn),i點(diǎn)的前一點(diǎn)就是(i-1)點(diǎn)。這個(gè)很容易理解。計(jì)算的時(shí)候,i點(diǎn)的控制點(diǎn)為i點(diǎn)的X+(點(diǎn)(i+1)的X - 點(diǎn)(i-1)的X) * 順滑常量,y值類似。點(diǎn)(i+i)的控制點(diǎn)為:點(diǎn)(i+1)的X - (點(diǎn)(i+2)的X - 點(diǎn)(i)的X) * 順滑常量。點(diǎn)(i+1)的控制點(diǎn)的Y值同理可得。

下面再次回到動(dòng)畫部分,假設(shè)你有一個(gè)應(yīng)用,里面有一個(gè)按鈕和一個(gè)圖片。點(diǎn)了這個(gè)按鈕之后,圖片就會(huì)模糊直到不見(fade out)。之后點(diǎn)擊按鈕圖片在由模糊到完全顯示(fade in)。這個(gè)完全可以使用alpha animation來(lái)實(shí)現(xiàn)。但是如果先點(diǎn)擊按鈕來(lái)讓圖片fade in,然后不等這個(gè)動(dòng)畫執(zhí)行完全就立馬點(diǎn)擊按鈕fade out會(huì)發(fā)生什么呢?這個(gè)圖片會(huì)立馬alpha=1的顯示出來(lái),然后再執(zhí)行fade out 動(dòng)畫。

然后看我們自定義折線圖的動(dòng)畫,隨意的切換不同的類別,各個(gè)數(shù)據(jù)的連線并不會(huì)突然就改變了,而是非常順滑的動(dòng)畫到下一個(gè)類別的數(shù)據(jù)中。

Stay tuned to my next episode!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容