增强版 RecyclerView 之 ObservableRecyclerView

2,135 阅读13分钟
原文链接: www.tuicool.com

以前的文章

[Android] ObservableScrollView分析(一)—— Quick Start

[Android] ObservableScrollView分析(二)—— Samples and Basic/Advanced techniques

[Android] ObservableScrollView分析(三)—— 显示/隐藏 Toolbar

[Android] ObservableScrollView分析(四)—— 视差图像 Parallax image 实现

[Android] ObservableScrollView分析(五)—— Sticky header 顶部固定

[Android] ObservableScrollView分析(六)—— Toolbar上的弹性空白

[Android] ObservableScrollView分析(七)—— 使用图像的弹性空白布局

概述

前面的章节,我们主要介绍了如何利用开源库 ObservableScrollView 来实现出各种我们需要的滚动效果的实例和实现代码,却一直没有分析过在实现过程中所使用的一系列 ObservablexxxView 的源代码,今天我们就来看看,在开源库中 ObservableRecyclerView 的源码。

ObservableRecyclerView 关系图如下:

由图可知:ObservableRecyclerView 继承自 RecyclerView 并实现 Scrollable 接口,与此同时,ObservableRecyclerView 还持有四个类的引用,这四个类分别是:ObservableScrollViewCallbacks、ScrollState、ViewGroup 和 MotionEvent,其中:

ObservableScrollViewCallbacks 是开源库所定义的一个回调接口;

ScrollState 是一个枚举类,表示了滑动的三个状态: STOPUPDOWN

ViewGroup 用于在 Touch 事件拦截的过程中,指定父类 View;

当用户触摸屏幕时则会产生一个 MotionEvent 对象,在 重写 onTouchEvent() 方法和 onInterceptTouchEvent() 方法时,都需要传入 MotionEvent 参数。

Scrollable

接口方法

首先来看 Scrollable 接口,该接口是开源库所定义的一个接口,给所有接下来需要实现可观测,可滚动的控件(RecyclerView、ScrollView、ListView、WebView 和 GridView)提供了一个通用的应用程序接口,实现该接口必须实现以下几个方法:

  • setScrollViewCallbacks(ObservableScrollViewCallbacks listener):设置一个回调监听
  • addScrollViewCallbacks(ObservableScrollViewCallbacks listener):增加一个回调监听
  • removeScrollViewCallbacks(ObservableScrollViewCallbacks listener):删除一个回调监听
  • clearScrollViewCallbacks():清除所有回调监听
  • scrollVerticallyTo(int y):垂直滚动到坐标 y 处(y 为绝对坐标)
  • getCurrentScrollY():返回当前 y 坐标
  • setTouchInterceptionViewGroup(ViewGroup viewGroup):设置一个触摸事件拦截的 ViewGroup ,用来将拦截事件传递到该 View 的父类 View 中去,这也是为什么 ObservableRecyclerView 类中会有 ViewGroup 类的引用的原因。

总结一下 Scrollable 接口,主要完成对滚动控件回调接口的设置、增加、删除和清理的工作,还能实现滚动的功能,返回当前垂直方向上的坐标,并且对拦截事件的处理进行相关设置。

具体实现

在 ObservableRecyclerView 中,这些方法分别是如何实现的?接下来让我们看一下:

@Override
  public void setScrollViewCallbacks(ObservableScrollViewCallbackslistener) {
    mCallbacks = listener;
  }
 
  @Override
  public void addScrollViewCallbacks(ObservableScrollViewCallbackslistener) {
    if (mCallbackCollection == null) {
      mCallbackCollection = new ArrayList<>();
    }
    mCallbackCollection.add(listener);
  }
 
  @Override
  public void removeScrollViewCallbacks(ObservableScrollViewCallbackslistener) {
    if (mCallbackCollection != null) {
      mCallbackCollection.remove(listener);
    }
  }
 
  @Override
  public void clearScrollViewCallbacks() {
    if (mCallbackCollection != null) {
      mCallbackCollection.clear();
    }
  }
 
  @Override
  public void setTouchInterceptionViewGroup(ViewGroupviewGroup) {
    mTouchInterceptionViewGroup = viewGroup;
  }
 
  @Override
  public void scrollVerticallyTo(int y) {
    ViewfirstVisibleChild = getChildAt(0);
    if (firstVisibleChild != null) {
      int baseHeight = firstVisibleChild.getHeight();
      int position = y / baseHeight;
      scrollVerticallyToPosition(position);
    }
  }
 
  @Override
  public int getCurrentScrollY() {
    return mScrollY;
  }
 

