Android嵌套滚动机制:从Recyclerview的onTouchEvent出发!

204 阅读7分钟

Recyclerview(v) -> (ViewPager2(h) -> LinearLayout(v)) -> NestScrollView(v)为例

为什么从Recyclerview出发呢?因为在嵌套滚动机制中,是由支持嵌套滚动的子View控制的,先看一段代码,Recyclerview默认是支持嵌套滚动

public RecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
	boolean nestedScrollingEnabled = true;
    // ……是否更改nestedScrollingEnabled的值,默认不修改 -> true
    setNestedScrollingEnabled(nestedScrollingEnabled);
}

Recyclerview#onTouchEvent()

Down

Down事件开始就在向上查找和当前子View支持滚动方向一致的父View了 -> vertical

public boolean onTouchEvent(MotionEvent e) {
	switch (action) {
        case MotionEvent.ACTION_DOWN: {
            mScrollPointerId = e.getPointerId(0);
            mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
            mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);
            // 可滚动方向:RecyclerView -> LayoutMager -> 此处以竖向为例 -> 0010
            int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE; // 0000
            if (canScrollHorizontally) {
                nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL; // 0001
            }
            if (canScrollVertically) {
                nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL; // 0010
            }
            startNestedScroll(nestedScrollAxis, TYPE_TOUCH); //看这里!看这里!看这里!
        } break;
}

开始阶段

  • Recyclerview#startNestedScroll()
    return getScrollingChildHelper().startNestedScroll(axes, type);
    
查找
  • NestedScrollingChildHelper#startNestedScroll()

    在这个方法中向上查找,注意:1.支持嵌套滚动 2.方向一致

    public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
        // 判断是否已经找到了处理滚动事件的父View:return mNestedScrollingParentTouch != null
        if (hasNestedScrollingParent(type)) { 
            // Already in progress
            return true;
        }
    
        if (isNestedScrollingEnabled()) {
            ViewParent p = mView.getParent();
            View child = mView;
    
            // 向上查找到支持嵌套滚动的父View:RecyclerView -> ViewPager2 -> LinearLayout -> NestedScrollView
            while (p != null) {  
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                    // 设置处理滚动事件的父View:mNestedScrollingParentTouch = p;
                    setNestedScrollingParentForType(type, p); 
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }
    
标记
  • ViewParentCompat#onStartNestedScroll()

    不同接口实现的兼容处理(Compat),后面会逐渐省略这部分

    NestedScrollView接口NestedScrollingParent3NestedScrollingParent3继承自NestedScrollingParent2,由NestedScrollView实现。其实是

    public static boolean onStartNestedScroll(@NonNull ViewParent parent, @NonNull View child,
            @NonNull View target, int nestedScrollAxes, int type) {
        if (parent instanceof NestedScrollingParent2) {
            // First try the NestedScrollingParent2 API
            return ((NestedScrollingParent2) parent).onStartNestedScroll(child, target,     
                    nestedScrollAxes, type);
        } else if (type == ViewCompat.TYPE_TOUCH) {
            // Else if the type is the default (touch), try the NestedScrollingParent API
            if (Build.VERSION.SDK_INT >= 21) {
                try {
                    return Api21Impl.onStartNestedScroll(parent, child, target, nestedScrollAxes);
                } catch (AbstractMethodError e) {
                    Log.e(TAG, "ViewParent " + parent + " does not implement interface "
                            + "method onStartNestedScroll", e);
                }
            } else if (parent instanceof NestedScrollingParent) {
                return ((NestedScrollingParent) parent).onStartNestedScroll(child, target,
                        nestedScrollAxes);
            }
        }
        return false;
    }
    
    • (NestedScrollingParent2)NestedScrollView#onStartNestedScroll()

      看咱滚的方向一致不一致,一致就你了

      @Override
      public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes,
              int type) {
          // 纵向:SCROLL_AXIS_VERTICAL = 1 << 1 = 2 横向:1。本例中 0010 & 0010 = 0010 = 2 != 0
          return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; 
      }
      
  • ViewParentCompat#onNestedScrollAccepted()

    后面好多这样的ViewCompat兼容处理,之后就省略掉了

    public static void onNestedScrollAccepted(@NonNull ViewParent parent, @NonNull View child,
            @NonNull View target, int nestedScrollAxes, int type) {
        if (parent instanceof NestedScrollingParent2) {
            // First try the NestedScrollingParent2 API
            ((NestedScrollingParent2) parent).onNestedScrollAccepted(child, target,
                    nestedScrollAxes, type);
        } 
        //……省略其它兼容处理(parent instanceof NestedScrollingParent)
    }
    
    • (NestedScrollingParent2)NestedScrollView#onNestedScrollAccepted()

      NestedScrollView可以接收竖向滚动事件,实现类NestedScrollingParentHelper记录滚动方向

      @Override
      public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes,
              int type) {
          mParentHelper.onNestedScrollAccepted(child, target, axes, type); // 标记可滚动方向
          startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, type); // return true
      }
      

