高级UI<第四十八篇>:NestedScrolling升级方案

前两章讲解了NestedScrolling的基础,NestedScrolling本质上是父view和子view在滚动的时候相互协调工作。
许多App的设计大致是:
头部一张图片,下面是recyclerview做为NestedScrolling的子view,默认情况下,头部图片是不显示的,当手指按住recyclerview慢慢向下滑动时,会逐渐显示图片,如果当前recyclerview已经被向下滚动了,那么手指滑动recyclerview时,先滚动recyclerview本身,当recyclerview到顶时头部图片才会慢慢显示。
这就是NestedScrolling被设计出来的初衷,Android 5.0之后,NestedScrollingParent和NestedScrollingChild被设计出来,以完成以上功能。
但是,recyclerview快速滚动后触发fling动作后,recyclerview达到顶部会立即停下来,不再会继续通过fling的惯性将顶部图片展示出来,也就是说,NestedScrollingParent和NestedScrollingChild对fling的设计并不友好。
好在Android 8.0之后Google弥补了这个缺陷,推出了NestedScrollingParent2NestedScrollingChild2,他们可以非常友好的处理fling事件。

前面两篇文章我已经讲解了NestedScrollingParent和NestedScrollingChild的各种方法的作用以及用法,NestedScrollingParent2NestedScrollingChild2内方法实现的原理其实和前者差不多,这里偷个懒就不写了。其实也没必要自己实现了,在Android SDK自带组件中有NestedScrollView组件,来看一下这个控件:

[NestedScrollView]

NestedScrollView到底是什么样的存在?我觉得它是ScrollView替代品,因为NestedScrollView具有ScrollView的所有特性,除此之外,还支持嵌套滑动机制,看一下源码:

