使用ListAdapter的submitList方法后,RecyclerView展开项Chevron旋转动画异常
我完全懂你遇到的这个头疼问题——首次点击时Chevron的旋转动画流畅得很,但调用submitList()更新列表后,图标直接跳转到目标角度,完全没有过渡动画。这本质上是因为submitList()触发RecyclerView重新绑定视图时,我们没处理好动画的起始状态,或者动画触发时机和RecyclerView的视图复用机制冲突了。
问题根源拆解
当你调用submitList()后,RecyclerView会通过DiffUtil对比新旧列表,触发onBindViewHolder。如果这时候直接根据UIModel.isExpanded调用view.animate().rotation(target),会出现两种问题:
- ViewHolder被复用时,之前的动画状态没被保留,视图的旋转角度可能被重置为初始值,动画系统误以为不需要过渡;
- 状态驱动UI的逻辑下,
onBindViewHolder可能被重复调用,动画被多次触发或者直接覆盖,导致跳变。
具体解决方案
方案1:让ViewHolder自己维护展开状态,基于实际旋转角度触发动画
不要直接依赖UIModel.isExpanded硬设目标角度,让ViewHolder跟踪自身的展开状态,或者直接获取视图当前的旋转值作为动画起始点,这样动画的过渡会更自然。
class ExpandableViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { private val chevronIcon = itemView.findViewById<ImageView>(R.id.iv_chevron) private var currentExpandedState = false fun bind(model: UIModel, onToggle: () -> Unit) { // 先同步展开内容的显示状态 itemView.tv_expandable_content.isVisible = model.isExpanded // 仅当状态真的变化时,才触发动画 if (model.isExpanded != currentExpandedState) { currentExpandedState = model.isExpanded val targetRotation = if (currentExpandedState) 180f else 0f chevronIcon.animate() .rotation(targetRotation) .setDuration(300) .setInterpolator(AccelerateDecelerateInterpolator()) .start() } // 设置点击回调 chevronIcon.setOnClickListener { onToggle() } } }
这种方式让ViewHolder自己管理状态,避免了onBindViewHolder重复触发动画,同时动画起始点是视图的实际旋转角度,不会出现跳变。
方案2:用DiffUtil Payload实现局部更新
通过DiffUtil的getChangePayload方法,只传递展开状态变化的信息,在onBindViewHolder中仅处理动画部分,而非全量绑定视图,减少不必要的操作冲突。
首先修改DiffUtil.ItemCallback:
class UIModelDiffCallback : DiffUtil.ItemCallback<UIModel>() { override fun areItemsTheSame(oldItem: UIModel, newItem: UIModel): Boolean { return oldItem.id == newItem.id // 用你的唯一标识判断 } override fun areContentsTheSame(oldItem: UIModel, newItem: UIModel): Boolean { return oldItem.content == newItem.content && oldItem.isExpanded == newItem.isExpanded } override fun getChangePayload(oldItem: UIModel, newItem: UIModel): Any? { // 仅当展开状态变化时,返回payload if (oldItem.isExpanded != newItem.isExpanded) { return mapOf("EXPANDED_STATE" to newItem.isExpanded) } return super.getChangePayload(oldItem, newItem) } }
然后在Adapter中处理payload:
override fun onBindViewHolder(holder: ExpandableViewHolder, position: Int, payloads: MutableList<Any>) { if (payloads.isNotEmpty()) { // 处理局部更新,只执行动画 val payload = payloads[0] as Map<String, Boolean> val isExpanded = payload["EXPANDED_STATE"] ?: false holder.triggerChevronAnimation(isExpanded) return } // 全量绑定逻辑 super.onBindViewHolder(holder, position, payloads) }
ViewHolder中添加动画触发方法:
fun triggerChevronAnimation(isExpanded: Boolean) { currentExpandedState = isExpanded itemView.tv_expandable_content.isVisible = isExpanded val targetRotation = if (isExpanded) 180f else 0f chevronIcon.animate() .rotation(targetRotation) .setDuration(300) .start() }
这种方式精准定位状态变化,避免全量绑定带来的动画冲突。
方案3:在Fragment中主动触发对应ViewHolder的动画
利用MVI架构中Fragment接收状态更新的特性,在状态变化后找到对应的ViewHolder,直接调用动画方法,把动画触发从绑定逻辑中剥离出来。
比如在Fragment的状态收集逻辑中:
viewModel.uiState.collect { state -> adapter.submitList(state.expandableItems) // 定位状态变化的item位置 val changedItem = state.expandableItems.find { it.id == lastToggledItemId } changedItem?.let { val position = state.expandableItems.indexOf(it) val holder = recyclerView.findViewHolderForAdapterPosition(position) as? ExpandableViewHolder holder?.triggerChevronAnimation(it.isExpanded) } }
注意:这种方式需要处理ViewHolder不存在的情况(比如item已滑出屏幕),可以配合recyclerView.scrollToPosition(position)确保item可见后再触发动画。
总结
最推荐方案1或方案2,它们既适配RecyclerView的视图复用机制,又符合MVI状态驱动的设计思想。方案3适合简单场景,但需要额外处理视图可见性问题。
内容的提问来源于stack exchange,提问作者Daniel Beleza