ObservableRecyclerView 持有一个 ArrayList,如果需要添加多个回调接口,则将添加的接口放入该容器中,删除和清理操作也是基于该容器的操作。注意一下 scrollVerticallyTo() 方法的实现,需要结合 scrollVerticallyToPosition() 方法一起阅读:

public void scrollVerticallyToPosition(int position) {
    LayoutManagerlm = getLayoutManager();
 
    if (lm != null && lminstanceof LinearLayoutManager) {
      ((LinearLayoutManager) lm).scrollToPositionWithOffset(position, 0);
    } else {
      scrollToPosition(position);
    }
  }
 

其中,在 scrollVerticallyToPosition() 方法中,针对 LayoutManager 的类型会有一个判断,如果 LayoutManager 是 LinearLayoutManager,执行 scrollToPositionWithOffset() 方法,否则执行 scrollToPosition() 方法,完成最后的滚动操作。

ObservableScrollViewCallbacks

接口方法

ObservableRecyclerView 持有 ObservableScrollViewCallbacks 的引用,该接口也是开源库所定义的接口,当使用 ObservableRecyclerView 的时候,由用户自己定义滚动时的回调函数,该接口中的方法如下:

  • onScrollChanged(int scrollY, boolean firstScroll, boolean dragging):在滚动状态发生改变的时候调用,但是不会在第一次加载页面的时候调用,如果需要在该方法中初始化布局,需要手动去调用一下。
  • onDownMotionEvent():手指按下的事件发生时的回调函数。
  • onUpOrCancelMotionEvent(ScrollState scrollState):手指抬起的事件发生或者滚动事件被取消时的回调函数。

具体实现

总的来说,在使用的时候,设置监听后重写回调接口中的这三个方法,已经足够实现我们所希望的滚动监听效果了。还记得最简单的那个 ActionBar 的 显示/隐藏 示例吗?首先,让 Activity 实现该接口:

public class ActionBarControlRecyclerViewActivityextends BaseActivityimplements ObservableScrollViewCallbacks ……
 

然后我们在 onCreate 中初始化 ObservableRecyclerView,并为它设置滚动监听:

ObservableRecyclerViewrecyclerView = (ObservableRecyclerView) findViewById(R.id.recycler);
        recyclerView.setLayoutManager(new LinearLayoutManager(this));
        recyclerView.setHasFixedSize(true);
        recyclerView.setScrollViewCallbacks(this);
 

设置好监听后,必须要实现以上三个方法,为了达到 ActionBar 的 显示/隐藏 效果,需要这样实现方法:

@Override
    public void onScrollChanged(int scrollY, boolean firstScroll, boolean dragging) {
    }
 
    @Override
    public void onDownMotionEvent() {
    }
 
    @Override
    public void onUpOrCancelMotionEvent(ScrollStatescrollState) {
        ActionBarab = getSupportActionBar();
        if (ab == null) {
            return;
        }
        if (scrollState == ScrollState.UP) {
            if (ab.isShowing()) {
                ab.hide();
            }
        } else if (scrollState == ScrollState.DOWN) {
            if (!ab.isShowing()) {
                ab.show();
            }
        }
    }
 

这样我们就完成了这个效果的实现。

注意,由于该效果的代码是放在 onUpOrCancelMotionEvent() 这个方法中,当你在滑动该页面的同时,并不会出现 显示/隐藏 的效果,而是要在手指抬起的瞬间才会产生相应的效果,如果你希望在滚动的同时产生 显示/隐藏 的效果,应该将实现该效果的代码放入 onScrollChanged() 方法中去。

ObservableRecyclerView 其他重要方法

下面我们来看一下 ObservableRecyclerView 中其他的重要的方法,其中有些是重写的父类方法,有些是自己定义的方法。

重写父类的方法有:

– onRestoreInstanceState(Parcelable state)

– onSaveInstanceState()

– onScrollChanged(int l, int t, int oldl, int oldt)

– onInterceptTouchEvent(MotionEvent ev)

– onTouchEvent(MotionEvent ev)

– getChildAdapterPosition(View child)

自己的方法:

– init()

– dispatchOnDownMotionEvent()

– dispatchOnScrollChanged(int scrollY, boolean firstScroll, boolean dragging)

– dispatchOnUpOrCancelMotionEvent(ScrollState scrollState)

– hasNoCallbacks()

以及一个内部类: SavedState

保存状态

