手把手实现ScrollView+ViewPager+RecyclerView常规嵌套首页布局

3,243 阅读13分钟

前言

目前主流APP常见首页布局有顶部banner+列表、顶部banner+ViewPager等形式,如果有刷新需求,再通过外层嵌套SwipeRefreshLayout以实现刷新需求。若能掌握这些布局方式,就能应对大部分APP需求。

XML布局

布局用到系统控件,需要添加依赖:

implementation 'androidx.appcompat:appcompat:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-alpha03' implementation "androidx.viewpager2:viewpager2:1.0.0" implementation 'com.google.android.material:material:1.1.0-beta02'

一. SwipeRefreshLayout+顶部banner+RecyclerView

效果预览:

在这里插入图片描述

xml实现:

<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/swipe_refresh"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".NestedScrollViewActivity">

    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".NestedScrollViewActivity">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/banner_view"
                android:layout_width="match_parent"
                android:layout_height="300dp" />

            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/recycler_view"
                android:layout_width="match_parent"
                android:layout_height="match_parent" />
        </LinearLayout>
    </androidx.core.widget.NestedScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

使用androidx.swiperefreshlayout.widget.SwipeRefreshLayout、androidx.core.widget.NestedScrollView、androidx.recyclerview.widget.RecyclerView按照这种排列布局即可实现,不需要自定义view处理滑动冲突。so easy~

ps:RecyclerView初始化、adapter初始化、模拟数据填充等代码,可以参考NestedRecyclerViewActivity.java

二. SwipeRefreshLayout+顶部banner+ViewPager

效果预览:

在这里插入图片描述

xml实现:

<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/swipe_refresh"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".NestedScrollViewActivity">

    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".NestedScrollViewActivity">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/banner_view"
                android:layout_width="match_parent"
                android:layout_height="300dp" />

            <com.google.android.material.tabs.TabLayout
                android:id="@+id/tablayout"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"/>

            <androidx.viewpager2.widget.ViewPager2
                android:id="@id/viewpager"
                android:layout_width="match_parent"
                android:layout_height="match_parent"/>
        </LinearLayout>
    </androidx.core.widget.NestedScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

布局方式和上面类似,只是将RecyclerView替换成TabLayout和ViewPager2。 接下来运行,会发现......有点不对劲,滑动卡壳了。怎么回事,为什么同样是系统控件,TabLayout和ViewPager2就不支持嵌套滑动呢?

点开SwipeRefreshLayout和NestedScrollView源码,发现他们均实现了NestedScrollingParent2、NestedScrollingChild2接口,RecyclerView实现了NestedScrollingChild2接口。而TabLayout和ViewPager2没有实现NestedScrollingParent2、NestedScrollingChild2任一接口。

关于NestedScrollingParent2、NestedScrollingChild2接口

  • NestedScrollingParent2官方注释:

This interface should be implemented by {@link android.view.ViewGroup ViewGroup} subclasses that wish to support scrolling operations delegated by a nested child view. Classes implementing this interface should create a final instance of a {@link NestedScrollingParentHelper} as a field and delegate any View or ViewGroup methods to the NestedScrollingParentHelper methods of the same signature.

简单说就是希望支持嵌套滑动的父容器需要实现此接口,处理来自子视图传递的滑动事件。可以借助NestedScrollingParentHelper辅助类来执行相应方法。

  • NestedScrollingChild2官方注释:

This interface should be implemented by {@link View View} subclasses that wish to support dispatching nested scrolling operations to a cooperating parent {@link android.view.ViewGroup ViewGroup}. Classes implementing this interface should create a final instance of a {@link NestedScrollingChildHelper} as a field and delegate any View methods to the NestedScrollingChildHelper methods of the same signature.

简单说就是希望支持嵌套滑动的子视图需要实现此接口,优先将滚动事件向上传递给父容器处理。可以借助NestedScrollingChildHelper辅助类来执行相应方法。

  • NestedScrollingParent2接口方法说明:
