阅读 5817

利用MotionLayout实现RecyclerView折叠展开动画

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联动,实现复杂动画等。
读者如有兴趣可阅读下方的参考链接,及本文的所有代码

本文的所有相关代码

MotionLayoutRecyclerView实现

参考资料

MotionLayout:打开动画新世界大门 (part I)
Android MotionLayout动画:续写ConstraintLayout新篇章

文章分类
Android
文章标签