Move

Move事件中处理嵌套滚动的2种情况(if中的方法):1.子View消费前 2.子View消费后

public boolean onTouchEvent(MotionEvent e) {
	switch (action) {
        case MotionEvent.ACTION_MOVE: {
            if (mScrollState == SCROLL_STATE_DRAGGING) {
                mReusableIntPair[0] = 0;
                mReusableIntPair[1] = 0;
                
                // 这里!这里!这里!1.子View消费前 -> 父View消费滚动事件
                if (dispatchNestedPreScroll(
                        canScrollHorizontally ? dx : 0,
                        canScrollVertically ? dy : 0,
                        mReusableIntPair, mScrollOffset, TYPE_TOUCH
                )) {
                    // 根据父View消费的多少consumed[]计算还剩多少dx、dy
                    dx -= mReusableIntPair[0]; 
                    dy -= mReusableIntPair[1];
                    // Updated the nested offsets
                    mNestedOffsets[0] += mScrollOffset[0];
                    mNestedOffsets[1] += mScrollOffset[1];
                    // Scroll has initiated, prevent parents from intercepting
                    getParent().requestDisallowInterceptTouchEvent(true);
                }

                mLastTouchX = x - mScrollOffset[0];
                mLastTouchY = y - mScrollOffset[1];
                
                // 还有这里!这里!2.先子View消费 -> 询问父View是否消费剩余部分
                if (scrollByInternal(
                        canScrollHorizontally ? dx : 0,
                        canScrollVertically ? dy : 0,
                        e)) { // -> dispatchNestedScroll()
                    getParent().requestDisallowInterceptTouchEvent(true);
                }
                if (mGapWorker != null && (dx != 0 || dy != 0)) {
                    mGapWorker.postFromTraversal(this, dx, dy);
                }
            }
        } break;
}

滚动阶段

阶段一:子View消费前
  • NestedScrollingChildHelper#dispatchNestedPreScroll()

    子View将Move事件分发给Down事件中找到的parent

    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, @NestedScrollType int type) {
        if (isNestedScrollingEnabled()) { // 判断是否可以滚动
            final ViewParent parent = getNestedScrollingParentForType(type); // parent = NestedScrollView
            if (parent == null) {
                return false;
            }
    
            // 有滚动
            if (dx != 0 || dy != 0) {
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow); // 获取当前View在父View中的坐标
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }
    
                if (consumed == null) {
                    consumed = getTempNestedScrollConsumed();
                }
                consumed[0] = 0;
                consumed[1] = 0;
    
                // 重写下面的方法处理滚动事件
                ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type); 
    
                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                return consumed[0] != 0 || consumed[1] != 0;
            // 无滚动
            } else if (offsetInWindow != null) {
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }
    
    • NestScrollView#onNestedPreScroll()

      重写这个方法处理滚动逻辑,不重写的话,为啥我感觉会有问题呢😂

      @Override
      public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
              int type) {
          dispatchNestedPreScroll(dx, dy, consumed, null, type); 
      }
      
      @Override
      public boolean dispatchNestedPreScroll(
              int dx,
              int dy,
              @Nullable int[] consumed,
              @Nullable int[] offsetInWindow,
              int type
      ) {
          return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
      }
      
阶段二:子View消费后

Recyclerview#scrollByInternal()

这一段是Recyclerview先进行消费,剩余的部分再交给NestedScrollView消费

