如何为 RecyclerView 内的 CardView 扩展和收缩设置动画(在性能和视觉方面)?
How to animate a CardView expansion and retraction inside a RecyclerView well (in terms of performance and visual)?
我已经成功创建了自动布局更新。我有一个 CardView
列表显示在 RecyclerView
中。单击 CardView
后,表示 CardView
将展开并导致其他可能展开的 CardView
缩回。我通过以下方式做到了:
- 维护一个变量,该变量保存要在我的
RecyclerView
的适配器中展开的 CardView
的位置
- 在任何
CardView
中发生点击时更新所述变量
- 如果另一个
CardView
被展开,收回前一个CardView
并展开新点击的CardView
执行上述操作的代码如下所示:
// Inside RecyclerView.Adapter
private var expandedViewHolder = -1
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val cardView = LayoutInflater.from(parent.context)
.inflate(R.layout.recyclerview_cardview, parent, false) as CardView
val holder = ViewHolder(cardView)
cardView.setOnClickListener {
notifyItemChanged(expandedPosition)
expandedPosition = if (expandedPosition == holder.adapterPosition) -1 else holder.adapterPosition
notifyItemChanged(expandedPosition)
}
return holder
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.headline.text = dataset[position].name
holder.desc.text = dataset[position].desc
if (position == expandedPosition) {
holder.desc.visibility = View.VISIBLE
}
else {
holder.desc.visibility = View.GONE
}
}
可以看出,我使用 notifyItemChanged()
强制 RecyclerView
重新触发 onBindViewHolder
。在 onBindViewHolder
中,CardView
的描述的可见性将相应改变。 onBindViewHolder
使用自动 transition/animation.
自动重绘新布局
然而,当一个 CardView
被展开而另一个被缩回时,缩回有一个小问题,如下所示(请注意,展开和缩回一个 CardView
不会产生问题):
如果仔细观察,在收回时,当 CardView
的高度完全收回时,文本会部分可见。理想情况下,我希望文本更快地不可见(alpha 设置为 0)(大概 10 毫秒)。因此,我的问题是:
- 如何在展开另一个
CardView
和 的同时平滑地缩回 CardView
并调整 alpha 值的持续时间? (这个问题,从gif可以看出,动画视觉上没有吸引力。)
- 除了使用重新触发
onBindViewHolder
的 notifyItemChanged
之外,还有更好的方法来实现我想要的简单动画吗?
这里更清楚地说明了文本可见性部分可见的含义:
好的,可见性动画有一些棘手的情况。例如你可以看看这个post。
Layout animation not working on first run
android:animateLayoutChanges="true"
animateLayoutChanges 在大多数情况下就足够了。但是在 recyclerView 中,开发人员应该恢复 viewHolder 以前的状态,因为 recyclerView 重用了视图持有者,所以如果你不根据位置检查视图持有者的真实状态,它可能是错误的(例如,两个不同的位置使用相同的持有者,其中一个展开另一个倒下了)。现在有一个新问题,如果您使用动画布局更改,则没有简单的方法可以在没有动画的情况下展开或折叠描述文本。您可以尝试禁用、启用父布局转换,但它对我不起作用。
另一种方法是将 textViews 高度 0 更改为 TextView.getHeight() 或相反,但在这种情况下,您不知道 textviews 高度,因为它的高度为 0,除非用户单击它,否则您不想展开它。但是你可以找到TextView想要的高度。这是我的工作代码。如果对您有帮助,请告诉我。
class Adapter : RecyclerView.Adapter<Adapter.Holder>() {
private var expandedHolderPosition = -1
private var itemList = emptyList<Item>()
private var getViewHolder: (position: Int) -> Holder? = { null }
override fun getItemCount() = itemList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val inflatedView = parent.inflate(R.layout.item, false)
return Holder(inflatedView)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val currentItem = itemList[position]
holder.bindItem(currentItem)
}
fun updateList(items: List<Item>) {
this.expandedHolderPosition = -1
this.itemList = items
this.notifyDataSetChanged()
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
getViewHolder = { position ->
recyclerView.findViewHolderForAdapterPosition(position) as? Holder
}
}
inner class Holder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val textTitle = itemView.textTitle
private val textDescription = itemView.textDescription
private var desiredTextViewHeight = 0
private val isExpanded get() = adapterPosition == expandedHolderPosition
fun bindItem(item: Item) {
textTitle.text = item.title.toString()
textDescription.setText(item.description)
textDescription.post {
desiredTextViewHeight = with(textDescription) {
lineHeight * lineCount + layout.bottomPadding - layout.topPadding
}
if (isExpanded) {
showDescription(false)
} else {
hideDescription(false)
}
}
itemView.setOnClickListener {
val position = adapterPosition
//Its in open state close it.
if (isExpanded) {
expandedHolderPosition = -1
hideDescription(true)
} else {
getViewHolder(expandedHolderPosition)?.hideDescription(true)
showDescription(true)
expandedHolderPosition = position
}
}
}
private fun hideDescription(animate: Boolean) {
logDebug("hideDescription")
if (animate) {
changeHeightWithAnimation(desiredTextViewHeight, 0)
} else {
updateDescriptionHeight(0)
}
}
private fun showDescription(animate: Boolean) {
logDebug("showDescription")
if (animate) {
changeHeightWithAnimation(0, desiredTextViewHeight)
} else {
updateDescriptionHeight(desiredTextViewHeight)
}
}
private fun changeHeightWithAnimation(from: Int, to: Int) {
val animator = ValueAnimator.ofInt(from, to)
animator.duration = 300
animator.addUpdateListener { animation: ValueAnimator ->
updateDescriptionHeight(animation.animatedValue as Int)
}
animator.start()
}
private fun updateDescriptionHeight(newHeight: Int) {
textDescription.updateLayoutParams<ViewGroup.LayoutParams> {
height = newHeight
}
}
}
}
data class Item(val title: Int, @StringRes val description: Int = R.string.lorem)
用户项的布局文件。
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:orientation="vertical">
<TextView
android:id="@+id/textTitle"
android:layout_width="match_parent"
android:layout_height="48dp"
android:gravity="center_vertical"
tools:text="Title" />
<TextView
android:id="@+id/textDescription"
android:layout_width="match_parent"
android:layout_height="0dp"
android:gravity="center_vertical"
tools:layout_height="wrap_content"
tools:text="Description" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
我已经成功创建了自动布局更新。我有一个 CardView
列表显示在 RecyclerView
中。单击 CardView
后,表示 CardView
将展开并导致其他可能展开的 CardView
缩回。我通过以下方式做到了:
- 维护一个变量,该变量保存要在我的
RecyclerView
的适配器中展开的CardView
的位置 - 在任何
CardView
中发生点击时更新所述变量
- 如果另一个
CardView
被展开,收回前一个CardView
并展开新点击的CardView
执行上述操作的代码如下所示:
// Inside RecyclerView.Adapter
private var expandedViewHolder = -1
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val cardView = LayoutInflater.from(parent.context)
.inflate(R.layout.recyclerview_cardview, parent, false) as CardView
val holder = ViewHolder(cardView)
cardView.setOnClickListener {
notifyItemChanged(expandedPosition)
expandedPosition = if (expandedPosition == holder.adapterPosition) -1 else holder.adapterPosition
notifyItemChanged(expandedPosition)
}
return holder
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.headline.text = dataset[position].name
holder.desc.text = dataset[position].desc
if (position == expandedPosition) {
holder.desc.visibility = View.VISIBLE
}
else {
holder.desc.visibility = View.GONE
}
}
可以看出,我使用 notifyItemChanged()
强制 RecyclerView
重新触发 onBindViewHolder
。在 onBindViewHolder
中,CardView
的描述的可见性将相应改变。 onBindViewHolder
使用自动 transition/animation.
然而,当一个 CardView
被展开而另一个被缩回时,缩回有一个小问题,如下所示(请注意,展开和缩回一个 CardView
不会产生问题):
如果仔细观察,在收回时,当 CardView
的高度完全收回时,文本会部分可见。理想情况下,我希望文本更快地不可见(alpha 设置为 0)(大概 10 毫秒)。因此,我的问题是:
- 如何在展开另一个
CardView
和 的同时平滑地缩回CardView
并调整 alpha 值的持续时间? (这个问题,从gif可以看出,动画视觉上没有吸引力。) - 除了使用重新触发
onBindViewHolder
的notifyItemChanged
之外,还有更好的方法来实现我想要的简单动画吗?
这里更清楚地说明了文本可见性部分可见的含义:
好的,可见性动画有一些棘手的情况。例如你可以看看这个post。 Layout animation not working on first run
android:animateLayoutChanges="true"
animateLayoutChanges 在大多数情况下就足够了。但是在 recyclerView 中,开发人员应该恢复 viewHolder 以前的状态,因为 recyclerView 重用了视图持有者,所以如果你不根据位置检查视图持有者的真实状态,它可能是错误的(例如,两个不同的位置使用相同的持有者,其中一个展开另一个倒下了)。现在有一个新问题,如果您使用动画布局更改,则没有简单的方法可以在没有动画的情况下展开或折叠描述文本。您可以尝试禁用、启用父布局转换,但它对我不起作用。
另一种方法是将 textViews 高度 0 更改为 TextView.getHeight() 或相反,但在这种情况下,您不知道 textviews 高度,因为它的高度为 0,除非用户单击它,否则您不想展开它。但是你可以找到TextView想要的高度。这是我的工作代码。如果对您有帮助,请告诉我。
class Adapter : RecyclerView.Adapter<Adapter.Holder>() {
private var expandedHolderPosition = -1
private var itemList = emptyList<Item>()
private var getViewHolder: (position: Int) -> Holder? = { null }
override fun getItemCount() = itemList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val inflatedView = parent.inflate(R.layout.item, false)
return Holder(inflatedView)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val currentItem = itemList[position]
holder.bindItem(currentItem)
}
fun updateList(items: List<Item>) {
this.expandedHolderPosition = -1
this.itemList = items
this.notifyDataSetChanged()
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
getViewHolder = { position ->
recyclerView.findViewHolderForAdapterPosition(position) as? Holder
}
}
inner class Holder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val textTitle = itemView.textTitle
private val textDescription = itemView.textDescription
private var desiredTextViewHeight = 0
private val isExpanded get() = adapterPosition == expandedHolderPosition
fun bindItem(item: Item) {
textTitle.text = item.title.toString()
textDescription.setText(item.description)
textDescription.post {
desiredTextViewHeight = with(textDescription) {
lineHeight * lineCount + layout.bottomPadding - layout.topPadding
}
if (isExpanded) {
showDescription(false)
} else {
hideDescription(false)
}
}
itemView.setOnClickListener {
val position = adapterPosition
//Its in open state close it.
if (isExpanded) {
expandedHolderPosition = -1
hideDescription(true)
} else {
getViewHolder(expandedHolderPosition)?.hideDescription(true)
showDescription(true)
expandedHolderPosition = position
}
}
}
private fun hideDescription(animate: Boolean) {
logDebug("hideDescription")
if (animate) {
changeHeightWithAnimation(desiredTextViewHeight, 0)
} else {
updateDescriptionHeight(0)
}
}
private fun showDescription(animate: Boolean) {
logDebug("showDescription")
if (animate) {
changeHeightWithAnimation(0, desiredTextViewHeight)
} else {
updateDescriptionHeight(desiredTextViewHeight)
}
}
private fun changeHeightWithAnimation(from: Int, to: Int) {
val animator = ValueAnimator.ofInt(from, to)
animator.duration = 300
animator.addUpdateListener { animation: ValueAnimator ->
updateDescriptionHeight(animation.animatedValue as Int)
}
animator.start()
}
private fun updateDescriptionHeight(newHeight: Int) {
textDescription.updateLayoutParams<ViewGroup.LayoutParams> {
height = newHeight
}
}
}
}
data class Item(val title: Int, @StringRes val description: Int = R.string.lorem)
用户项的布局文件。
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:orientation="vertical">
<TextView
android:id="@+id/textTitle"
android:layout_width="match_parent"
android:layout_height="48dp"
android:gravity="center_vertical"
tools:text="Title" />
<TextView
android:id="@+id/textDescription"
android:layout_width="match_parent"
android:layout_height="0dp"
android:gravity="center_vertical"
tools:layout_height="wrap_content"
tools:text="Description" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>