RecyclerView 中添加定時器動畫的一般套路

背景

假設(shè)有這樣的需求,我們需要在 RecyclerView 的每個 item 中都通過定時器切換圖片來持續(xù)播放一個動畫,比如通過每秒切換一張電量不同的電池圖片來實現(xiàn)類似充電時的動畫效果,這個需求看起來好像很簡單,但是如果在 RecyclerView 的每個 item 中都需要實現(xiàn)這樣的動畫,由于 RecyclerView 的復用機制,就會導致錯亂的問題。

RecyclerView 的重用機制

我們可以嘗試一下,按照正常的思路,RecyclerView 的 Adapter 代碼如下:

class Sample1Adapter(context: Context): SampleAdapter<Sample1Adapter.ViewHolder>() {

    private val mContext = context

    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
        return ViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item_sample_1, parent, false))
    }

    override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
        holder?.textView?.text = position.toString()
    }

    override fun getItemCount(): Int { return 100 }

    inner class ViewHolder(itemView: View?) : RecyclerView.ViewHolder(itemView) {
        val button = itemView?.findViewById<Button>(R.id.button)
        val imageView = itemView?.findViewById<ImageView>(R.id.image_view)
        val textView = itemView?.findViewById<TextView>(R.id.text_view)
        private var disposable: Disposable? = null
        init {
            button?.setOnClickListener {
                if (disposable == null) {
                    disposable = Observable.interval(0, 1, TimeUnit.SECONDS)
                            .subscribeOn(Schedulers.computation())
                            .map { when((it % 5).toInt()) {
                                1 -> R.drawable.ic_battery_charging_1
                                2 -> R.drawable.ic_battery_charging_2
                                3 -> R.drawable.ic_battery_charging_3
                                4 -> R.drawable.ic_battery_charging_4
                                else -> {
                                    R.drawable.ic_battery_charging_0
                                }
                            } }
                            .observeOn(AndroidSchedulers.mainThread())
                            .subscribe{imageView?.setImageResource(it)}
                    button.text = "stop"
                    addDisposable(disposable)
                } else {
                    removeDisposable(disposable)
                    disposable?.dispose()
                    disposable = null
                    imageView?.setImageResource(R.drawable.ic_battery_charging_0)
                    button.text = "start"
                }
            }
        }
    }
}

代碼的邏輯還是很簡單的,在每個 ViewHolder 中定義一個定時器,通過點擊按鈕來控制定時器的開關(guān),當定時器開啟時,每秒切換一張圖片,模擬充電的效果,然后看一下運行效果

我們發(fā)現(xiàn),點擊第一個 item 的按鈕之后,動畫開始播放了,然后上滑,發(fā)現(xiàn)第 10 個 item 也在播放動畫, 出現(xiàn)這種情況的原因是什么呢,下面我們分析一下。

如圖所示,假設(shè)屏幕的大小剛好夠顯示 5 個 viewholder,那么當設(shè)置 adapter 的時候,系統(tǒng)會立即創(chuàng)建 5 個 viewholder 用于顯示前 5 條數(shù)據(jù),然后我們向上滑,這時候系統(tǒng)并不會再次創(chuàng)建 viewholder,而是把上面移除屏幕的 viewholder 重新拿到下面來使用,這就是 RecyclerView 的復用機制。

如圖,上滑一個 item 的距離時,item5 移入屏幕,這時候并不是重新創(chuàng)建一個 viewholder,而是把之前顯示 item0 數(shù)據(jù)的 viewholder0 直接拿到下面來顯示 item5。事實上,總共創(chuàng)建的 viewholder 數(shù)量比屏幕顯示的最大 item 數(shù)量要多一點,就是說,這里其實 item5 還是會新創(chuàng)建 viewholder 的,可能后面的 item6 或者 item7 甚至更大才會重用 viewholder0,這里為了方便畫圖,就這么解釋了,大家理解意思就好了。

那么根據(jù)上面測試的結(jié)果,我們可以推斷,當 item10 移入屏幕的時候,它是復用了本來用來顯示 item0 的 viewholder0, 而 viewholder0 在之前的操作中打開了動畫,所以item10 也會播放動畫。

