RecyclerView 中添加定时器动画的一般套路

背景

假设有这样的需求,我们需要在 RecyclerView 的每个 item 中都通过定时器切换图片来持续播放一个动画,比如通过每秒切换一张电量不同的电池图片来实现类似充电时的动画效果,这个需求看起来好像很简单,但是如果在 RecyclerView 的每个 item 中都需要实现这样的动画,由于 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 中定义一个定时器,通过点击按钮来控制定时器的开关,当定时器开启时,每秒切换一张图片,模拟充电的效果,然后看一下运行效果

我们发现,点击第一个 item 的按钮之后,动画开始播放了,然后上滑,发现第 10 个 item 也在播放动画, 出现这种情况的原因是什么呢,下面我们分析一下。

如图所示,假设屏幕的大小刚好够显示 5 个 viewholder,那么当设置 adapter 的时候,系统会立即创建 5 个 viewholder 用于显示前 5 条数据,然后我们向上滑,这时候系统并不会再次创建 viewholder,而是把上面移除屏幕的 viewholder 重新拿到下面来使用,这就是 RecyclerView 的复用机制。

如图,上滑一个 item 的距离时,item5 移入屏幕,这时候并不是重新创建一个 viewholder,而是把之前显示 item0 数据的 viewholder0 直接拿到下面来显示 item5。事实上,总共创建的 viewholder 数量比屏幕显示的最大 item 数量要多一点,就是说,这里其实 item5 还是会新创建 viewholder 的,可能后面的 item6 或者 item7 甚至更大才会重用 viewholder0,这里为了方便画图,就这么解释了,大家理解意思就好了。

那么根据上面测试的结果,我们可以推断,当 item10 移入屏幕的时候,它是复用了本来用来显示 item0 的 viewholder0, 而 viewholder0 在之前的操作中打开了动画,所以item10 也会播放动画。

那么改如何解决这样的问题呢?

刷新 item 列表

最简单的方法就是在定义一个图片资源的数组,用于存放 item 的图片,在定时器中需要切换图片的时候,直接改变数组中的值,然后刷新对应位置的 item,adapter 的代码如下

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

    private val mContext = context

    // 用于显示对应 item 位置的图片
    @DrawableRes
    private val drawables = IntArray(100)

    // 定时器数组,每个 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 都能保持自己的动画播放状态,必须为每个 item 都设置一个定时器,用于记录其对应 item 的动画播放状态,可以看一下运行效果

这种方法非常简单粗暴,原理也很简单,直接通过改变 item 中图片资源的值,然后刷新
item,而不用关心 item 是存放在哪个 viewholder 中,但是每秒钟都要刷新 item,如果同时开起多个 item 的定时器,那么每秒钟都要刷新多个 item,这无疑会有巨大的性能消耗。

同步播放状态

有一种比较高效的方法是在滑动过程中,及时把当前位置 item 的动画播放状态同步到 viewholder 中,然后 viewholder 中根据播放状态来确定是否要播放动画,代码如下

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

    private val mContext = context

    // 布尔类型数组,用于记录每个 item 的播放状态
    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 的播放状态同步给 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 {
            // 创建 viewholder 的时候立即开启定时器
            val d = Observable.interval(0, 1, TimeUnit.SECONDS)
                    .subscribeOn(Schedulers.computation())
                    .filter { playing } // 根据开关状态确定是否播放动画
                    .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 创建的时候直接开启定时器,但是定时器有个开关,在滑动过程中,把每个 item 的播放状态实时的同步到 viewholder 中来控制开关,从而控制定时器是否要播放动画。

分析一下滑动的过程,首先 item0 在 viewholer0 中显示,点击 item0 中的按钮时,打开了它的播放开关,这时候 viewholder0 开始播放动画

然后滑动到下面的时候,item10 在 viewholder0 中显示,但是在滑动的过程中,把 item10 的播放开关同步到 viewholder0 中了,所以这时 viewholder0 没有播放动画然后再次点击 viewholder0 中的按钮时,开启了 item10 的动画播放开关,同时把播放状态同步到 viewholder0 中,viewholder0 继续播放动画

然后回到 item0,再次把 item0 的播放状态同步到 viewholder0 中,动画仍然在播放,然后关闭 item0 的动画,同时把播放状态同步到 viewholder0 中,所以动画停止播放

再次下滑,由于 item10 的动画还在播放中,并在滑动过程中把播放状态同步到 viewholder0 中了,所以 viewholer0 中又开始播放动画

同步播放进度

同步播放状态的方法虽然很高效,但是有一定的限制,就是我们在上下滑动的过程中只能同步每个 item 的播放状态,是播放中还是未播放,但是无法同步播放的进度。假设现在每个 item 中不是要切换图片,而是有一个 ProgressBar,类似于那种下载进度条,ProgressBar 是一直在动的,然后在滑动过程中还需要随时还原每个 item 中的进度,那应该如何实现呢?

首先肯定是每个 item 都需要一个定时器和控制开关,分别用于记录自己的进度和控制动画是否播放,然后在可以 viewholder 中保存上一次存放在该 viewholder 的 item 的位置,然后在滑动过程中,关闭上一次的位置的开关(因为这时候它已经滑出屏幕了),再开启当前位置的开关,代码如下

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

    private val mContext = context

    // 定时器数组,每个 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 的播放进度,如果不做任何处理,就会发现有多个定时器更新进度的效果,所以我们记录 viewholer 中上一次存放的 item,然后当 item 滑出屏幕时,关闭它的更新 ui 的开关(这里只是不更新ui,定时器仍然在发送数据),只开启当前显示在 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 的定时器已经走到了 15%,把它的进度同步到 viewholder0 中,所以 viewholder0 显示的进度是 15%,并且跟随 item0 的定时器继续走

接着关闭 item0 的定时器,然后下滑到 item10,当 item10 再次显示在 viewholder0 中时,它的开关被重新开启,此时 item10 的定时器走到了 10%,再把它的进度同步到 viewholder0中,所以 viewholder0 显示的进度是 10%,并且跟随 item10 的定时器继续走

代码已上传到 https://github.com/Zackratos/RvItemAm

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271