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

背景

假設有這樣的需求,我們需要在 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 中定義一個定時器虏缸,通過點擊按鈕來控制定時器的開關,當定時器開啟時嫩实,每秒切換一張圖片刽辙,模擬充電的效果,然后看一下運行效果

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

如圖所示缘挑,假設屏幕的大小剛好夠顯示 5 個 viewholder,那么當設置 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ù)上面測試的結果池充,我們可以推斷桩引,當 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 都設置一個定時器锄开,用于記錄其對應 item 的動畫播放狀態(tài)素标,可以看一下運行效果

這種方法非常簡單粗暴,原理也很簡單萍悴,直接通過改變 item 中圖片資源的值头遭,然后刷新
item,而不用關心 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)
        // 是否播放動畫的開關
        var playing: Boolean = false
        init {
            // 創(chuàng)建 viewholder 的時候立即開啟定時器
            val d = Observable.interval(0, 1, TimeUnit.SECONDS)
                    .subscribeOn(Schedulers.computation())
                    .filter { playing } // 根據(jù)開關狀態(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)建的時候直接開啟定時器澜术,但是定時器有個開關艺蝴,在滑動過程中,把每個 item 的播放狀態(tài)實時的同步到 viewholder 中來控制開關鸟废,從而控制定時器是否要播放動畫猜敢。

分析一下滑動的過程,首先 item0 在 viewholer0 中顯示,點擊 item0 中的按鈕時缩擂,打開了它的播放開關鼠冕,這時候 viewholder0 開始播放動畫

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

然后回到 item0叉趣,再次把 item0 的播放狀態(tài)同步到 viewholder0 中泞边,動畫仍然在播放,然后關閉 item0 的動畫疗杉,同時把播放狀態(tài)同步到 viewholder0 中阵谚,所以動畫停止播放

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

同步播放進度

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

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

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

    private val mContext = context

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

    // 用于記錄是否更新 ui 的開關
    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"
        }
        // 關閉上一次位置的開關
        if (holder?.lastPosition != -1) {
            flags[holder?.lastPosition!!] = false
        }
        // 開啟當前位置的開關
        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 滑出屏幕時蕊蝗,關閉它的更新 ui 的開關(這里只是不更新ui仅乓,定時器仍然在發(fā)送數(shù)據(jù)),只開啟當前顯示在 viewholder 中的 item 的開關

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

當 item10 顯示在 viewholder0 的時候痛单,item0 中的開關被關閉,此時 item10 中的定時器還沒有開啟劲腿,把 item10 的進度同步到 viewholder0 中旭绒,所以 viewholder0 顯示的是進度為 0%

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

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

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末嚣艇,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子华弓,更是在濱河造成了極大的恐慌食零,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,126評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件该抒,死亡現(xiàn)場離奇詭異慌洪,居然都是意外死亡顶燕,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,254評論 2 382
  • 文/潘曉璐 我一進店門冈爹,熙熙樓的掌柜王于貴愁眉苦臉地迎上來涌攻,“玉大人,你說我怎么就攤上這事频伤】一眩” “怎么了?”我有些...
    開封第一講書人閱讀 152,445評論 0 341
  • 文/不壞的土叔 我叫張陵憋肖,是天一觀的道長因痛。 經(jīng)常有香客問我,道長岸更,這世上最難降的妖魔是什么鸵膏? 我笑而不...
    開封第一講書人閱讀 55,185評論 1 278
  • 正文 為了忘掉前任,我火速辦了婚禮怎炊,結果婚禮上谭企,老公的妹妹穿的比我還像新娘。我一直安慰自己评肆,他們只是感情好债查,可當我...
    茶點故事閱讀 64,178評論 5 371
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著瓜挽,像睡著了一般盹廷。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上久橙,一...
    開封第一講書人閱讀 48,970評論 1 284
  • 那天俄占,我揣著相機與錄音,去河邊找鬼剥汤。 笑死颠放,一個胖子當著我的面吹牛,可吹牛的內容都是我干的吭敢。 我是一名探鬼主播碰凶,決...
    沈念sama閱讀 38,276評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼鹿驼!你這毒婦竟也來了欲低?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 36,927評論 0 259
  • 序言:老撾萬榮一對情侶失蹤畜晰,失蹤者是張志新(化名)和其女友劉穎砾莱,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體凄鼻,經(jīng)...
    沈念sama閱讀 43,400評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡腊瑟,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 35,883評論 2 323
  • 正文 我和宋清朗相戀三年聚假,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片闰非。...
    茶點故事閱讀 37,997評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡膘格,死狀恐怖,靈堂內的尸體忽然破棺而出财松,到底是詐尸還是另有隱情瘪贱,我是刑警寧澤,帶...
    沈念sama閱讀 33,646評論 4 322
  • 正文 年R本政府宣布辆毡,位于F島的核電站菜秦,受9級特大地震影響,放射性物質發(fā)生泄漏舶掖。R本人自食惡果不足惜球昨,卻給世界環(huán)境...
    茶點故事閱讀 39,213評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望眨攘。 院中可真熱鬧褪尝,春花似錦、人聲如沸期犬。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,204評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽龟虎。三九已至,卻和暖如春沙庐,著一層夾襖步出監(jiān)牢的瞬間鲤妥,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,423評論 1 260
  • 我被黑心中介騙來泰國打工拱雏, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留棉安,地道東北人嚼吞。 一個月前我還...
    沈念sama閱讀 45,423評論 2 352
  • 正文 我出身青樓绒净,卻偏偏與公主長得像,于是被迫代替她去往敵國和親指黎。 傳聞我的和親對象是個殘疾皇子鹊汛,可洞房花燭夜當晚...
    茶點故事閱讀 42,722評論 2 345