RecyclerView & NestedScrollView 嵌套收缩动画解决方案

在复杂的业务场景中,会利用到 NestedScrollView 嵌套好几个固定的布局来展示内容。
在固定的布局中可能存在竖向的列表,并且要求列表完全展开。针对列表中的 Item 还需要赋予位置移动动画,整个列表收缩动画及展开动画。

情景说明

本文针对该场景采取的是嵌套实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<android.support.v4.widget.NestedScrollView
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/nested_scroll_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<LinearLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content">
</android.support.v7.widget.RecyclerView>

</LinearLayout>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="@color/colorAccent">
</LinearLayout>
</LinearLayout>
</android.support.v4.widget.NestedScrollView>

在上面的布局中,RecyclerView中是一个列表数据的展示,其中包含 位置移动,收缩,展开等操作。(PS:上述层级较多,只是为了测试层级对RecyclerView的影响,毕竟复杂场景不会只有一个RecyclerView

1
2
3
4
5
6
7
8
9
val list = ArrayList<Int>()
list += 1..20
recycler_view.layoutManager = LinearLayoutManager(this)
adapter = Adapter<Int>(list)
recycler_view.adapter = adapter
recycler_view.itemAnimator = DefaultItemAnimator()
recycler_view.itemAnimator.addDuration = 1000
recycler_view.itemAnimator.removeDuration = 1000
adapter!!.expand()

这里我们将动画的时长设置很长,便于观察。其中 Adapter 增加了收缩和展开的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private var maxCount = 0

override fun getItemCount(): Int {
return minOf(dataList.size, maxCount)
}

fun expand() {
val count = itemCount // 同 getItemCount()
maxCount = Int.MAX_VALUE
notifyItemRangeInserted(count, itemCount - count)
}

fun collapse() {
val count = itemCount
maxCount = 1
notifyItemRangeRemoved(1, count - itemCount)
}

简单的配置之后,我们发现。在展开动画开启时,RecyclerView 会伸缩到合适的高度以容纳 所有的 Item(这里设置了 android:fillViewport="true" 的属性),然后我们才会看到默认的 add Item 的动画。但是,当我们收缩列表的时候,并没有观察到动画,给人的感觉是 直接 调用了 notifyDataSetChanged() 的方法。下面我们追踪一下源码来看看为什么会出现这种问题。

首先是 notifyItemRangeRemoved() 方法

1
2
3
public final void notifyItemRangeRemoved(int positionStart, int itemCount) {
mObservable.notifyItemRangeRemoved(positionStart, itemCount);
}

根据该条代码进行追踪到最终实现的部分,在 RecyclerViewDataObserver 的实现中,我们找到了具体的实现逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
assertNotInLayoutOrScroll(null);
if (mAdapterHelper.onItemRangeInserted(positionStart, itemCount)) {
triggerUpdateProcessor();
}
}

void triggerUpdateProcessor() {
if (POST_UPDATES_ON_ANIMATION && mHasFixedSize && mIsAttached) {
ViewCompat.postOnAnimation(RecyclerView.this, mUpdateChildViewsRunnable);
} else {
mAdapterUpdateDuringMeasure = true;
requestLayout();
}
}

mHasFixedSize 字段的控制是关键所在,默认是false,所以直接进行了 requestLayout() 的操作,导致RecyclerView的高度直接变化到最小。

个人解决方案

  • 如果列表的初始状态为完全展开状态。可以通过测量第一个Item高度,以及总高度
1
2
3
4
5
6
7
recycler_view.post {
val viewFirst = recycler_view.layoutManager.findViewByPosition(0)
firstItemHeight = viewFirst!!.height
totalHeight = recycler_view.height
}
//调用 adapter.collapse()或者 expand()的时候 调用 下面的方法。
height(recycler_view, totalHeight.toFloat(), firstItemHeight.toFloat(), 1000, null)

只需要在收缩时 调用 height 的动画即可,展开也可以调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fun height(view: View, from: Float, to: Float, duration: Int, animatorListener: Animator.AnimatorListener?): ValueAnimator {
val animator = ValueAnimator.ofFloat(from, to)
animator.duration = duration.toLong()
if (animatorListener != null) {
animator.addListener(animatorListener)
}
animator.addUpdateListener { animation ->
if (view.layoutParams != null) {
val lp = view.layoutParams
val aFloat = animation.animatedValue as Float
lp.height = aFloat.toInt()
view.layoutParams = lp
}
}
animator.start()
return animator
}
  • 通过设置 mHasFixedSize 属性 来达到目的
1
2
3
4
5
6
7
8
9
10
11
if (iem.itemId == R.id.collapse) {
recycler_view.setHasFixedSize(true)
it.collapse()
recycler_view.postDelayed({
recycler_view.setHasFixedSize(false)
recycler_view.requestLayout()
}, recycler_view.itemAnimator.addDuration)
} else {
recycler_view.setHasFixedSize(false)
it.expand()
}