我开源了一个方便RecyclerView吸顶的Android库,欢迎您访问https://github.com/lizijin/StickyHeaderForRecyclerView,如果您使用本库,请提出您的宝贵意见。
它目前支持以下功能:
- 支持单类型吸顶功能
- 支持多类型吸顶功能
- 支持开启和关闭吸顶功能
- 支持指定位置吸顶功能
- 支持设置吸顶偏移量
- 支持自定义RecyclerView上Item吸顶边界自定义
- 可以无缝配合AppBarLayout
开门见山,最近需要实现一个二次吸顶的效果,UE给出的效果图如下
分析如下:
看到吸顶的效果,首先想到使用CoordinatorLayout和AppBarLayout来实现。界面主要分为2个部分:
- 底部的可滑动区域,可以用ScrollView或者RecyclerView实现。
- 顶部的吸顶区域用AppBarLayout实现。
我们把顶部的吸顶区域拆分一下,可以分为以下4个部分:
- 品牌区域,当往下滑的时候,RecyclerView显示第一个Item的时候,该区域才开始往下滑。
- 品类图片区域,当下滑的时候,该区域会跟随往下滑动,当该区域到达顶部时会一直吸顶,直到RecyclerView第一个Item出现的时候,才往下滑动。
- 品类标题区域,当上滑的时候,该区域会先跟随往上滑动,当该区域到达顶部时会一直吸顶
- 排序和筛选区域,当下滑的时候,无论RecyclerView当前的位置在哪里,该区域会首先跟随向下滑出
在本文中,我把向上滑动区域3吸顶,向下滑动区域2吸顶做了一个定义叫二次吸顶,二次吸顶的功能会有一个矛盾点,我们想让区域3在上滑的时候吸顶,那么势必造成区域4无法作为AppBarLayout的一部分(熟悉AppBarLayout的scroll标志的同学应该能理解,如果不能理解,本文后半部分原理篇会讲解到),那么区域4只能做为RecyclerView的头部,作为头部,只有当RecyclerView下滑到顶,区域4才会向下跟随滑出,无法满足上面区域4的要求。
当分析出这样一个矛盾点的时候。我的第一反应是,使用AppBarLayout无法满足现有的要求,我必须得自己重写一个Behavior来实现该功能,而且该Behavior必须满足下面3个条件:
- Behavior本身需要处理滑动事件,在吸顶区域滑动时,RecyclerView需要联动
- Behavior需要处理RecylerView和吸顶区域之间的嵌套滑动,在RecyclerView区域滑动时,吸顶区域需要联动
- 上滑时区域3要吸顶,下滑时区域4要吸顶
一想到只需要满足这么三个条件便能实现效果,迫不及待地跃跃欲试,但是冷静了几秒钟会发现,但是处理滑动事件,不仅需要考虑各种事件类型的处理,嵌套滑动的处理,以及CoordiatorLayout复杂的事件分发逻辑,还有Fling等操作的处理,突然感觉是一个超大的工程量,真的有必要自己手工写一个Behavior吗?难道Google爸爸的AppBarLayout真的就那么肤浅,只能实现一个单一的吸顶功能吗?在手写Behavior之前看来很有必要,看看AppBarLayout内部的实现原理呀,所谓知己知彼方能百战百胜嘛,又所谓知其然,知其所以然。如果AppBarLayout不能实现二次吸顶功能,我们要知道其中的原因,如果能实现二次吸顶的功能,那么我们应该怎么做呢?
经过了三天三夜,不断对AppBarLayout源码的深入研究以及不断的Demo验证,终于发现,原来Google爸爸心思缜密,二次吸顶的功能,早给我们想好了,就等待着我们去发掘。终于在无需手写一行自定义Behavior、无需手写嵌套滑动逻辑的情况下,不辱使命,完成了二次吸顶效果的开发。效果图如下:
代码如下
布局文件
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".coordinatorlayout.CollapsingToolbarLayoutActivity">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/long_hate_song"></TextView>
</androidx.core.widget.NestedScrollView>
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/pic1"
android:contentDescription="吸顶区域一"
android:gravity="center"
android:scaleType="fitXY"
app:layout_scrollFlags="scroll" />
<ImageView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/pic2"
android:contentDescription="吸顶区域二"
android:gravity="center"
app:layout_scrollFlags="enterAlways|scroll"></ImageView>
<com.peter.viewgrouptutorial.coordinatorlayout.FloatLinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_scrollFlags="enterAlways|scroll|exitUntilCollapsed">
<ImageView
android:id="@+id/text.view3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/pic3"
android:contentDescription="吸顶区域三"
android:gravity="center"
android:text="HEAD3"
app:layout_pin="true" />
<ImageView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/pic4"
android:contentDescription="吸顶区域四"
android:gravity="center"
app:layout_pin="false" />
<ImageView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/pic5"
android:contentDescription="吸顶区域四"
app:layout_pin="false" />
</com.peter.viewgrouptutorial.coordinatorlayout.FloatLinearLayout>
>
</com.google.android.material.appbar.AppBarLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
自定义FloatLinearLayout
public class FloatLinearLayout extends LinearLayout {
int currentOffset;
private AppBarLayout.OnOffsetChangedListener onOffsetChangedListener;
public FloatLinearLayout(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
setChildrenDrawingOrderEnabled(true);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (((LayoutParams) child.getLayoutParams()).pin) {
setMinimumHeight(child.getMeasuredHeight());
break;
}
}
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
// Add an OnOffsetChangedListener if possible
final ViewParent parent = getParent();
if (parent instanceof AppBarLayout) {
// Copy over from the ABL whether we should fit system windows
ViewCompat.setFitsSystemWindows(this, ViewCompat.getFitsSystemWindows((View) parent));
if (onOffsetChangedListener == null) {
onOffsetChangedListener = new FloatLinearLayout.OffsetUpdateListener();
}
((AppBarLayout) parent).addOnOffsetChangedListener(onOffsetChangedListener);
// We're attached, so lets request an inset dispatch
ViewCompat.requestApplyInsets(this);
}
}
@Override
protected void onDetachedFromWindow() {
// Remove our OnOffsetChangedListener if possible and it exists
final ViewParent parent = getParent();
if (onOffsetChangedListener != null && parent instanceof AppBarLayout) {
((AppBarLayout) parent).removeOnOffsetChangedListener(onOffsetChangedListener);
}
super.onDetachedFromWindow();
}
@Override
protected int getChildDrawingOrder(int childCount, int i) {
return childCount - i - 1;
}
@Override
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
return p instanceof LayoutParams;
}
@Override
protected LayoutParams generateDefaultLayoutParams() {
return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new LayoutParams(getContext(), attrs);
}
@Override
protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
return new LayoutParams(p);
}
public static class LayoutParams extends LinearLayout.LayoutParams {
boolean pin = false;
public LayoutParams(Context c, AttributeSet attrs) {
super(c, attrs);
TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.FloatLinearLayout_Layout);
pin =
a.getBoolean(
R.styleable.FloatLinearLayout_Layout_layout_pin, false);
a.recycle();
}
public LayoutParams(int width, int height) {
super(width, height);
}
public LayoutParams(int width, int height, float weight) {
super(width, height, weight);
}
public LayoutParams(ViewGroup.LayoutParams p) {
super(p);
}
public LayoutParams(MarginLayoutParams source) {
super(source);
}
public LayoutParams(LinearLayout.LayoutParams source) {
super(source);
}
}
private class OffsetUpdateListener implements com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener {
OffsetUpdateListener() {
}
@Override
public void onOffsetChanged(AppBarLayout layout, int verticalOffset) {
currentOffset = verticalOffset;
for (int i = 0, z = getChildCount(); i < z; i++) {
final View child = getChildAt(i);
final FloatLinearLayout.LayoutParams lp = (FloatLinearLayout.LayoutParams) child.getLayoutParams();
final ViewOffsetHelper offsetHelper = getViewOffsetHelper(child);
if (lp.pin) {
int offset = -currentOffset - getTop();
offsetHelper.setTopAndBottomOffset(MathUtils.clamp(offset, 0, offset));
}
}
}
}
@NonNull
static ViewOffsetHelper getViewOffsetHelper(@NonNull View view) {
ViewOffsetHelper offsetHelper = (ViewOffsetHelper) view.getTag(com.google.android.material.R.id.view_offset_helper);
if (offsetHelper == null) {
offsetHelper = new ViewOffsetHelper(view);
view.setTag(com.google.android.material.R.id.view_offset_helper, offsetHelper);
}
return offsetHelper;
}
}