/**
 * 子视图触发滑动时会回调该方法,父容器在该方法中根据子view、滑动方向、触摸类型等判断自己是否支持接收,
 * 若接收返回true,否则返回false。(可由NestedScrollingChild2的startNestedScroll方法触发)
 */
boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes, @NestedScrollType int type);

/**
 * onStartNestedScroll返回true后会回调该方法,可在此方法中做一些初始配置操作。
 */
void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes, @NestedScrollType int type);

/**
 * 开始滑动时,子视图会优先回调该方法。父容器可以处理自己的滚动操作,之后将剩余的滚动偏移量
 * 传回给子视图。(可由NestedScrollingChild2的dispatchNestedPreScroll方法触发)
 */
void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, @NestedScrollType int type);

/**
 * 子视图处理完剩余的滚动偏移量后,若还有剩余,则将剩余的滚动偏移量再通过该回调传给
 * 父容器处理。(可由NestedScrollingChild2的dispatchNestedScroll方法触发)
 */
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @NestedScrollType int type);

/**
 * 当滑动结束时,回调该方法。(可由NestedScrollingChild2的stopNestedScroll方法触发)
 */
void onStopNestedScroll(@NonNull View target, @NestedScrollType int type);

(ps:接口方法详细的说明可以查看源码注释或者百度谷歌。)

  • NestedScrollingChild2接口方法说明:
/**
 * 通知开始滑动,会回调父容器的onStartNestedScroll方法。
 */
boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type);

/**
 * 通知停止滑动,会回调父容器的onStopNestedScroll方法。
 */
void stopNestedScroll(@NestedScrollType int type);

/**
 * 查询是否有父容器支持指定类型的嵌套滑动。
 */
boolean hasNestedScrollingParent(@NestedScrollType int type);

/**
 * 在子视图处理滑动前,先将滚动偏移量传递给父容器。
 */
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, @NestedScrollType int type);

/**
 * 子视图处理滑动后,再将剩余的滚动偏移量传递给父容器。
 */
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, @NestedScrollType int type);

(ps:接口方法详细的说明可以查看源码注释或者百度谷歌。)

方法执行流程规范: child.startNestedScroll -> parent.onStartNestedScroll -> parent.onNestedScrollAccepted -> child.dispatchNestedPreScroll -> parent.onNestedPreScroll -> child.dispatchNestedScroll -> parent.onNestedScroll -> child.stopNestedScroll -> parent.onStopNestedScroll

简而言之,滑动产生时,由child主动通知,parent被动接收判断处理。这里的child和parent不必是直接父子关系,child会向上遍历parent。

部分参数含义说明:

  • child:表示包含target的当前容器的直接子view。
  • target:表示调用startNestedScroll触发onStartNestedScroll回调的那个子view。
  • axes:表示即将滑动的坐标轴方向,通过位运算求出方向。
  • type:表示触摸类型,有TYPE_TOUCH(用户触摸)、TYPE_NON_TOUCH(惯性滑动)两种类型。
  • dx:水平滑动偏移量。<0表示手指向右划,>0则相反。
  • dy:垂直滑动偏移量。<0表示手指向下划,>0则相反。
  • consumed:保存父容器滑动消耗的偏移量(索引0存x轴偏移,1存y轴偏移)。在父容器滑动后,子view会将原偏移量减去consumed中的值得到剩余偏移量,再进行自身的滚动处理。
  • dxConsumed:子view消耗的水平偏移量。
  • dyConsumed:子view消耗的垂直偏移量。
  • dxUnconsumed:子view滑动后还剩下的水平偏移量。
  • dyUnconsumed:子view滑动后还剩下的垂直偏移量。

注意:若有用户触摸滑动到惯性滑动,会走两遍方法执行流程,即不同type各触发一次流程。