那么改如何解決這樣的問題呢?

刷新 item 列表

最簡單的方法就是在定義一個圖片資源的數(shù)組,用于存放 item 的圖片,在定時器中需要切換圖片的時候,直接改變數(shù)組中的值,然后刷新對應位置的 item,adapter 的代碼如下

class Sample2Adapter(context: Context): SampleAdapter<Sample2Adapter.ViewHolder>() {

    private val mContext = context

    // 用于顯示對應 item 位置的圖片
    @DrawableRes
    private val drawables = IntArray(100)

    // 定時器數(shù)組,每個 item 都需要一個定時器
    private val disposables = arrayOfNulls<Disposable>(100)

    init {
        for (i in drawables.indices) {
            drawables[i] = R.drawable.ic_battery_charging_0
        }
    }

    override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
        holder?.textView?.text = position.toString()
        holder?.imageView?.setImageResource(drawables[position])
        if (disposables[position] == null) holder?.button?.text = "start"
        else holder?.button?.text = "stop"
    }

    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
        return ViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item_sample_1, parent, false))
    }

    override fun getItemCount(): Int {
        return 100
    }

    inner class ViewHolder(itemView: View?): RecyclerView.ViewHolder(itemView) {
        val button = itemView?.findViewById<Button>(R.id.button)
        val imageView = itemView?.findViewById<ImageView>(R.id.image_view)
        val textView = itemView?.findViewById<TextView>(R.id.text_view)

        init {
            button?.setOnClickListener {
                val position = adapterPosition
                if (disposables[position] == null) {
                    // 定時器用于改變對應 item 位置的圖片,然后刷新該位置的 item
                    disposables[position] = Observable.interval(0, 1, TimeUnit.SECONDS)
                            .subscribeOn(Schedulers.computation())
                            .map { when((it % 5).toInt()) {
                                1 -> R.drawable.ic_battery_charging_1
                                2 -> R.drawable.ic_battery_charging_2
                                3 -> R.drawable.ic_battery_charging_3
                                4 -> R.drawable.ic_battery_charging_4
                                else -> {
                                    R.drawable.ic_battery_charging_0
                                }
                            } }
                            .doOnNext { drawables[position] = it }
                            .observeOn(AndroidSchedulers.mainThread())
                            .subscribe { notifyItemChanged(position) }
                    addDisposable(disposables[position])
                } else {
                    removeDisposable(disposables[position])
                    disposables[position]?.dispose()
                    disposables[position] = null
                    drawables[position] = R.drawable.ic_battery_charging_0
                    notifyItemChanged(position)
                }
            }
        }
    }
}

這里需要注意,為了保證上下滑動過程中,每個 item 都能保持自己的動畫播放狀態(tài),必須為每個 item 都設(shè)置一個定時器,用于記錄其對應 item 的動畫播放狀態(tài),可以看一下運行效果

這種方法非常簡單粗暴,原理也很簡單,直接通過改變 item 中圖片資源的值,然后刷新
item,而不用關(guān)心 item 是存放在哪個 viewholder 中,但是每秒鐘都要刷新 item,如果同時開起多個 item 的定時器,那么每秒鐘都要刷新多個 item,這無疑會有巨大的性能消耗。

同步播放狀態(tài)

有一種比較高效的方法是在滑動過程中,及時把當前位置 item 的動畫播放狀態(tài)同步到 viewholder 中,然后 viewholder 中根據(jù)播放狀態(tài)來確定是否要播放動畫,代碼如下

class Sample3Adapter(context: Context): SampleAdapter<Sample3Adapter.ViewHolder>() {

    private val mContext = context