onRestoreInstanceState()onSaveInstanceState() 以及内部类 SavedState 完成一些临时性的状态的保存工作,需要保存的属性如下:

private int mPrevFirstVisiblePosition;
private int mPrevFirstVisibleChildHeight = -1;
private int mPrevScrolledChildrenHeight;
private int mPrevScrollY;
private int mScrollY;
private SparseIntArraymChildrenHeights;
 

在某个时刻 Activity 因为系统回收资源的问题要被杀掉,通过 onSaveInstanceState 将有机会保存其用户界面状态,使得将来用户返回到 Activity 时能通过 onCreate(Bundle) 或者 onRestoreInstanceState(Bundle) 恢复界面的状态。

onScrollChanged

onScrollChanged(int l, int t, int oldl, int oldt)重写 View 中的方法,代码如下:

@Override
  protected void onScrollChanged(int l, int t, int oldl, int oldt) {
    super.onScrollChanged(l, t, oldl, oldt);
    if (hasNoCallbacks()) {
      return;
    }
    if (getChildCount() > 0) {
      int firstVisiblePosition = getChildAdapterPosition(getChildAt(0));
      int lastVisiblePosition = getChildAdapterPosition(getChildAt(getChildCount() - 1));
      for (int i = firstVisiblePosition, j = 0; i <= lastVisiblePosition; i++, j++) {
        int childHeight = 0;
        Viewchild = getChildAt(j);
        if (child != null) {
          if (mChildrenHeights.indexOfKey(i) < 0 || (child.getHeight() != mChildrenHeights.get(i))) {
            childHeight = child.getHeight();
          }
        }
        mChildrenHeights.put(i, childHeight);
      }
 
      ViewfirstVisibleChild = getChildAt(0);
      if (firstVisibleChild != null) {
        if (mPrevFirstVisiblePosition < firstVisiblePosition) {
          // 向下滑动
          int skippedChildrenHeight = 0;
          if (firstVisiblePosition - mPrevFirstVisiblePosition != 1) {
            for (int i = firstVisiblePosition - 1; i > mPrevFirstVisiblePosition; i--) {
              if (0 < mChildrenHeights.indexOfKey(i)) {
                skippedChildrenHeight += mChildrenHeights.get(i);
              } else {
                // 把每个 item 的高度近似为第一个可见子 View 的高度
                // 这样计算也许不正确,但如果不这样做,当从底部向上滑动时scrollY会出错
                skippedChildrenHeight += firstVisibleChild.getHeight();
              }
            }
          }
          mPrevScrolledChildrenHeight += mPrevFirstVisibleChildHeight + skippedChildrenHeight;
          mPrevFirstVisibleChildHeight = firstVisibleChild.getHeight();
        } else if (firstVisiblePosition < mPrevFirstVisiblePosition) {
          // 向上滑动
          int skippedChildrenHeight = 0;
          if (mPrevFirstVisiblePosition - firstVisiblePosition != 1) {
            for (int i = mPrevFirstVisiblePosition - 1; i > firstVisiblePosition; i--) {
              if (0 < mChildrenHeights.indexOfKey(i)) {
                skippedChildrenHeight += mChildrenHeights.get(i);
              } else {
                // 把每个 item 的高度近似为第一个可见子 View 的高度
                // 这样计算也许不正确,但如果不这样做,当从底部向上滑动时 scrollY 会出错
                skippedChildrenHeight += firstVisibleChild.getHeight();
              }
            }
          }
          mPrevScrolledChildrenHeight -= firstVisibleChild.getHeight() + skippedChildrenHeight;
          mPrevFirstVisibleChildHeight = firstVisibleChild.getHeight();
        } else if (firstVisiblePosition == 0) {
          mPrevFirstVisibleChildHeight = firstVisibleChild.getHeight();
          mPrevScrolledChildrenHeight = 0;
        }
        if (mPrevFirstVisibleChildHeight < 0) {
          mPrevFirstVisibleChildHeight = 0;
        }
        mScrollY = mPrevScrolledChildrenHeight - firstVisibleChild.getTop() + getPaddingTop();
        mPrevFirstVisiblePosition = firstVisiblePosition;
 
        dispatchOnScrollChanged(mScrollY, mFirstScroll, mDragging);
        if (mFirstScroll) {
          mFirstScroll = false;
        }
 
        if (mPrevScrollY < mScrollY) {
          //向下
          mScrollState = ScrollState.UP;
        } else if (mScrollY < mPrevScrollY) {
          //向上
          mScrollState = ScrollState.DOWN;
        } else {
          mScrollState = ScrollState.STOP;
        }
        mPrevScrollY = mScrollY;
      }
    }
  }
 

