Android头部伸缩组件的原理及实现(上)

  唐皓萌  寒江不钓  前天

前言


我们的App最近上了一个新Feature,名叫Speed Dial,类似于视频中这样,Header和ViewPager里面的Page一起滚动,支持下拉刷新,有的页面还要支持上拉刷新,有的页面还有Bar固定在页面底部。


其实陌陌、大众点评、马蜂窝现在都有类似的功能,但可能我们的更复杂一些。


ViewPager是之前就有的,里面的页面支持用户自定义,有各式各样,如何在改动尽量少的情况下加上这个Header,满足业务需求,并且提高代码复用性呢?


下文是我司的Android工程师Troy带来的分享。

正文


相信大家对目前最新版本微信客户端中的小程序坞都不会陌生,就是那个聊天列表滑到顶部后继续向下overscroll就能被拉出来的“抽屉”(里面包含一个支持横向滚动的小程序列表)。得益于其在丰富了页面层次的同时保持了对屏幕空间的友好等特性,这种纵向抽屉式的设计在目前并不罕见,于是便有了做一个通用的头部header可伸缩组件的想法。考虑到大多数的此类产品需求为向一个现有的列表或页面的头部添加这样一个header,或者是将之前的layout分为两段(header&body),并且要求可根据用户的垂直滚动操作伸缩,这个组件必须能够做到支持无侵入式集成,即无需改变现有列表或页面的实现,采用组合的方式便可完成集成,即插即用。


扫描二维码关注公众号,回复: 2174477 查看本文章

OK,需求清楚了,下面就开始看看什么样的接口和控件能够帮助我们实现这个组件,这里分为两部分来描述,本文会根据需求理出实现的思路,而下一篇文章则会讨论具体的实现。