    // 布爾類型數(shù)組,用于記錄每個 item 的播放狀態(tài)
    private val flags = BooleanArray(100)

    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
        return ViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item_sample_1, parent, false))
    }

    override fun getItemCount(): Int { return 100 }

    override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
        holder?.textView?.text = position.toString()
        // 把當前位置 item 的播放狀態(tài)同步給 viewholder
        holder?.playing = flags[position]
        holder?.setStatus()
    }
    
    inner class ViewHolder(itemView: View?) : RecyclerView.ViewHolder(itemView) {
        val button = itemView?.findViewById<Button>(R.id.button)
        val imageView = itemView?.findViewById<ImageView>(R.id.image_view)
        val textView = itemView?.findViewById<TextView>(R.id.text_view)
        // 是否播放動畫的開關(guān)
        var playing: Boolean = false
        init {
            // 創(chuàng)建 viewholder 的時候立即開啟定時器
            val d = Observable.interval(0, 1, TimeUnit.SECONDS)
                    .subscribeOn(Schedulers.computation())
                    .filter { playing } // 根據(jù)開關(guān)狀態(tài)確定是否播放動畫
                    .map {
                        when ((it % 5).toInt()) {
                            1 -> R.drawable.ic_battery_charging_1
                            2 -> R.drawable.ic_battery_charging_2
                            3 -> R.drawable.ic_battery_charging_3
                            4 -> R.drawable.ic_battery_charging_4
                            else -> R.drawable.ic_battery_charging_0
                        }
                    }
                    .observeOn(AndroidSchedulers.mainThread())
                    .subscribe { imageView?.setImageResource(it) }
            addDisposable(d)

            button?.setOnClickListener {
                val position = adapterPosition
                playing = !playing
                flags[position] = playing
                setStatus()
            }
        }

        fun setStatus() {
            when {
                playing -> button?.text = "stop"
                else -> {
                    button?.text = "start"
                    imageView?.setImageResource(R.drawable.ic_battery_charging_0)
                }
            }
        }
    }
}

這里在每個 viewholder 創(chuàng)建的時候直接開啟定時器,但是定時器有個開關(guān),在滑動過程中,把每個 item 的播放狀態(tài)實時的同步到 viewholder 中來控制開關(guān),從而控制定時器是否要播放動畫。

分析一下滑動的過程,首先 item0 在 viewholer0 中顯示,點擊 item0 中的按鈕時,打開了它的播放開關(guān),這時候 viewholder0 開始播放動畫

然后滑動到下面的時候,item10 在 viewholder0 中顯示,但是在滑動的過程中,把 item10 的播放開關(guān)同步到 viewholder0 中了,所以這時 viewholder0 沒有播放動畫然后再次點擊 viewholder0 中的按鈕時,開啟了 item10 的動畫播放開關(guān),同時把播放狀態(tài)同步到 viewholder0 中,viewholder0 繼續(xù)播放動畫

然后回到 item0,再次把 item0 的播放狀態(tài)同步到 viewholder0 中,動畫仍然在播放,然后關(guān)閉 item0 的動畫,同時把播放狀態(tài)同步到 viewholder0 中,所以動畫停止播放

再次下滑,由于 item10 的動畫還在播放中,并在滑動過程中把播放狀態(tài)同步到 viewholder0 中了,所以 viewholer0 中又開始播放動畫

同步播放進度

同步播放狀態(tài)的方法雖然很高效,但是有一定的限制,就是我們在上下滑動的過程中只能同步每個 item 的播放狀態(tài),是播放中還是未播放,但是無法同步播放的進度。假設(shè)現(xiàn)在每個 item 中不是要切換圖片,而是有一個 ProgressBar,類似于那種下載進度條,ProgressBar 是一直在動的,然后在滑動過程中還需要隨時還原每個 item 中的進度,那應該如何實現(xiàn)呢?

首先肯定是每個 item 都需要一個定時器和控制開關(guān),分別用于記錄自己的進度和控制動畫是否播放,然后在可以 viewholder 中保存上一次存放在該 viewholder 的 item 的位置,然后在滑動過程中,關(guān)閉上一次的位置的開關(guān)(因為這時候它已經(jīng)滑出屏幕了),再開啟當前位置的開關(guān),代碼如下

class Sample4Adapter(context: Context): SampleAdapter<Sample4Adapter.ViewHolder>() {

    private val mContext = context