boolean scrollByInternal(int x, int y, MotionEvent ev) {
    int unconsumedX = 0;
    int unconsumedY = 0;
    int consumedX = 0;
    int consumedY = 0;

    consumePendingUpdateOperations();
    // 子View(RecyclerView)消费滚动事件
    if (mAdapter != null) {
        mReusableIntPair[0] = 0;
        mReusableIntPair[1] = 0;
        scrollStep(x, y, mReusableIntPair);
        consumedX = mReusableIntPair[0];
        consumedY = mReusableIntPair[1];
        unconsumedX = x - consumedX;
        unconsumedY = y - consumedY;
    }
    if (!mItemDecorations.isEmpty()) {
        invalidate();
    }

    mReusableIntPair[0] = 0;
    mReusableIntPair[1] = 0;
    // 子View再次询问父View是否要消费滚动事件
    dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
            TYPE_TOUCH, mReusableIntPair);
    unconsumedX -= mReusableIntPair[0];
    unconsumedY -= mReusableIntPair[1];
    boolean consumedNestedScroll = mReusableIntPair[0] != 0 || mReusableIntPair[1] != 0;

    // Update the last touch co-ords, taking any scroll offset into account
    mLastTouchX -= mScrollOffset[0];
    mLastTouchY -= mScrollOffset[1];
    mNestedOffsets[0] += mScrollOffset[0];
    mNestedOffsets[1] += mScrollOffset[1];

    // 处理过度滚动效果
    if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
        if (ev != null && !MotionEventCompat.isFromSource(ev, InputDevice.SOURCE_MOUSE)) {
            pullGlows(ev.getX(), unconsumedX, ev.getY(), unconsumedY);
        }
        considerReleasingGlowsOnScroll(x, y);
    }
    
    // 分发滚动事件给相应的监听器 -> 同步滑动效果实现
    if (consumedX != 0 || consumedY != 0) {
        dispatchOnScrolled(consumedX, consumedY);
    }
    if (!awakenScrollBars()) {
        invalidate();
    }
    return consumedNestedScroll || consumedX != 0 || consumedY != 0;
}
  • NestedScrollingChildHelper#dispatchNestedScroll()

    这块除了部分判断条件不同,和阶段一dispatchNestedPreScroll()的实现大体一致

    public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
            int dyUnconsumed, @Nullable int[] offsetInWindow, @NestedScrollType int type,
            @Nullable int[] consumed) {
        dispatchNestedScrollInternal(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                offsetInWindow, type, consumed);
    }
    // 实现和预滚动分发区别不大
    private boolean dispatchNestedScrollInternal(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
            @NestedScrollType int type, @Nullable int[] consumed) {
        if (isNestedScrollingEnabled()) {
            final ViewParent parent = getNestedScrollingParentForType(type);
            if (parent == null) {
                return false;
            }
    
            if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }
    
                if (consumed == null) {
                    consumed = getTempNestedScrollConsumed();
                    consumed[0] = 0;
                    consumed[1] = 0;
                }
                
                // 还是重写这个方法,看!是不是和阶段一差不多
                ViewParentCompat.onNestedScroll(parent, mView,
                        dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed);
    
                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                return true;
            } else if (offsetInWindow != null) {
                // No motion, no dispatch. Keep offsetInWindow up to date.
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }
    
    • NestScrollView#onNestedScroll()

      这里NestScrollView提供了部分实现

      public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
              int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
          onNestedScrollInternal(dyUnconsumed, type, consumed);
      }
      
      private void onNestedScrollInternal(int dyUnconsumed, int type, @Nullable int[] consumed) {
          final int oldScrollY = getScrollY();
          scrollBy(0, dyUnconsumed); // 消费没有被消费掉的滚动距离
          final int myConsumed = getScrollY() - oldScrollY;
      
          if (consumed != null) {
              consumed[1] += myConsumed;
          }
          final int myUnconsumed = dyUnconsumed - myConsumed;
      
          mChildHelper.dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed);
      }
      
总结

滚动阶段需要重写的方法:onNestedPreScroll()onNestedScroll()

Up

public boolean onTouchEvent(MotionEvent e) {
    switch (action) {
        case MotionEvent.ACTION_UP: {
            mVelocityTracker.addMovement(vtev);
            eventAddedToVelocityTracker = true;
            mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
            final float xvel = canScrollHorizontally
                    ? -mVelocityTracker.getXVelocity(mScrollPointerId) : 0;
            final float yvel = canScrollVertically
                    ? -mVelocityTracker.getYVelocity(mScrollPointerId) : 0;
            // 看这里!看这里!看这里! -> fling()
            if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) { // 惯性滑动`fling`
                setScrollState(SCROLL_STATE_IDLE);
            }
            resetScroll(); // 还有这里!这里!结束阶段 -> stopNestedScroll(TYPE_TOUCH)
        } break;
    }
}

惯性阶段

这一块建议多想一想,真的很绕😟,其实有点没搞明白,希望能有位大佬指点迷津(☆▽☆)