因为TabLayout和ViewPager2不支持这种布局下的嵌套滑动,所以只能通过自定义view来处理滑动和事件分发。

滑动逻辑分析

首先将布局拆分成上下两部分(即父容器包含top_view和content_view两个子视图):

  • 当手指向上滑动时,若top_view仍然可见,则父容器需要进行滚动处理直至top_view不可见
  • 当手指向下滑动时,若top_view不完全可见(即之前向上滑动过),且content_view不可向下滑动(即content_view自身内容已经滑动至自身顶部),则父容器需要进行滚动处理直至top_view完全可见

代码实现

自定义父容器ComboScrollLayout

  1. ComboScrollLayout继承NestedScrollingParent2
public class ComboScrollLayout extends LinearLayout implements NestedScrollingParent2 {

    private View topView;
    private View contentView;

    private int topHeight;

    private NestedScrollingParentHelper parentHelper = new NestedScrollingParentHelper(this);
    
    @Override
    protected void onFinishInflate() {
    }
    
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    }
    
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    }
    
    @Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
    }
    
    @Override
    public int getNestedScrollAxes() {
    }
    
    @Override
    public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {
    }
    
    @Override
    public void onStopNestedScroll(@NonNull View target, int type) {
    }
    
    @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
    }
    
    @Override
    public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
    }
}
  1. 初始成员变量
public class ComboScrollLayout extends LinearLayout implements NestedScrollingParent2 {
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        // 获取top_view和content_view
        if (getChildCount() > 0) {
            topView = getChildAt(0);
        }
        if (getChildCount() > 1) {
            contentView = getChildAt(1);
        }
        if (topView == null || contentView == null) {
            throw new AndroidRuntimeException("容器中至少需要两个子view");
        }
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        // 获取top_view的高度
        if (topView != null) {
            topHeight = topView.getMeasuredHeight();
        }
    }
    
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 调整contentView的高度为父容器高度,使之填充布局,避免父容器滚动后出现空白
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        ViewGroup.LayoutParams lp = contentView.getLayoutParams();
        lp.height = getMeasuredHeight();
        contentView.setLayoutParams(lp);
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }
}
  1. 判断处理滑动的方向
public class ComboScrollLayout extends LinearLayout implements NestedScrollingParent2 {
    @Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
        if (contentView != null) {
            // 开始滚动前先停止滚动
            if (contentView instanceof RecyclerView) {
                ((RecyclerView) contentView).stopScroll();
            } else if (contentView instanceof NestedScrollView) {
                ((NestedScrollView) contentView).stopNestedScroll();
            } else if (contentView instanceof ViewPager2) {
                ((ViewPager2) contentView).stopNestedScroll();
            }
        }
        topView.stopNestedScroll();
        // 处理垂直方向的滑动
        boolean handled = (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
        return handled;
    }

    @Override
    public int getNestedScrollAxes() {
        return parentHelper.getNestedScrollAxes();
    }

    @Override
    public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {
        parentHelper.onNestedScrollAccepted(child, target, axes, type);
    }
}

ComboScrollLayout需要在onStartNestedScroll方法中判断即将开始滑动的方向是否是自己想要处理的。本例中判断若为垂直方向滚动就返回true,表示将接收处理垂直方向滑动事件。

onNestedScrollAccepted方法中,调用了NestedScrollingParentHelper辅助类的同样方法签名的方法,用以缓存前一步onStartNestedScroll方法中判断条件拦截的结果。

getNestedScrollAxes方法中,调用NestedScrollingParentHelper辅助类的同名方法,返回前一步onNestedScrollAccepted方法中缓存的进行拦截处理的坐标轴。

  1. 拦截滑动处理