    // 定時器數(shù)組,每個 item 都需要一個定時器
    private val disposables = arrayOfNulls<Disposable>(100)

    // 用于記錄是否更新 ui 的開關(guān)
    private val flags = BooleanArray(100)

    // 用于記錄 item 中 progressBar 的進度
    private val progresss = IntArray(100)

    override fun getItemCount(): Int { return 100 }

    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
        return ViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item_sample_2, parent, false))
    }

    override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
        holder?.textView?.text = position.toString()
        holder?.progressBar?.progress = progresss[position]
        when {
            disposables[position] == null -> holder?.button?.text = "start"
            else -> holder?.button?.text = "stop"
        }
        // 關(guān)閉上一次位置的開關(guān)
        if (holder?.lastPosition != -1) {
            flags[holder?.lastPosition!!] = false
        }
        // 開啟當前位置的開關(guān)
        flags[position] = true
        holder.lastPosition = position
    }

    inner class ViewHolder(itemView: View?) : RecyclerView.ViewHolder(itemView) {
        val button = itemView?.findViewById<Button>(R.id.button)
        val progressBar = itemView?.findViewById<ProgressBar>(R.id.progress_bar)
        val textView = itemView?.findViewById<TextView>(R.id.text_view)
        // 上一次存放在 viewholder 中的 item 的位置
        var lastPosition: Int = -1
        init {
            button?.setOnClickListener {
                val position = adapterPosition
                if (disposables[position] == null) {
                    disposables[position] = Observable.interval(0, 1, TimeUnit.SECONDS)
                            .subscribeOn(Schedulers.computation())
                            .filter { it <= 100 && flags[position] }
                            .map { it.toInt() }
                            .doOnNext { progresss[position] = it }
                            .observeOn(AndroidSchedulers.mainThread())
                            .subscribe { progressBar?.progress = it }
                    addDisposable(disposables[position])
                    button.text = "stop"
                } else {
                    disposables[position]?.dispose()
                    removeDisposable(disposables[position])
                    disposables[position] = null
                    progresss[position] = 0
                    progressBar?.progress = 0
                    button.text = "start"
                }
            }
        }
    }
}

這里的原理其實是在 viewholder 中同時開啟了多個定時器,分別用于記錄不同 item 的播放進度,如果不做任何處理,就會發(fā)現(xiàn)有多個定時器更新進度的效果,所以我們記錄 viewholer 中上一次存放的 item,然后當 item 滑出屏幕時,關(guān)閉它的更新 ui 的開關(guān)(這里只是不更新ui,定時器仍然在發(fā)送數(shù)據(jù)),只開啟當前顯示在 viewholder 中的 item 的開關(guān)

照例分析一下滑動的過程,首先 item0 在 viewholder0 中顯示,它的進度是 0%,然后點擊按鈕,viewholder0 中的進度條開始動,當它走到 5% 的時候,向下滑動

當 item10 顯示在 viewholder0 的時候,item0 中的開關(guān)被關(guān)閉,此時 item10 中的定時器還沒有開啟,把 item10 的進度同步到 viewholder0 中,所以 viewholder0 顯示的是進度為 0%

然后開啟 item10 的定時器,viewholder0 的進度開始動了,當它走到 5% 的時候,上滑回到 item0,當 item0 重新顯示在 viewholder0 中時,item 10 的開關(guān)被關(guān)閉,item0 的開關(guān)被重新開啟,而此時 item0 的定時器已經(jīng)走到了 15%,把它的進度同步到 viewholder0 中,所以 viewholder0 顯示的進度是 15%,并且跟隨 item0 的定時器繼續(xù)走

接著關(guān)閉 item0 的定時器,然后下滑到 item10,當 item10 再次顯示在 viewholder0 中時,它的開關(guān)被重新開啟,此時 item10 的定時器走到了 10%,再把它的進度同步到 viewholder0中,所以 viewholder0 顯示的進度是 10%,并且跟隨 item10 的定時器繼續(xù)走

代碼已上傳到 https://github.com/Zackratos/RvItemAm

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

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