Recyclerview#fling()

public boolean fling(int velocityX, int velocityY) {
    // ……
    if (!dispatchNestedPreFling(velocityX, velocityY)) { 
        // 父View没有消费(完)惯性滑动距离velocityY -> false
        final boolean canScroll = canScrollHorizontal || canScrollVertical; // Recyclerview的可滚动方向
        dispatchNestedFling(velocityX, velocityY, canScroll); // 通知NestedScrollView 惯性滑动距离和方向
        
        // 如果有监听器 -> 监听器处理
        if (mOnFlingListener != null && mOnFlingListener.onFling(velocityX, velocityY)) {
            return true;
        }

        if (canScroll) {
            int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
            if (canScrollHorizontal) {
                nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
            }
            if (canScrollVertical) {
                nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
            }
            startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH); // 再次启用嵌套滚动

            velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
            velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
            mViewFlinger.fling(velocityX, velocityY); // 启动惯性滚动,当作给了Recyclerview一组`Move`
            return true;
        }
    }
    // 父View消费完惯性滑动距离velocityY -> true
    return false;
}
情况一:在子View前响应
  • NestedScrollingChildHelper#dispatchNestedPreFling()
    public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
        if (isNestedScrollingEnabled()) {
            ViewParent parent = getNestedScrollingParentForType(TYPE_TOUCH);
            if (parent != null) {
                return ViewParentCompat.onNestedPreFling(parent, mView, velocityX,
                        velocityY);
            }
        }
        return false;
    }
    
    • NestedScrollView#onNestedPreFling()
      @Override // 重写此方法
      public boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY) {
          return dispatchNestedPreFling(velocityX, velocityY);
      }
      
      @Override
      public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
          return mChildHelper.dispatchNestedPreFling(velocityX, velocityY);
      }
      
情况二:在子View后响应
  • NestedScrollingChildHelper#dispatchNestedFling()
    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
        if (isNestedScrollingEnabled()) {
            ViewParent parent = getNestedScrollingParentForType(TYPE_TOUCH);
            if (parent != null) {
                return ViewParentCompat.onNestedFling(parent, mView, velocityX,
                        velocityY, consumed);
            }
        }
        return false;
    }
    
    • NestedScrollView#onNestedFling()
      public boolean onNestedFling(
              @NonNull View target, float velocityX, float velocityY, boolean consumed) {
          if (!consumed) {
              dispatchNestedFling(0, velocityY, true);
              fling((int) velocityY);
              return true;
          }
          return false;
      }
      

结束阶段

  • Recyclerview#resetScroll()
    private void resetScroll() {
        if (mVelocityTracker != null) {
            mVelocityTracker.clear();
        }
        stopNestedScroll(TYPE_TOUCH); // getScrollingChildHelper().stopNestedScroll(type);
        releaseGlows();
    }
    
  • NestedScrollingChildHelper#stopNestedScroll()

    还记得在开始阶段做了什么嘛?1.parent = p 2.实现类NestedScrollingParentHelper记录了滚动方向

    public void stopNestedScroll(@NestedScrollType int type) {
        ViewParent parent = getNestedScrollingParentForType(type);
        if (parent != null) {
            ViewParentCompat.onStopNestedScroll(parent, mView, type);
            setNestedScrollingParentForType(type, null);  // parent = null 看这里!
        }
    }
    
    • NestedScrollView#onStopNestedScroll()
      @Override
      public void onStopNestedScroll(@NonNull View target, int type) {
          mParentHelper.onStopNestedScroll(target, type);
          stopNestedScroll(type); // mChildHelper.stopNestedScroll(type); 就上面那个
      }
      
    • NestedScrollingParentHelper#onStopNestedScroll()

      这里相当于清掉了开始阶段记录的滚动方向

      public void onStopNestedScroll(@NonNull View target, @NestedScrollType int type) {
          if (type == ViewCompat.TYPE_NON_TOUCH) {
              mNestedScrollAxesNonTouch = ViewGroup.SCROLL_AXIS_NONE;
          } else {
              mNestedScrollAxesTouch = ViewGroup.SCROLL_AXIS_NONE;
          }
      }
      

嵌套滑动机制流程图(自绘)

image.png

关于滚动和惯性

可以在手机APP:哔哩哔哩视频暂停或者放完、小红书个人主页、米游社首页来回滑动感受一下

简单的嵌套实现

github.com/userbiliy/N… 自己写了一点,并不完善,快速滚动惯性效果会出问题