public class ComboScrollLayout extends LinearLayout implements NestedScrollingParent2 {
    @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
        // 向上滑动。若当前topview可见,需要将topview滑动至不可见
        boolean hideTop = dy > 0 && getScrollY() < topHeight;
        // 向下滑动。若contentView滑动至顶,已不可再滑动,且当前topview未完全可见,则将topview滑动至完全可见
        boolean showTop = dy < 0 &&
                getScrollY() > 0 &&
                !ViewCompat.canScrollVertically(target, -1) &&
                !ViewCompat.canScrollVertically(contentView, -1);

        if (hideTop || showTop) {
            // 若需要滑动topview,则滑动dy偏移量
            scrollBy(0, dy);
            // 将ComboScrollLayout消耗的偏移量赋值给consumed数组
            consumed[1] = dy;
        }
    }

    @Override
    public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
        if (dyUnconsumed > 0) {
            if (target == topView) {
                // 由topView发起的向上滑动,继续让contentView滑动剩余的未消耗完的偏移量
                scrollBy(0, dyUnconsumed);
            }
        }
    }

    @Override
    public void scrollTo(int x, int y) {
        // 将ComboScrollLayout自身的滚动范围限制在0~topHeight(即在topview完全可见至完全不可见的范围内滑动)
        if (y < 0) {
            y = 0;
        }
        if (y > topHeight) {
            y = topHeight;
        }
        super.scrollTo(x, y);
    }
}

ComboScrollLayout拦截滑动处理是在onNestedPreScrollonNestedScroll回调方法中,其中onNestedPreScroll触发时机是在子view进行滑动之前,onNestedScroll是在子view滑动之后。

本例在onNestedPreScroll方法中,会判断当前是否需要由ComboScrollLayout进行滚动,若判断成立,则会消耗掉所有偏移量,子view将不再处理滚动,从而达到拦截的目的。

  1. 滑动停止
public class ComboScrollLayout extends LinearLayout implements NestedScrollingParent2 {
@Override
    public void onStopNestedScroll(@NonNull View target, int type) {
        parentHelper.onStopNestedScroll(target, type);
    }
}

当滑动结束时,将会触发onStopNestedScroll方法,可以做一些收尾工作。本例中委托给NestedScrollingParentHelper的同名方法。

  1. SwipeRefreshLayout冲突处理

若要支持下拉刷新,需要在ComboScrollLayout外套一层SwipeRefreshLayout,需要处理和SwipeRefreshLayout下拉的滑动冲突。

首先获取SwipeRefreshLayout引用:

public class ComboScrollLayout extends LinearLayout implements NestedScrollingParent2 {
    private SwipeRefreshLayout refreshLayout;
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        if (topView != null) {
            topHeight = topView.getMeasuredHeight();
        }
        // 获取外层SwipeRefreshLayout
        if (refreshLayout == null && getParent() != null && getParent() instanceof SwipeRefreshLayout) {
            refreshLayout = (SwipeRefreshLayout) getParent();
        }
    }
}

在滑动开始/结束时启用/禁用SwipeRefreshLayout:

public class ComboScrollLayout extends LinearLayout implements NestedScrollingParent2 {
	@Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
        // 省略部分代码
        ...
        
        boolean handled = (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
        // 若为垂直滚动方向,且topView未完全可见,应由ComboScrollLayout处理滑动,禁用SwipeRefreshLayout。
        if (handled && refreshLayout != null && getScrollY() != 0) {
            refreshLayout.setEnabled(false);
        }

        return handled;
    }
	
	@Override
    public void onStopNestedScroll(@NonNull View target, int type) {
        // 滑动结束,启用SwipeRefreshLayout
        if (refreshLayout != null) {
            refreshLayout.setEnabled(true);
        }
        parentHelper.onStopNestedScroll(target, type);
    }
}

至此完成了自定义容器编写,ComboScrollLayout实现了NestedScrollingParent2接口,从而能够响应处理子view滑动事件,优先消耗掉滑动事件。