public class NestedScrollView extends FrameLayout implements NestedScrollingParent2, 
   NestedScrollingChild2, ScrollingView {

显然,NestedScrollView已经实现了NestedScrollingParent2NestedScrollingChild2,在AndroidX中推出了NestedScrollingParent3NestedScrollingChild3,比xxx2新增了水平和垂直方向消费的距离控制。再来看一下AndroidX中NestedScrollView的源码:

public class NestedScrollView extends FrameLayout implements NestedScrollingParent3,
    NestedScrollingChild3, ScrollingView {

说不定以后会推出NestedScrollingParent4NestedScrollingChild4,但是这已经不重要了。

完成嵌套滑动机制不仅仅需要一个实现NestedScrollingParent的父view还需要一个实现NestedScrollingChild的子view,NestedScrollView不仅实现了NestedScrollingParent,还实现了NestedScrollingChild,那么,NestedScrollView是否可以当做子view?答案是可以的。
但是,结合实际app开发套路,NestedScrollView一般做为嵌套滑动机制的父view。
问题来了,有什么控件可以当作嵌套滑动机制的子view?

RecyclerView是我们常用的数据显示控件,ListView将被它所替代(之所以被替代不是因为RecyclerView的性能比ListView好,而是因为RecyclerView加入了其它方面的支持,RecyclerView支持嵌套滑动机制就是其中之一)

RecyclerView部分源码如下:

public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild2 {

在AndroidX版本中,NestedScrollingChild2升级到了NestedScrollingChild3

public class RecyclerView extends ViewGroup implements ScrollingView,
    NestedScrollingChild2, NestedScrollingChild3 {

所以,可以将RecyclerView做为嵌套滑动机制的子view。

[NestedScrollView和RecyclerView实现嵌套滑动]

首先看一下以下布局:

<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

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

        <ImageView
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:scaleType="center"
            android:src="@mipmap/top_pic" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="权威发布"
            android:textColor="@color/colorAccent"
            android:background="@color/colorPrimary"
            android:padding="20dp"
            android:textAlignment="center"
            android:textSize="20sp"/>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recycler_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>

    </LinearLayout>

</androidx.core.widget.NestedScrollView>

在预览界面的效果如下:

图片.png

但是,在真机或者模拟器显示的效果是:

52.gif

当recyclerview具有惯性并且惯性滑动到recyclerview顶部时,会直接现实顶部图片,解决了NestedScrollingParentNestedScrollingChild会卡在recyclerview顶部的弊端,如下图:

54.gif

NestedScrollView + RecyclerView虽然可以实现嵌套滑动机制,但是却很有问题:

【问题一】 NestedScrollView破坏了RecyclerView的复用机制

RecyclerView的强大之处就在于它具有复用机制,那么,如果它复用的特性被破坏了,那么RecyclerView将一无是处。

【问题二】 RecyclerView初始位置异常

52.gif

如图,第一次打开页面只能看到RecyclerView,顶部的图片尽然看不到,因为RecyclerView默认设置焦点,导致RecyclerView滚动,在页面复杂的情况下,也能还会导致头部和RecyclerView跳动,在网络上存在大量的解决方案,但是,我认为NestedScrollView下嵌套RecyclerView本身就是错误的

不管是NestedScrollView还是RecyclerView,它们都实现了ScrollingView接口,所以NestedScrollViewRecyclerView都具备滚动特性,既然都具备滚动特性,那为什么还要嵌套??

我们看一下这样的布局,如下:

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

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recycler_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>
        
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recycler_view2"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>

    </LinearLayout>

当两种不同数据的集合被要求放入一个列表下时,有些研发人员为了省事,便擅作主张的写了这样的布局,为了能够让两个RecyclerView一起滚动,便添加了NestedScrollView,修改后的代码如下:

<androidx.core.widget.NestedScrollView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

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

        <ImageView
            android:id="@+id/imageview"
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:scaleType="center"
            android:src="@mipmap/top_pic" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="权威发布"
            android:textColor="@color/colorAccent"
            android:background="@color/colorPrimary"
            android:padding="20dp"
            android:textAlignment="center"
            android:textSize="20sp"/>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recycler_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>

    </LinearLayout>

</androidx.core.widget.NestedScrollView>

然而,这样写破坏了RecyclerView自身的惯性滑动,上下两个RecyclerView的fling事件无法被触发,为了解决这个问题,解决这个问题也简单,将两个RecyclerView分别设置如下属性即可:

    mRecyclerView.setNestedScrollingEnabled(false);

这下,终于完成了需求。

但是,我想说,如果程序员这样设计是及其不负责任的行为,或者他的技能等级没有达到一定的水平。NestedScrollView嵌套RecyclerView的做法是不可取的,即使能解决一系列冲突问题,那么性能方面怎么说?NestedScrollView破坏了RecyclerView的复用功能。

既然,NestedScrollView嵌套RecyclerView的做法不可取,那么应该怎么完美实现嵌套滑动机制呢?

[CoordinatorLayout控件]

我们可以使用CoordinatorLayout控件替换上文的NestedScrollView,老规矩,看一下源码

public class CoordinatorLayout extends ViewGroup implements NestedScrollingParent2 {

在AndroidX后支持NestedScrollingParent3

public class CoordinatorLayout extends ViewGroup implements NestedScrollingParent2,
    NestedScrollingParent3 {

CoordinatorLayout是专门为嵌套滑动机制设计的,CoordinatorLayout控件必须和Behavior一起使用。

在这里需要声明一下:目前而言,CoordinatorLayout & Behavior是实现嵌套滑动的最优方案,其中经常使用自定义Behavior

自定义Behavior的讲解先放一放,文章后面会讲到。

说到Behavior,我想说,Android有自带的Behavior,AppBarLayout控件是Android中自带Behavior的控件,老规矩,简单看一下它的源码:

@CoordinatorLayout.DefaultBehavior(AppBarLayout.Behavior.class)
public class AppBarLayout extends LinearLayout {

从源码中可以看到

CoordinatorLayout.DefaultBehavior是自定义注解,AppBarLayout.Behavior.class是这个注解想要传递的值,点开这个注解,发现在CoordinatorLayout控件类中自定义了这样一个注解:

@Deprecated
@Retention(RetentionPolicy.RUNTIME)
public @interface DefaultBehavior {
    Class<? extends Behavior> value();
}

即使这个注解在AndroidX中是过时的,但是这并不是一个巧合。AppBarLayout通过自定义注解的方式将“AppBarLayout.Behavior.class”传递给CoordinatorLayout控件,在CoordinatorLayout控件中通过反射机制获取Behavior对象

图片.png

如上图所示,这个Behavior必须是Behavior的子类。

CoordinatorLayout中,有这样一个方法:

LayoutParams getResolvedLayoutParams(View child) {
    final LayoutParams result = (LayoutParams) child.getLayoutParams();
    if (!result.mBehaviorResolved) {
        if (child instanceof AttachedBehavior) {
            Behavior attachedBehavior = ((AttachedBehavior) child).getBehavior();
            if (attachedBehavior == null) {
                Log.e(TAG, "Attached behavior class is null");
            }
            result.setBehavior(attachedBehavior);
            result.mBehaviorResolved = true;
        } else {
            // The deprecated path that looks up the attached behavior based on annotation
            Class<?> childClass = child.getClass();
            DefaultBehavior defaultBehavior = null;
            while (childClass != null
                    && (defaultBehavior = childClass.getAnnotation(DefaultBehavior.class))
                    == null) {
                childClass = childClass.getSuperclass();
            }
            if (defaultBehavior != null) {
                try {
                    result.setBehavior(
                            defaultBehavior.value().getDeclaredConstructor().newInstance());
                } catch (Exception e) {
                    Log.e(TAG, "Default behavior class " + defaultBehavior.value().getName()
                            + " could not be instantiated. Did you forget"
                            + " a default constructor?", e);
                }
            }
            result.mBehaviorResolved = true;
        }
    }
    return result;
}

这段代码比较简单,如果子View实现了AttachedBehavior,可以直接获取Behavior,并将Behavior设置到view的属性中,否则读取CoordinatorLayout控件的子view,如果存在Behavior的自定义注解,则采用反射机制获取自定义注解中的传值,这个传值就是Behavior,最后将Behavior设置到view的属性中。

看一下这个布局:

<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    tools:context=".MainActivity">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:visibility="visible">

        <ImageView
            android:id="@+id/imageview"
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:scaleType="center"
            app:layout_scrollFlags="scroll"
            android:src="@mipmap/top_pic" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="权威发布"
            android:textColor="@color/colorAccent"
            android:background="@color/colorPrimary"
            android:padding="20dp"
            android:textAlignment="center"
            android:textSize="20sp"/>
    </com.google.android.material.appbar.AppBarLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"/>



</androidx.coordinatorlayout.widget.CoordinatorLayout>

这个布局被CoordinatorLayout包裹,CoordinatorLayout有两个子view,分别是AppBarLayout和RecyclerView,在RecyclerView的属性中看到了这样一句话:

app:layout_behavior="@string/appbar_scrolling_view_behavior"

layout_behavior是系统自定义属性,appbar_scrolling_view_behavior是系统资源文件中的字符串,这个字符串如下:

<string name="appbar_scrolling_view_behavior" translatable="false">
    com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior</string>

到这里,我想这个逻辑就完全贯通了,逻辑是:(重要)

CoordinatorLayout获取子view属性时,首先判断这个子view是否直接或间接实现了AttachedBehavior,
显然,RecyclerView并没有继承AttachedBehavior,从而走到else分支,读取RecyclerView的所有属性,发现了一个默认的Behavior,
这个Behavior就是`AppBarLayout$ScrollingViewBehavior`,也就是说,AppBarLayout的内部类`ScrollingViewBehavior`。

ScrollingViewBehavior间接继承于CoordinatorLayout.Behavior

以上xml布局的效果如下:

55.gif

那么,自定义Behavior该怎么实现呢?

自定义Behavior需要实现layoutDependsOnonDependentViewChanged方法,我已经写好,如下:

public class MyBehavior extends CoordinatorLayout.Behavior {

    //必须要写构造方法
    public MyBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
        //我们这里监听的是一个RecyclerView,当RecyclerView变化后,捕获
        return dependency instanceof AppBarLayout || super.layoutDependsOn(parent, child, dependency);
    }

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
        //偏移量
        int offset = dependency.getBottom() - child.getTop();
        child.setTranslationY(offset);
        return false;
    }
}

layoutDependsOn决定依赖的对象,这个demo的RecyclerView必须依赖AppBarLayout来变化,当RecyclerView滑动到顶部时,依赖对象AppBarLayout会发生变化,这时onDependentViewChanged被执行,相应的修改RecyclerView的位置。

使用这个自定义Behavior有两种方法:

[方法一]

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/recycler_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior=".MyBehavior"/>

这种方式在AndroidX被放弃,被另一种方式替代,请看方法二。

[方法二]

public class MyRecyclerView extends RecyclerView implements CoordinatorLayout.AttachedBehavior {

    private Context context;
    private AttributeSet attrs;

    public MyRecyclerView(@NonNull Context context) {
        this(context, null);
    }

    public MyRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        this.context = context;
        this.attrs = attrs;
    }


    @NonNull
    @Override
    public CoordinatorLayout.Behavior getBehavior() {
        return new MyBehavior(context, attrs);
    }
}

自定义一个MyRecyclerView,getBehavior的返回值是MyBehavior,在xml中的代码如下:

<com.juexing.nestedscrollingdemo.MyRecyclerView
    android:id="@+id/recycler_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

这个Behavior的效果和Android自带Behavior是一致的,然而,修改MyBehavior中的代码可以实现其它效果,显然,自定义Behavior的扩展性更高,在以后的开发中,基本上都会使用自定义Behavior来完成相应的需求。

AppBarLayout布局内有两个view,分别是ImageView和TextView,ImageView被设置了滚动标志

        app:layout_scrollFlags="scroll"

而TextView却没有,所以只有ImageView被滚动。

那么,如果变动一下需求,当ImageView渐渐消失后,TextView从上而下慢慢显示出来,这个效果怎么实现呢?

其实很简单,重新自定义一个Behavior,将AppBarLayout和TextView产生依赖,代码如下:

public class MyBehavior2 extends CoordinatorLayout.Behavior {

    //必须要写构造方法
    public MyBehavior2(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
        //我们这里监听的是一个RecyclerView,当RecyclerView变化后,捕获
        return dependency instanceof AppBarLayout || super.layoutDependsOn(parent, child, dependency);
    }

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {

        //偏移量
        float offset = -child.getHeight();

        //获取TextView的高度
        int textHeight = child.getHeight();
        //获取AppBarLayout的高度
        int appBarLayoutHeight = dependency.getHeight();

        if(appBarLayoutHeight > textHeight){
            offset = (Math.abs(dependency.getY()) * textHeight / (appBarLayoutHeight - textHeight)) - textHeight;
            if(offset > 0){
                offset = 0;
            }
        }else{
            //这里自由发挥,就不写了
        }
        child.setTranslationY(offset);
        return false;
    }
}

xml布局代码如下:

<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    tools:context=".MainActivity">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:visibility="visible"
        android:orientation="vertical"
        app:elevation="0dp">

        <ImageView
            android:id="@+id/imageview"
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:scaleType="center"
            app:layout_scrollFlags="scroll"
            android:src="@mipmap/top_pic" />

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior=".MyBehavior"/>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="权威发布"
        android:textColor="@color/colorAccent"
        android:background="@color/colorPrimary"
        android:padding="20dp"
        android:textAlignment="center"
        app:layout_behavior=".MyBehavior2"
        android:textSize="20sp"/>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

效果如下:

57.gif

如果加上透明度的话,只需要在代码中添加透明度即可:

图片.png

效果如下:

56.gif

最后,有关layout_scrollFlags属性的配置,可以查看这篇文章:

https://www.jianshu.com/p/f3a2fed6fd6e

[本章完...]

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容