1. 效果图
开门见山,最近需要实现一个二次吸顶的效果,UE给出的效果图如下
2. 实现思路
看到吸顶的效果,首先想到使用CoordinatorLayout和AppBarLayout来实现。界面主要分为2个部分:
- 「底部的可滑动区域,可以用ScrollView或者RecyclerView实现。」
- 「顶部的吸顶区域用AppBarLayout实现。」
我们把顶部的吸顶区域拆分一下,可以分为以下4个部分:
- 「品牌区域,当往下滑的时候,RecyclerView显示第一个Item的时候,该区域才开始往下滑。」
「品类图片区域,当下滑的时候,该区域会跟随往下滑动,当该区域到达顶部时会一直吸顶,直到RecyclerView第一个Item出现的时候,才往下滑动。」
- 「品类标题区域,当上滑的时候,该区域会先跟随往上滑动,当该区域到达顶部时会一直吸顶」
「排序和筛选区域,当下滑的时候,无论RecyclerView当前的位置在哪里,该区域会首先跟随向下滑出」
「在本文中,我把向上滑动区域3吸顶,向下滑动区域4吸顶做了一个定义叫二次吸顶。二次吸顶的功能会有一个矛盾点,我们想让区域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; } }