从上面的代码我们可以得知:

1、没有设置回调的话,直接返回,不会将触摸事件进行分发;

2、子布局的数量大于0的情况下,获取第一个子布局的位置 firstVisiblePosition 和最后一个子布局的位置 lastVisiblePosition

3、循环遍历所有子布局,当 SparseIntArray 容器中没有保存该子布局的高度,或者保存的值跟该子布局现在的高度不一致时,将该值存入容器中;

4、比较 mPrevFirstVisiblePositionfirstVisiblePosition 的差值,判断此时的滑动方向(是 UP 还是 DOWN ),分别求 mPrevScrolledChildrenHeightmPrevFirstVisibleChildHeight 的大小,最终求得 mScrollY 的值,调用 dispatchOnScrollChanged() ,让回调方法去实现最终的 onScrollChanged() 方法。

事件分发

onInterceptTouchEvent()onTouchEvent() 重写了 ViegGroup 中的方法,前者完成对触摸事件的拦截,如果检测到手指按下,则调用方法 dispatchOnDownMotionEvent() 。在方法 dispatchOnDownMotionEvent() 中,对事件进行处理,处理方式为:如果设置了回调,就调用回调方法中的 onDownMotionEvent() 方法(具体是什么操作需要用户自己在使用时实现),如果存放回调接口的容器不为零,将遍历容器中的每一个接口,调用每个接口的 onDownMotionEvent() 方法。

onTouchEvent() 方法中,如果检测到触摸事件被取消,则调用 dispatchOnUpOrCancelMotionEvent() 方法,跟上面的 dispatchOnDownMotionEvent() 方法类似,也会调用回调方法中的 onUpOrCancelMotionEvent() 方法,或者遍历容器。

而当检测到触摸事件为 MOVE 时,情况就复杂了许多,我们看代码:

            case MotionEvent.ACTION_MOVE:
        if (mPrevMoveEvent == null) {
          mPrevMoveEvent = ev;
        }
        float diffY = ev.getY() - mPrevMoveEvent.getY();
        mPrevMoveEvent = MotionEvent.obtainNoHistory(ev);
        if (getCurrentScrollY() - diffY <= 0) {
 
          if (mIntercepted) {
            return false;
          }
 
          final ViewGroupparent;
          if (mTouchInterceptionViewGroup == null) {
            parent = (ViewGroup) getParent();
          } else {
            parent = mTouchInterceptionViewGroup;
          }
 
          float offsetX = 0;
          float offsetY = 0;
          for (View v = this; v != null && v != parent; v = (View) v.getParent()) {
            offsetX += v.getLeft() - v.getScrollX();
            offsetY += v.getTop() - v.getScrollY();
          }
 
          final MotionEventevent = MotionEvent.obtainNoHistory(ev);
          event.offsetLocation(offsetX, offsetY);
 
          if (parent.onInterceptTouchEvent(event)) {
            mIntercepted = true;
 
            event.setAction(MotionEvent.ACTION_DOWN);
 
            post(new Runnable() {
              @Override
              public void run() {
                parent.dispatchTouchEvent(event);
              }
            });
            return false;
          }
          return super.onTouchEvent(ev);
        }
        break;
 

在该方法中, mTouchInterceptionViewGroup 将设置好的拦截 View 赋值给 parent ,如果没有设置,则自动赋值当前 View 的父类给 parent 。得到 parent 以后,就可以完成物理坐标向逻辑坐标的转换:

event.offsetLocation(offsetX, offsetY);
 

如果父类已经将该事件拦截,则返回 false,并且启动线程调用父类的 dispatchTouchEvent() 方法。

getChildAdapterPosition 和 init

最后, getChildAdapterPosition() 方法重写自 View 中的方法,根据 recyclerViewLibraryVersion 的值判断是调用 getChildAdapterPosition() 还是 getChildPosition() ,而 init() 方法在构造器中被调用,它新建一个SparseIntArray(比 HashMap 效率更高,可以提高性能),同时调用 checkLibraryVersion() 检查 RecyclerView 库的版本号。

ObservableRecyclerView 的源码分析到这里也就差不多了,通过阅读 ObservableRecyclerView 的源码,又学到了许多的知识,尤其是巩固了之前学的不是很明白的 View 的事件拦截和事件处理这部分的内容,让我受益匪浅。