RecyclerView的展开与折叠是一种常见的动画
主要有两种方式可以实现
1.通过添加与移除元素
notifyInsert,notifyRemoved,这种方式涉及到元素的加减,动画效果不太流畅
2.通过给RecyclerView的item添加动画
这种情况需要考虑一个item添加动画时,对其他的item的影响。而利用MotionLayout可以方便的实现这一点。
先来看看效果
1.支持流畅的展开折叠
2.支持多类型item
3.支持只能同时展开一个
下面来看下具体实现
引入MotionLayout库
dependencies { implementation 'com.android.support.constraint:constraint-layout:2.0.0-beta2' }
在布局文件中使用
MotionLayout 想要使用 MotionLayout,只需要在布局文件中作如下声明即可:
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/motionContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
app:layoutDescription="@xml/motion_list_rv_item_scene">
.....
</android.support.constraint.motion.MotionLayout>
由于 MotionLayout 作为 ConstraintLayout 的子类,那么就自然而然地可以像 ConstraintLayout 那样使用去“约束”子视图了,不过这可就有点“大材小用了”,MotionLayout 的用处可远不止这些。我们先来看看 MotionLayout 的构成:
由上图可知,MotionLayout 可分为 和 两个部分。 部分可简单理解为一个 ConstraintLayout,至于 其实就是我们的“动画层”了。MotionLayout 为我们提供了 layoutDescription 属性,我们需要为它传入一个 MotionScene 包裹的 XML 文件,想要实现动画交互,就必须通过这个“媒介”来连接。
MotionScene
什么是 MotionScene?结合上图 MotionScene 主要由三部分组成:StateSet、ConstraintSet 和 Transition
实现RecyclerView展开折叠效果,主要用到了 ConstarintSet 和 Transition
首先来看看布局文件
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/motionContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
app:layoutDescription="@xml/motion_list_rv_item_scene">
<LinearLayout
android:id="@+id/box_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="86dp">
....
</LinearLayout>
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="100dp"
android:background="@color/blue_magic" />
</LinearLayout>
<View
android:id="@+id/view2"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@id/box_content"
android:background="#eaeaef" />
</androidx.constraintlayout.motion.widget.MotionLayout>
布局文件很简单,只不过你可能会注意到,我们对 LinearLayout并没有添加任何约束,原因在于:我们会在 MotionScene 中声明 ConstraintSet,里面将包含该 LinearLayout 的“运动”起始点和终点的约束信息。
当然你也可以在布局文件中对其加以约束,但 MotionScene 中对于控件约束的优先级会高于布局文件中的设定。这里我们通过 layoutDescription 来为 MotionLayout 设置它的 MotionScene 为 motion_list_rv_item_scene,接下来就让我们一睹 MotionScene 的芳容:
动画文件
<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ConstraintSet android:id="@+id/start">
<Constraint
android:id="@id/box_content"
android:layout_width="0dp"
android:layout_height="86dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</ConstraintSet>
<ConstraintSet android:id="@+id/end">
<Constraint
android:id="@id/box_content"
android:layout_width="0dp"
android:layout_height="186dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</ConstraintSet>
<Transition
app:constraintSetEnd="@id/end"
app:constraintSetStart="@+id/start"
app:duration="500"
app:motionInterpolator="easeInOut">
</Transition>
</MotionScene>
首先,可以发现我们定义了两个 分别描述了RecyclerView中的item的动画起始位置以及结束位置的约束信息(仅包含少量必要信息,如:width、height、margin以及位置属性等)。
显而易见,itemView起始高度为86dp,结束高度186dp.
那么问题来了,如何让它动起来呢?
这就要依靠我们的 元素了。
事实上,我们都知道,动画都是有开始位置和结束位置的,而 MotionLayout 正是利用这一客观事实,将首尾位置和动画过程分离,两个点位置和距离虽然是固定的,但是它们之间的 Path 是无限的,可以是“一马平川”,也可以是"蜿蜒曲折"的。
我们只需要为 Transition 设置起始位置和结束位置的 ConstraintSet 并设置动画时间即可,剩下的都交给 MotionLayout 自动去帮我们完成。
当然你也可以通过 onClick 点击事件来触发动画,绑定目标控件的 id 以及通过 clickAction 属性来设置点击事件的类型。
OnClick有多种类型
- 1.toggle,如果布局当前处于开始状态,请将动画效果切换为结束状态;否则,请将动画效果切换为开始状态。
- 2.transitionToStart,为从当前布局到 元素的 motion::constraintSetStart 属性指定的布局添加动画效果。
- 3.transitionToEnd,为从当前布局到 元素的 motion:constraintSetEnd 属性指定的布局添加动画效果。
只能同时展开一个item实现
因为我们需要在展开一个item时,折叠其他item,因此不在xml中指定点击事件,去adapter中指定
实现展开一个时折叠其他item 我们可以通过MotionLayout的progress判断当前是在start状态还是end状态。
下面的代码主要有几点需要注意的
1.如果是start状态则展开,否则则折叠
2.利用payload局部刷新达到折叠其他itemView的效果。
3.在RecyclerView滚动时会复用,所以需要在onBindViewHolder时初始化item的状态,即progress,不然会发生错位现象
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is MotionViewHolder) {
val motionBox = holder.itemView.findViewById<MotionLayout>(R.id.motionContainer)
if (expandList[position]){
motionBox.progress = 1.0f
}else{
motionBox.progress = 0f
}
holder.itemView.setOnClickListener {
expandList.fill(false)
if (motionBox.progress == 1.0f) {
motionBox.transitionToStart()
} else if (motionBox.progress == 0.0f) {
motionBox.transitionToEnd()
expandList[position] = true
}
for (i in 0 until itemCount) {
if (i != position) {
notifyItemChanged(i, "collapse")
}
}
}
}
}
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>
) {
if (payloads.isNullOrEmpty()) {
super.onBindViewHolder(holder, position, payloads)
} else {
if (holder is MotionViewHolder) {
val motionBox = holder.itemView.findViewById<MotionLayout>(R.id.motionContainer)
motionBox.transitionToStart()
}
}
}
总结
通过以上步骤,即利用MotionLayout比较简单的实现了RecyclerView的item展开折叠效果
1.支持流畅的展开折叠
2.支持多类型item
3.支持只能同时展开一个
MotionLayout还有很多更强大的功能,比如与AppBarLayout联动,与Lottie联动,实现复杂动画等。
读者如有兴趣可阅读下方的参考链接,及本文的所有代码
本文的所有相关代码
参考资料
MotionLayout:打开动画新世界大门 (part I)
Android MotionLayout动画:续写ConstraintLayout新篇章