美文网首页高级UI
高级UI<第四十八篇>:NestedScrolling升级方案

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

作者: NoBugException | 来源:发表于2020-04-05 00:46 被阅读0次

    前两章讲解了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

    [本章完...]

    相关文章

      网友评论

        本文标题:高级UI<第四十八篇>:NestedScrolling升级方案

        本文链接:https://www.haomeiwen.com/subject/kpephhtx.html