(ps:完整源码见ComboScrollLayout.java

修改XML布局

<?xml version="1.0" encoding="utf-8"?>
<com.cdh.nestedscrolling.widget.ComboSwipeRefreshLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/swipe_refresh"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".NestedScrollViewActivity">

    <com.cdh.nestedscrolling.widget.ComboScrollLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@id/combo_top_view"
            android:layout_width="match_parent"
            android:layout_height="300dp"
            app:layout_constraintTop_toTopOf="parent"/>

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <com.google.android.material.tabs.TabLayout
                android:id="@+id/tablayout"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"/>

            <androidx.viewpager2.widget.ViewPager2
                android:id="@id/combo_content_view"
                android:layout_width="match_parent"
                android:layout_height="match_parent"/>
        </LinearLayout>

    </com.cdh.nestedscrolling.widget.ComboScrollLayout>
</com.cdh.nestedscrolling.widget.ComboSwipeRefreshLayout>

(ps:ComboSwipeRefreshLayout继承自SwipeRefreshLayout,重写了onInterceptTouchEvent方法,用于处理嵌套横向banner时的滑动冲突。完整源码可见ComboSwipeRefreshLayout.java

自定义子视图ComboChildLayout

父容器实现了NestedScrollingParent2接口,子view也需要实现NestedScrollingChild2接口,才能形成完整的交互。

  1. 定义关键成员变量
public class ComboChildLayout extends LinearLayout implements NestedScrollingChild2 {

	private int orientation;
    // touch滑动相关参数
    private int lastX = -1, lastY = -1;
    private final int[] offset = new int[2];
    private final int[] consumed = new int[2];

    // fling滑动相关参数
    private boolean isFling;
    private final int minFlingVelocity, maxFlingVelocity;
    private Scroller scroller;
    private VelocityTracker velocityTracker;
    private int lastFlingX, lastFlingY;
    private final int[] flingConsumed = new int[2];
    
	private NestedScrollingChildHelper childHelper = new NestedScrollingChildHelper(this);
    
	public ComboChildLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
		// 获取布局排布方向
        orientation = getOrientation();
        setNestedScrollingEnabled(true);
        // 获取当前页面配置信息
        ViewConfiguration config = ViewConfiguration.get(context);
        // 设置系统默认最小和最大加速度
        minFlingVelocity = config.getScaledMinimumFlingVelocity();
        maxFlingVelocity = config.getScaledMaximumFlingVelocity();
        scroller = new Scroller(context);
    }
}
  1. 继承NestedScrollingChild2
public class ComboChildLayout extends LinearLayout implements NestedScrollingChild2 {
	@Override
    public boolean startNestedScroll(int axes, int type) {
        return childHelper.startNestedScroll(axes, type);
    }

    @Override
    public void stopNestedScroll(int type) {
        childHelper.stopNestedScroll(type);
    }

    @Override
    public boolean hasNestedScrollingParent(int type) {
        return childHelper.hasNestedScrollingParent(type);
    }
    
	@Override
    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
        if (orientation == VERTICAL) {
            dxUnconsumed = 0;
        } else {
            dyUnconsumed = 0;
        }
        return childHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow, type);
    }

    @Override
    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, int type) {
        if (orientation == VERTICAL) {
            dx = 0;
        } else {
            dy = 0;
        }
        return childHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
    }

    @Override
    public void setNestedScrollingEnabled(boolean enabled) {
        childHelper.setNestedScrollingEnabled(enabled);
    }

    @Override
    public boolean isNestedScrollingEnabled() {
        return childHelper.isNestedScrollingEnabled();
    }
}

这些方法中全部委托给NestedScrollingChildHelper辅助类相应的同名方法执行。

  1. 重写onTouchEvent分发事件
