高级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实现嵌套滑动]

首先看一下以下布局:




    

        

        

        

    


在预览界面的效果如下:

图片.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都具备滚动特性,既然都具备滚动特性,那为什么还要嵌套??

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

    

        
        
        

    

当两种不同数据的集合被要求放入一个列表下时,有些研发人员为了省事,便擅作主张的写了这样的布局,为了能够让两个RecyclerView一起滚动,便添加了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 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的属性中。

看一下这个布局:



    

        

        
    

    




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

app:layout_behavior="@string/appbar_scrolling_view_behavior"

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


    com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior

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

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被放弃,被另一种方式替代,请看方法二。

[方法二]

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中的代码如下:


这个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布局代码如下:



    

        

    

    

    


效果如下:

57.gif

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

图片.png

效果如下:

56.gif

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

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

[本章完...]

你可能感兴趣的:(高级UI<第四十八篇>:NestedScrolling升级方案)