在开始前先给出已完成的组件,源码放在了GitHub上GitHub - kfrozen/HeaderCollapsibleLayout (https://github.com/kfrozen/HeaderCollapsibleLayout),点击阅读原文可查看,同时也上传到了maven上,大家可以通过在module的gradle中添加下述依赖来使用:

dependencies {
    compile 'com.troy.collapsibleheaderlayout:collapsibleheaderlayout:2.0.2'
}

下面开始正文。首先,显而易见的是该组件的行为一定是基于垂直方向的滚动事件完成的,这是大方向,同时列出几个关键词:垂直方向,头部控件,分层,无侵入,滚动。下面就来一步步通过布局,事件传递,header开闭方式等几个方面进行分析:


  • 布局: 显然,我们的这个外层组件(起个名字:HeaderCollapsibleLayout)基于纵向LinearLayout是个不错的选择,不论是新添加一个头部控件还是把原先的页面一分为二,都可以将上下两部分作为两个child添加到HeaderCollapsibleLayout中。实现的大体思路为将header和body的layoutId作为属性传入HeaderCollapsibleLayout,并在其构造函数中读取layoutId,同时inflate并完成添加操作:

    private void initStyleable(Context context, AttributeSet attrs) {
            if (attrs == null) {
                  return;
            }
            final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.HeaderCollapsibleLayout, 0, 0);
    
            if (a.hasValue(R.styleable.HeaderCollapsibleLayout_topPanelLayoutId)) {
                  initTopView(a.getResourceId(R.styleable.HeaderCollapsibleLayout_topPanelLayoutId, -1), this);
            }
            if (a.hasValue(R.styleable.HeaderCollapsibleLayout_bottomPanelLayoutId)) {
                  initBottomView(a.getResourceId(R.styleable.HeaderCollapsibleLayout_bottomPanelLayoutId, -1), this);
            }
    
          ......
    
          if (mTopView != null) addView(mTopView);
            if (mBottomView != null) addView(mBottomView);
    
            a.recycle();
        }
  • 事件传递: 我们需要根据用户的手势进行对header的缩放,自然地,这里离不开对于滚动事件的上下传递。回想一下我们平时对于此类页面的使用习惯:当header关闭时,手指向上移动就是对body列表的正常向下滚动(这里指列表内容向下滚动),而手指向下移动时,我们期待的是先向上滚动列表内容,当列表内容已到顶部时,此时手指再向下滑动才将header打开;相反的,在header已打开时,手指向下滑动应直接作用于列表,将其内容向上滚动,而手指向上滑动应该首先关闭header,当header被完全关闭后,再下来的向上滑动手势才会带动列表内容向下滚动。 因此,对于我们外层的HeaderCollapsibleLayout来说,需要拦截并按需消费向上的移动距离dy(dy>0),当header被完全关闭时即无需继续消费,此时需将剩余的dy下放给body。而对于向下的移动距离dy(dy<0)则不应由HeaderCollapsibleLayout拦截,而是应该等待下层body传回的剩余dy并消费。 好了,传递的方向和层次理清楚了,下面就该来选择用何种方式来实现上述的逻辑了。最直接的就是在dispatchTouchEvent, onInterceptTouchEvent和onTouchEvent这三个标准的touch事件回调中处理,但是这样的话无法避免地需要修改body中的代码,而且对于fling事件的处理也比较麻烦,所以需要考虑其他方案。考虑到body中的主体多为可垂直滚动的列表,似乎根据滚动事件来处理会方便很多,此处不难想到Android官方在support.v4包中推出的NestedScroll相关接口和组件,这套组件不仅提供了滚动发生前后的两组回调接口,更是帮我们处理了fling事件并且在其发生前后同样提供了回调接口。更重要的是,作为一个外层wrapper组件我们无法保证之前的body页面是否包含可滚动的控件,也许只是一个满屏的大图或是一个webview,面对这种情况NestedScrollView就能派上大用场了,它不仅能传递我们所需的滚动事件,还能很好地处理嵌套滚动的情况,并且在body最外层添加一个NestedScrollView成本是不高的,最多加几行代码设几个参数就行,毕竟我们的目的只是让滚动事件能通过NestedScroll机制在上下层View间传递,而不是真的让body能够滚动。 既然决定了利用NestedScroll机制来传递事件,就得先了解一下这家伙是如何工作的,这里分为两部分来看:NestedScrollingChild和NestedScrollingParent。其实跟我们平常的View间onTouchEvent传递很类似,事件由底至上传递,child可以消费事件也可以向上分发,parent接收到来自child的事件并处理。一个ViewGroup可以同时实现child和parent两个接口,或只选择其一实现,而一个View只能作为NestedScrollingChild存在。这两个接口主要是围绕nestedScroll和nestedFling两类事件展开,同时还分为PreNestedXXX和NestedXXX两大类,顾名思义,方法名带有Pre的会在此事件即将发生前被调用,给parent类一个机会去拦截或处理该事件,而不带Pre的就是事件被child执行完毕后回到parent时的余量,相当于没有被child消费的事件,fling事件(如果有)总是会发生在scroll事件之后。听起来是不是和我们的需求还挺契合的,下面就分别看一下两个接口中我们需要关注的方法:

    • NestedScrollingChild 一个NestedScrollingChild的实现主要负责将事件向其parent分发,按照一个touch事件被接收后的调用顺序,我们主要关注以下的调用链:dispatchPreNestedScroll -> dispatchNestedScroll -> dispatchPreNestedFling -> dispatchNestedFling -> stopNestedScroll。

    • NestedScrollingParent 而一个NestedScrollingParent的实现是通过接收由其NestedScrollingChild分发来的事件进行处理及动作的,对应分发顺序,这里的调用链为:onPreNestedScroll -> onNestedScroll -> onPreNestedFling -> onNestedFling -> onStopNestedScroll。

      就HeaderCollapsibleLayout来说,这是一个类似于NestedScrollView的夹层layout,既要作为NestedScrollingParent来接收其child传来的nestedScrolling事件,同时也需要作为NestedScrollingChild将处理后的事件分发出去,所以它需要同时实现child和parent两组接口以便做到能够无侵入地插入已有layout结构中,但与NestedScrollView不同的是,NestedScrollView不需要对接受到的事件做任何处理,直接dispatch出去即可,而具体到我们的需求,child接口中对于事件的分发同样不需要做任何定制,但在parent接口接收到滚动事件时我们需要根据这个事件对HeaderCollapsibleLayout做出相应改变。对于这些接口的实现,Android的support包已经为我们提供了一套默认实现,分别位于NestedScrollingChildHelper和NestedScrollingParentHelper两个类中,如果翻看NestedScrollView源码可以发现其对于所有的接口方法基本都是用这两个Helper类来代理实现的,这帮我们省了不少事,我们只需要修改上述调用链中关注的方法实现,而对于接口中其他的方法直接用默认实现即可。

  • 接口实现: 上面说到我们需要对NestedScrollingParent中的接口方法实现进行定制,具体分为四种情况:向上scroll,向下scroll,向上fling和向下fling。我们首先跟着事件传递顺序过一遍我们需要重写的地方,而具体的实现方案有两种,一个是基于scroll滚动,一个是基于reLayout也即修改header高度,在后文中会给出详细实现及这两种方案的优劣。下面先过一遍流程:

    • 向上scroll 手指自下而上滚动的时候,合理的响应应该是先折叠Header,当Header完全折叠后Body中的内容再开始接收滚动事件。所以显而易见的,这里需要在onNestedPreScroll回调中根据接收到的滚动事件开始折叠Header,下面是该方法的定义:

      /*@param target View that initiated the nested scroll
      * @param dx Horizontal scroll distance in pixels
      * @param dy Vertical scroll distance in pixels
      * @param consumed Output. The horizontal and vertical scroll distance consumed by this parent
      */
      public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) 

      我们要关注的是后两个参数,dy是本次滚动事件在垂直方向上的有效距离,consumed是由child传入的一个输出数组,用于记录该parent对于本次滚动事件的消费量。此方法会在NestedScrollingChild的dispatchPreNestedScroll方法中被调用,具体如下:

      public boolean dispatchNestedPreScroll(int dx, int dy,
          @Nullable @Size(2) int[] consumed, @Nullable @Size(2) int[] offsetInWindow) {
      
          ......
      
          consumed[0] = 0;
          consumed[1] = 0;
          mNestedScrollingParent.onNestedPreScroll(this, dx, dy, consumed);
      
          ......
      
          return consumed[0] != 0 || consumed[1] != 0;
      
          ......
      }

      其中,dy>0表示手指自下向上移动,反之表示向下。根据上面的分析,我们在这个方法中只需要处理dy>0的情况,也即关闭header的操作,所以在HeaderCollapsibleLayout中,重写这个方法,并在dy>0的时候拦截消费该事件来关闭header。具体的实现会在后文中给出。

    • 向下scroll 手指向下滚动时,我们期待的情景是先滚动body中的内容,当其已经滚动到顶部或不需要继续消费滚动事件时,再进行header展开的操作。于是这里我们应考虑在onNestedScroll回调中根据body分发来的剩余事件展开header,该方法定义如下:

      /*
      * @param target The descendent view controlling the nested scroll
      * @param dxConsumed Horizontal scroll distance in pixels already consumed by target
      * @param dyConsumed Vertical scroll distance in pixels already consumed by target
      * @param dxUnconsumed Horizontal scroll distance in pixels not consumed by target
      * @param dyUnconsumed Vertical scroll distance in pixels not consumed by target
      */
      public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
              int dxUnconsumed, int dyUnconsumed);

      我们在这里需要关心的是dyConsumed和dyUnconsumed,它们分别代表了child在垂直方向已经消费掉的以及仍未消费的距离。其中dyUnconsumed就是我们可以用来消费的距离。此方法会在NestedScrollingChild的dispatchNestedScroll方法中被调用,具体如下:

      public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
          if((isNestedScrollingEnabled() && mNestedScrollingParent != null) {
              ......
      
              mNestedScrollingParent.onNestedScroll(this, dxConsumed, dyConsumed,
                      dxUnconsumed, dyUnconsumed);
      
              ......
      
              return true;
          }
          return false;
      }

      由于我们在这里只需要处理手指向下滚动的情况,所以当dyUnconsumed>0时不需做处理并直接将该事件dispatch出去即可。而对于dyUnconsumed<0的情况,我们可以利用它进行打开header的操作

    • 向上fling & 向下fling 因为fling事件的相关回调都是在scroll事件后才发生的,所以不需要像处理scroll事件那样在事件发生的前后去分别处理,一律在onNestedPreFling中处理即可,下面是方法的定义:

      /*
      * @param target View that initiated the nested scroll
      * @param velocityX Horizontal velocity in pixels per second
      * @param velocityY Vertical velocity in pixels per second
      * @return true if this parent consumed the fling ahead of the target view
      */
      public boolean onNestedPreFling(View target, float velocityX, float velocityY);

      其中第三个参数velocityY是垂直方向上fling的速度,通过这个参数的正负我们可以判断出fling的放向,大于0表示向上fling,此时应该自动关闭header,反之向下时应该自动打开header,这里的打开关闭应使用动画完成而非像scroll一样跟随手指移动。这里有个需要注意的地方,在发生向下的fling动作时,我们仍应该保证只有在body的内容已滚动到顶时才打开header(当然这只是常规的操作体验,具体还是要根据产品需求来定),为了做到这一点,我们需要在onNestedScroll方法中记录下当前传入的dyUnconsumed,这样在后续的onNestedPreFling回调中,我们就可以通过判断本次事件是否在垂直方向上还有未被消费的部分,如果有说明body内容已到顶,这时就可以进行打开header的操作。 本来到这里我们的需求流程已经走完了,可谁让咱们是Android工程师呢,机型适配永远是一个绕不开的坎,果然这次也没让我失望,在三星S9上测试的时候,所有跟fling相关的回调都华丽丽的不工作了,而且是完全不会被调用的那种,经过一番debug,还是没明白到底是为啥。。。所以只好曲线救国了,回看到我们之前给出的函数调用链,在fling之后还会跟一个onStopNestedScroll事件,事实上这个事件是无论如何都会被回调的,不论fling事件有没有被触发,而且是在用户的手指从屏幕上抬起的时候被触发,所以这是一个很好的替代实现fling相关功能的地方,同时这里还能兼顾实现吸入式开关header的功能。下面是这个方法的定义:

       /*
       * @param target View that initiated the nested scroll
       */
       public void onStopNestedScroll(View target);

      到这里,整体的实现思路和流程就有了,回顾一下:布局方面我们采用了垂直方向的LinearLayout来装载一个可折叠的header和原本的body;事件传递方面我们选择了基于NestedScrolling事件来实现这个组件,在其提供的NestedScrollingChild和NestedScrollingParent两组接口中,我们重点需要重写onNestedPreScroll,onNestedScroll和onStopNestedScroll这三个方法来实现header可折叠的需求。

下一篇文章中,我们会根据本文的分析,继续讨论具体的实现方案和一些技术细节,最后再给出实现过程中遇到的一些坑供大家参考讨论。

阅读原文

https://mp.weixin.qq.com/s/Y0MoLSpw7rw5iardzZm8WA

猜你喜欢

转载自blog.csdn.net/sinat_17775997/article/details/81047816
今日推荐