public class ComboChildLayout extends LinearLayout implements NestedScrollingChild2 {
    @Override
    public boolean onTouchEvent(MotionEvent event) {
    	// 重置fling相关参数
        cancelFling();
        // 获取VelocityTracker,用于加速度计算
        if (velocityTracker == null) {
            velocityTracker = VelocityTracker.obtain();
        }
        // 追踪触摸点移动加速度
        velocityTracker.addMovement(event);

        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                // 初始化值
                consumed[0] = 0;
                consumed[1] = 0;
                offset[0] = 0;
                offset[1] = 0;
                lastX = (int) event.getX();
                lastY = (int) event.getY();
                // 调用startNestedScroll通知parent根据滑动方向和滑动类型进行启用嵌套滑动,
                // 当前属于用户触摸滑动,type传TYPE_TOUCH。
                if (orientation == VERTICAL) {
                	// 垂直方向滑动
                    startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
                } else {
                	// 水平方向滑动
                    startNestedScroll(ViewCompat.SCROLL_AXIS_HORIZONTAL, ViewCompat.TYPE_TOUCH);
                }
                break;

            case MotionEvent.ACTION_MOVE:
                int curX = (int) event.getX();
                int curY = (int) event.getY();
                // 计算滑动偏移量,起始坐标-当前坐标
                int dx = lastX - curX;
                int dy = lastY - curY;

                // 优先将滑动偏移量交由parent处理,
                if (dispatchNestedPreScroll(dx, dy, consumed, offset, ViewCompat.TYPE_TOUCH)) {
                    // parent滑动完后
                    // 滑动偏移量减去parent消耗的量
                    dx -= consumed[0];
                    dy -= consumed[1];
                }

                // 用于记录子view自身滑动消耗的偏移量
                int consumedX = 0;
                int consumedY = 0;
                // 自身或child进行滑动
                if (orientation == VERTICAL) {
                    consumedY = childConsumedY(dy);
                } else {
                    consumedX = childConsumedX(dx);
                }

                // 滑动偏移量减去自身或child消耗的量,然后再交由parent处理
                dispatchNestedScroll(consumedX, consumedY, dx-consumedX, dy-consumedY, null, ViewCompat.TYPE_TOUCH);

                lastX = curX;
                lastY = curY;
                break;

            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                // 通知parent滑动结束
                stopNestedScroll(ViewCompat.TYPE_TOUCH);

                if (velocityTracker != null) {
                    // 计算触摸点加速度
                    velocityTracker.computeCurrentVelocity(1000, maxFlingVelocity);
                    // 获取xy轴加速度
                    int vx = (int) velocityTracker.getXVelocity();
                    int vy = (int) velocityTracker.getYVelocity();
                    // 进行fling
                    fling(vx, vy);
                    velocityTracker.clear();
                }

                lastX = -1;
                lastY = -1;
                break;

            default:
                break;
        }

        return true;
    }
}

在onTouchEvent方法中,首先在DOWN时通过通知parent对滑动进行判断响应。之后在ACTION_MOVE过程中,计算滑动偏移量,优先交由parent进行消耗处理,若有parent接收处理,则在parent滑动后,减去parent消耗的偏移量,在交给自身或子view进行剩余偏移量的滑动。若自身或子view滑动后还有剩余的偏移量,则再交由parent处理。最后在UP/CANCEL通知parent滑动结束。

(ps:在本例中,UP/CANCEL后有进行fling操作,在fling中会再触发startNestedScroll到stopNestedScroll的过程,不同的是传递的type变为TYPE_NON_TOUCH。)

自定义子视图完整源码可见ComboChildLayout.java

  1. ComboChildLayout使用场景 ComboChildLayout不是用来包装ViewPager2,而是当ViewPager2有某一页为普通view时,用来包裹该页的根布局。 本例在ViewPager2中添加了三个Fragment,分别演示ViewPager2下为普通线性布局、滚动布局、列表布局的情况。其中普通线性布局需要使用ComboChildLayout进行包裹,而滚动布局和列表布局因为使用NestedScrollView和RecyclerView,其自身实现了NestedScrollingChild2接口,所以不需要额外操作。

完整项目地址

github:ComboScrollLayout