图解Android嵌套滑动一|NestedScrollingParent和Child

文章内容如有错误欢迎探讨指正!

概述

在传统的事件分发机制中,通常来说父控件和子控件们只会有一个控件去处理事件流,但我们时常会有一些特殊的需求,比如说多个控件之间的滑动需要联动起来,这时候就需要用到嵌套滑动机制了。

NestedScrolling 是 Android 5.0 推出的嵌套滑动机制,它可以指定在一个滑动事件流中,父控件和子控件分别消费多少。比如,在一个顶部为 HeaderView,下方是 RecyclerView 列表的布局中,如果手指在 RecyclerView 上产生了向上的滑动事件,我们可以让 HeaderView 先消费滑动事件(比如把 HeaderView 的高度从 100 缩小到 40),然后列表再向上滑动。即 HeaderView 消费 60 的滑动事件,剩下的再交给列表处理。

NestedScrolling 机制是在原有的事件分发基础上,在 View 和 ViewGroup 的触摸/滑动过程中新增了一系列方法调用,达到嵌套滑动的效果,本质上还是依托 Android View 事件分发机制的。

一提到嵌套滑动,我们可能会想到 CoordinatorLayout, AppBarLayout, Behavior 这些组件,在分析这些组件是怎么实现嵌套效果前,先来看看嵌套滑动的两个核心接口:NestedScrollingParent 和 NestedScrollingChild。掌握这俩组件后,即使对 CoordinatorLayout, AppBarLayout, Behavior 这些不了解,也足够处理嵌套滑动的场景了。

嵌套滑动的逻辑流程

首先看一下父控件(NestedScrollingParent) 和子控件(NestedScrollingChild) 之间嵌套滑动的流程:

  1. 事件分发时,父控件不拦截,由子控件处理;
  2. 子控件开始滑动前(收到 DOWN 事件),询问父控件是否要配合嵌套滑动,如果父控件返回不配合,则不会继续下面的步骤,否则继续;
  3. 子控件接收到 MOVE 事件后,先把滑动信息传给父控件,父控件消费部分/全部滑动距离,并通知子控件它消费的滑动距离;
  4. 子控件处理剩下的滑动距离,它消费全部/部分剩下的滑动距离后,把还剩下的滑动距离传给父控件处理;
  5. 如果子控件在滑动过程中还发生了惯性滑动,就先把惯性速度信息传给父控件,父控件可以选择消费/不消费惯性滑动,并告诉子控件它的消费结果;
  6. 如果父控件没有消费惯性事件,则子控件来决定消不消费,并把这个消费结果再次传给父控件,父控件根据需要返回消费结果。
  7. 触摸/Fling 事件结束后,通知父控件嵌套滑动流程结束。

流程图如下:

通过上述嵌套滑动机制,在一次滑动操作过程中父控件和子控件都可以对滑动事件作出响应。

NestedScrollingParent&NestedScrollingChild

接下来我们看看上面嵌套滑动流程图中,NestedScrollingParent 和 NestedScrollingChild 角色相关的方法,它们是 NestedScrolling 机制的基础。在 Android 中提供了一系列实现了这俩接口的控件:

  1. NestedScrollingParent: 如 CoordinatorLayout,NestedScrollView 等,此接口应由希望支持嵌套滑动事件的父控件实现,此外还提供了 NestedScrollingParent2 和 NestedScrollingParent3 扩展功能的接口。
  2. NestedScrollingChild: 如 RecyclerView,NestedScrollView 等,此接口应由希望支持嵌套滑动事件的子控件实现,此外还提供了 NestedScrollingChild2 和 NestedScrollingChild3 扩展功能的接口。

既然子 View 控件是嵌套滑动事件分发的起点,那么下面我们就从 NestedScrollingChild 接口看起。

嵌套滑动事件为什么是从子 View 开始的?从交互上来看,滑动事件是由用户的手势触发的,而这些手势最先作用在子 View 上。因此,子 View 先感知到用户的滑动需求,进而决定是自己处理还是分发该事件。

NestedScrollingChild

Java 复制代码
public interface NestedScrollingChild {
    void setNestedScrollingEnabled(boolean enabled);
    boolean isNestedScrollingEnabled();
    boolean startNestedScroll(@ScrollAxis int axes);
    void stopNestedScroll();
    boolean hasNestedScrollingParent();
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow);
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}

public interface NestedScrollingChild2 extends NestedScrollingChild {
    boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type);
    void stopNestedScroll(@NestedScrollType int type);
    boolean hasNestedScrollingParent(@NestedScrollType int type);
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, @NestedScrollType int type);
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, @NestedScrollType int type);
}

public interface NestedScrollingChild3 extends NestedScrollingChild2 {
    void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, @ViewCompat.NestedScrollType int type, @NonNull int[] consumed);
}

主要方法如下:

  • setNestedScrollingEnabled 和 isNestedScrollingEnabled: 一对 get/set 方法,用来设置/判断子控件是否支持嵌套滑动。
  • startNestedScroll: 嵌套滑动起始方法,找到接收滑动信息的父控件,返回值表示父控件是否接受嵌套滑动流程。
  • stopNestedScroll: 嵌套滑动结束方法,清空嵌套滑动相关的状态。
  • dispatchNestedPreScroll: 在子控件消费滑动事件前把滑动信息分发给父控件。
  • dispatchNestedScroll: 在子控件消费滑动事件后把剩下的滑动距离信息分发给父控件。
  • dispatchNestedPreFling 和 dispatchNestedFling: 跟 Scroll 对应方法作用类似,不过分发的不是滑动信息而是 Fling 信息(惯性滑动)。

NestedScrollingChild2 和 NestedScrollingChild3 在 NestedScrollingChild 基础上有所扩展,比如新增了 type 类型参数,表示滑动的类型,取值有 TYPE_TOUCH 和 TYPE_NON_TOUCH:

  • TYPE_TOUCH: 输入类型来自用户触摸屏幕。
  • TYPE_NON_TOUCH: 输入类型不是由用户触摸屏幕引起的,一般来自 Fling 动作(惯性)。

NestedScrollingParent

less 复制代码
public interface NestedScrollingParent {
    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
    void onStopNestedScroll(@NonNull View target);
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed);
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);
    boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
    boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);
    int getNestedScrollAxes();
}

public interface NestedScrollingParent2 extends NestedScrollingParent {
    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes, @NestedScrollType int type);
    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes, @NestedScrollType int type);
    void onStopNestedScroll(@NonNull View target, @NestedScrollType int type);
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @NestedScrollType int type);
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, @NestedScrollType int type);
}

public interface NestedScrollingParent3 extends NestedScrollingParent2 {
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @ViewCompat.NestedScrollType int type, @NonNull int[] consumed);
}

父控件的大部分方法都是被子控件的对应方法回调的:

  • onStartNestedScroll: 子控件调用 startNestedScroll 方法时,会找到接收滑动信息的父控件,然后调用父控件的这个方法来确定父控件是否接收滑动信息,返回值表示父控件是否接受嵌套滑动流程。这个方法通常会根据 axes 来判断是垂直还是水平方向,进而返回 true 或 false。
  • onStopNestedScroll: 子控件调用 stopNestedScroll 方法时,会调用到父控件的这个方法,用来做一些收尾工作。
  • onNestedScrollAccepted: 当父控件确定接受嵌套滑动流程后该方法会被回调,可以让父控件针对嵌套滑动做一些前期工作。
  • onNestedPreScroll: 嵌套滑动的关键方法,子控件通过 dispatchNestedPreScroll 分发滑动信息后,该方法被调用,它用来接收子控件处理滑动前的滑动距离信息,在这里父控件可以优先响应滑动操作,可以根据需要来消费滑动距离,然后通过 consumed 参数把它消费的距离传回给子控件。
  • onNestedScroll: 子控件调用 dispatchNestedScroll 后,该方法被调用,它用来接收子控件处理完滑动后的滑动距离信息,父控件可以在这个方法里消费剩余的滑动距离。
  • getNestedScrollAxes: 返回嵌套滑动的方向,横向或竖向滑动。
  • onNestedPreFling 和 onNestedFling: 同上。

父控件通过 onNestedPreScroll 和 onNestedScroll 来接收子控件响应滑动前后的滑动距离信息,这两个方法是实现嵌套滑动效果的关键方法。

解释一下上面方法里的 child 和 target 分别是啥(在下文会通过源码解析其传值):

  • target: 当前的 NestedScrollingChild 控件;
  • child: 包含 target 的 NestedScrollingParent 的直接子控件,所以 child 可能就是 target,也可能不是。

与 NestedScrollingChild 类似,NestedScrollingParent2 和 NestedScrollingParent3 也是对 NestedScrollingParent 功能的扩展,比如新增了 type 参数。

嵌套滑动的接口流程

结合具体的接口方法,看看上面的流程图:

需要注意的是上面的 NestedScrollingChild 和 NestedScrollingParent 都只是接口,具体的分发逻辑需要自己实现,但官方提供了两个实现了相关逻辑的帮助类:NestedScrollingChildHelper 和 NestedScrollingParentHelper。通过调用这两个 Helper 类,可以实现这套嵌套联动逻辑

NestedScrollingChildHelper

NestedScrollingChildHelper 对 NestedScrollingChild 接口的相关方法做了一个实现,一般来说,子控件可以直接调用 NestedScrollingChildHelper 内部的方法,来实现嵌套滑动事件的分发。

Java 复制代码
public class NestedScrollingChildHelper {
    public void setNestedScrollingEnabled(boolean enabled) {
        // ...
    }
    public boolean isNestedScrollingEnabled() {
        // ...
    }
    public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
        // ...
    }
    public void stopNestedScroll(@NestedScrollType int type) {
        // ...
    }
    public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
            int dyUnconsumed, @Nullable int[] offsetInWindow, @NestedScrollType int type,
            @Nullable int[] consumed) {
        // ...
    }
    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, @NestedScrollType int type) {
        // ...
    }
    // ...
}

构造方法

Java 复制代码
public NestedScrollingChildHelper(@NonNull View view) {
    mView = view;
}

创建 NestedScrollingChildHelper 实例需要传入一个 View,这个 View 便是嵌套滑动的子控件。

setNestedScrollingEnabled

Java 复制代码
public void setNestedScrollingEnabled(boolean enabled) {
    if (mIsNestedScrollingEnabled) {
        ViewCompat.stopNestedScroll(mView);
    }
    mIsNestedScrollingEnabled = enabled;
}

public boolean isNestedScrollingEnabled() {
    return mIsNestedScrollingEnabled;
}

用来设置当前子控件是否要支持嵌套滑动。

startNestedScroll

Java 复制代码
public boolean startNestedScroll(@ScrollAxis int axes) {
    return startNestedScroll(axes, TYPE_TOUCH);
}

public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
    if (hasNestedScrollingParent(type)) {
        // 如果没有配合处理的 NestedScrollingParent,则直接返回
        return true;
    }
    // 如果当前控件开启了嵌套滑动支持,才会继续
    if (isNestedScrollingEnabled()) {
        ViewParent p = mView.getParent();
        View child = mView;
        // 逐一往上寻找能配合处理嵌套滑动的 NestedScrollingParent
        while (p != null) {
            // ViewParentCompat.onStartNestedScroll() 会判断 p 是否实现 NestedScrollingParent 接口
            // 如果是则调用其 onStartNestedScroll 方法,并返回 onStartNestedScroll 的 Boolean 结果
            // 否则返回 false
            if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                setNestedScrollingParentForType(type, p);
                // 如果 NestedScrollingParent 接受嵌套滑动事件,则调用其 onNestedScrollAccepted 方法
                ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                return true;
            }
            if (p instanceof View) {
                child = (View) p;
            }
            p = p.getParent();
        }
    }
    return false;
}

因此可以看出 NestedScrollingParent 接口方法里的 child 和 target 分别是啥:

  • target: 当前的 NestedScrollingChild 控件;
  • child: 包含 target 的 NestedScrollingParent 的直接子控件,所以 child 可能就是 target,也可能不是。

dispatchNestedPreScroll

Java 复制代码
public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
        @Nullable int[] offsetInWindow) {
    return dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, TYPE_TOUCH);
}

public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
        @Nullable int[] offsetInWindow, @NestedScrollType int type) {
    // 如果当前控件开启了嵌套滑动支持,才会继续
    if (isNestedScrollingEnabled()) {
        final ViewParent parent = getNestedScrollingParentForType(type);
        // 如果没有配合处理的 NestedScrollingParent,则直接返回
        if (parent == null) {
            return false;
        }

        if (dx != 0 || dy != 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;
            // 调用 Parent 的 onNestedPreScroll 方法
            // 传入的 consumed 数组是个引用,且初始化 0
            ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);

            if (offsetInWindow != null) {
                mView.getLocationInWindow(offsetInWindow);
                offsetInWindow[0] -= startX;
                offsetInWindow[1] -= startY;
            }
            // consumed 有一个元素不为 0 则返回 true
            // 说明 Parent 有消费滑动距离
            return consumed[0] != 0 || consumed[1] != 0;
        } else if (offsetInWindow != null) {
            offsetInWindow[0] = 0;
            offsetInWindow[1] = 0;
        }
    }
    return false;
}

主要作用是把滑动距离分发给 NestedScrollingParent 父控件,父控件需要把自己消费的滑动距离赋值给 consumed 数组。

其他 dispatchNestedScroll, dispatchNestedFling, dispatchNestedPreFling 逻辑都比较类似,都是按照前面说的嵌套滑动的逻辑流程,把事件分发给父控件,不再一一分析。

NestedScrollingParentHelper

Java 复制代码
public class NestedScrollingParentHelper {
    private int mNestedScrollAxesTouch;
    private int mNestedScrollAxesNonTouch;

    public void onNestedScrollAccepted(@NonNull View child, @NonNull View target,
            @ScrollAxis int axes) {
        onNestedScrollAccepted(child, target, axes, ViewCompat.TYPE_TOUCH);
    }

    public void onNestedScrollAccepted(@NonNull View child, @NonNull View target,
            @ScrollAxis int axes, @NestedScrollType int type) {
        if (type == ViewCompat.TYPE_NON_TOUCH) {
            mNestedScrollAxesNonTouch = axes;
        } else {
            mNestedScrollAxesTouch = axes;
        }
    }

    @ScrollAxis
    public int getNestedScrollAxes() {
        return mNestedScrollAxesTouch | mNestedScrollAxesNonTouch;
    }

    public void onStopNestedScroll(@NonNull View target) {
        onStopNestedScroll(target, ViewCompat.TYPE_TOUCH);
    }

    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;
        }
    }
}

NestedScrollingParentHelper 逻辑比较简单,只提供对应 NestedScrollingParent 父控件相关的 axes 滑动方向字段的管理。

示例

接下来分别用自定义嵌套滑动子控件系统 RecyclerView 实现下面的效果:

自定义子控件 RecyclerView

示例一:自定义子控件

  1. 在了解嵌套滑动的流程及父子控件接口的逻辑后,可以通过它来实现一个嵌套滑动的Demo:
    1. 顶部是个普通 View,底部是嵌套滑动子控件,父布局是嵌套滑动的父控件。
    2. 子控件接收到滑动事件后,分发给父控件,父控件控制顶部 View 的高度,直到顶部 View 高度达到最小高度后,父控件不再消费滑动距离,交给底部控件消费。
    3. 嵌套滑动效果比较简单,可以自己加一下其他效果,比如松手后自动吸附,响应fling事件等。
  2. NestedScrollingChild 和 NestedScrollingParent 其实只是接口,定义了嵌套滑动的事件分发规范,但实际上的逻辑需要我们自己实现。
  3. 对于 NestedScrollingChild 的接口实现,官方有提供一个 NestedScrollingChildHelper 类,一般直接使用它就行。NestedScrollingChildHelper 内部会和 NestedScrollingParent 进行交互。
  4. 对于 NestedScrollingParent 的实现,官方也有提供一个 NestedScrollingParentHelper 类,内部维护了一些状态。
  5. 具体的滑动行为,开发者可以根据具体需求自己来实现。

布局 XML:

XML 复制代码
<?xml version="1.0" encoding="utf-8"?>
<com.hearing.demo.nested.v1.NestedContainerView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.hearing.demo.nested.v1.NestedBottomView
        android:id="@+id/bottom_view"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:background="@drawable/bg_gradient"
        app:layout_constraintTop_toBottomOf="@+id/top_view" />

    <View
        android:id="@+id/top_view"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:alpha="0.5"
        android:background="@drawable/ic_test1"
        app:layout_constraintBottom_toTopOf="@+id/bottom_view"
        app:layout_constraintTop_toTopOf="parent" />
</com.hearing.demo.nested.v1.NestedContainerView>

底部子控件 NestedBottomView:

Kotlin 复制代码
class NestedBottomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr), NestedScrollingChild {
    private val childHelper by lazy { NestedScrollingChildHelper(this) }

    private var lastY = 0f
    private val consumed = intArrayOf(0, 0)

    init {
        // 设置支持嵌套滑动
        isNestedScrollingEnabled = true
    }

    override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                lastY = event.y
                // 起始方法
                if (startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL)) {
                    return true
                }
            }
            MotionEvent.ACTION_MOVE -> {
                var dy = (lastY - event.y).toInt()
                lastY = event.y
                // 首先分发给 NestedScrollingParent 处理
                if (dispatchNestedPreScroll(0, dy, consumed, null)) {
                    dy -= consumed[1]
                }
                if (dy != 0) {
                    // NestedScrollingParent 处理后,剩下未消费的交给自己处理
                    var translation = translationY - dy
                    if (translation > 0) {
                        translation = 0f
                    }
                    translationY = translation
                    // 简单处理一下高度
                    layoutParams.height = SizeUtil.getScreenHeight(context) - translation.toInt()
                    requestLayout()
                }
                // 剩下的再交给 NestedScrollingParent 处理,这里直接传 0,也可以不调
                dispatchNestedScroll(0, dy, 0, 0, null)
                return true
            }
            MotionEvent.ACTION_UP -> {
                // 结束方法
                stopNestedScroll()
            }
        }
        return super.dispatchTouchEvent(event)
    }

    override fun setNestedScrollingEnabled(enabled: Boolean) {
        childHelper.isNestedScrollingEnabled = enabled
    }

    override fun isNestedScrollingEnabled(): Boolean {
        return childHelper.isNestedScrollingEnabled
    }

    override fun startNestedScroll(axes: Int): Boolean {
        return childHelper.startNestedScroll(axes)
    }

    override fun stopNestedScroll() {
        childHelper.stopNestedScroll()
    }

    override fun hasNestedScrollingParent(): Boolean {
        return childHelper.hasNestedScrollingParent()
    }

    override fun dispatchNestedScroll(
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int,
        offsetInWindow: IntArray?
    ): Boolean {
        return childHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow)
    }

    override fun dispatchNestedPreScroll(dx: Int, dy: Int, consumed: IntArray?, offsetInWindow: IntArray?): Boolean {
        return childHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow)
    }
}

父控件 NestedContainerView:

kotlin 复制代码
class NestedContainerView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), NestedScrollingParent {

    private val parentHelper by lazy { NestedScrollingParentHelper(this) }

    private lateinit var topView: View
    private lateinit var bottomView: View
    private val topMinHeight = SizeUtil.dp2px(50f)
    private val topMaxHeight = SizeUtil.dp2px(200f)

    override fun onFinishInflate() {
        super.onFinishInflate()
        // 简单处理
        topView = findViewById(R.id.top_view)
        bottomView = findViewById(R.id.bottom_view)
    }

    override fun onStartNestedScroll(child: View, target: View, nestedScrollAxes: Int): Boolean {
        return (nestedScrollAxes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0
    }

    override fun onNestedScrollAccepted(child: View, target: View, axes: Int) {
        parentHelper.onNestedScrollAccepted(child, target, axes)
        Log.i(TAG, "onNestedScrollAccepted: $this")
    }

    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray) {
        val canScrollUp = dy > 0 && topView.height > topMinHeight
        val canScrollDown = dy < 0 && topView.height < topMaxHeight && bottomView.translationY == 0f
        if (canScrollUp || canScrollDown) {
            // 父 View 需要消费
            val newHeight = (topView.height - dy).coerceIn(topMinHeight, topMaxHeight)
            topView.layoutParams.height = newHeight
            topView.requestLayout()
            consumed[1] = dy
        } else {
            // 父 View 不需要消费
            consumed[1] = 0
        }
    }

    override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int) {
    }

    override fun onStopNestedScroll(child: View) {
        parentHelper.onStopNestedScroll(child)
    }

    override fun getNestedScrollAxes(): Int {
        return parentHelper.nestedScrollAxes
    }
}

代码里有注释,按照前面的流程图步骤来实现就行。

示例二:RecyclerView

  1. 上面是完全自定义的 NestedScrollingChild 子控件,但 Android 系统已经有很多实现了 NestedScrollingChild 接口的控件了,比如说 RecyclerView。
  2. 使用 RecyclerView 来实现类似上个示例的嵌套滑动效果。

布局 XML

ini 复制代码
<?xml version="1.0" encoding="utf-8"?>
<com.hearing.demo.nested.v2.NestedContainerView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <View
        android:id="@+id/top_view"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:alpha="0.5"
        android:background="@drawable/ic_test1"
        app:layout_constraintBottom_toTopOf="@+id/bottom_view"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/bottom_view"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintTop_toBottomOf="@+id/top_view" />
</com.hearing.demo.nested.v2.NestedContainerView>

父控件逻辑基本不变

kotlin 复制代码
class NestedContainerView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), NestedScrollingParent {

    private val parentHelper by lazy { NestedScrollingParentHelper(this) }

    private lateinit var topView: View
    private lateinit var bottomView: RecyclerView
    private val topMinHeight = SizeUtil.dp2px(50f)
    private val topMaxHeight = SizeUtil.dp2px(200f)

    override fun onFinishInflate() {
        super.onFinishInflate()
        // 简单处理
        topView = findViewById(R.id.top_view)
        bottomView = findViewById(R.id.bottom_view)
        bottomView.setViewHeight(SizeUtil.getScreenHeight(context) - topMinHeight)
    }

    override fun onStartNestedScroll(child: View, target: View, nestedScrollAxes: Int): Boolean {
        return (nestedScrollAxes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0
    }

    override fun onNestedScrollAccepted(child: View, target: View, axes: Int) {
        parentHelper.onNestedScrollAccepted(child, target, axes)
        Log.i(TAG, "onNestedScrollAccepted: $this")
    }

    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray) {
        val isBottomAtTop = (bottomView.layoutManager as? LinearLayoutManager)?.findFirstCompletelyVisibleItemPosition() == 0
        val canScrollUp = dy > 0 && topView.height > topMinHeight
        val canScrollDown = dy < 0 && topView.height < topMaxHeight && isBottomAtTop
        if (canScrollUp || canScrollDown) {
            // 父 View 需要消费
            val newHeight = (topView.height - dy).coerceIn(topMinHeight, topMaxHeight)
            topView.layoutParams.height = newHeight
            topView.requestLayout()
            consumed[1] = dy
        } else {
            // 父 View 不需要消费
            consumed[1] = 0
        }
    }

    override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int) {
    }

    override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
        // 可以根据实际需求决定要不要响应,这里先简单处理,直接拦截,不处理 fling
        return true
    }

    override fun onNestedFling(target: View, velocityX: Float, velocityY: Float, consumed: Boolean): Boolean {
        return true
    }

    override fun onStopNestedScroll(child: View) {
        parentHelper.onStopNestedScroll(child)
    }

    override fun getNestedScrollAxes(): Int {
        return parentHelper.nestedScrollAxes
    }
}

小结

本篇文章主要介绍了 NestedScrolling 嵌套滑动机制的流程,以及 NestedScrollingParent 和 NestedScrollingChild 的用法:

上面的示例比较简单,接下来的文章会通过一个比较复杂的嵌套滑动示例,再次介绍 NestedScrollingParent 和 NestedScrollingChild 的用法;以及解析 Android 系统提供的 CoordinatorLayout 和 Behavior 等组件原理。

觉得内容不错的同学可以点点赞,评论区交流。

之前在掘金上对Kotlin协程的解析比较零散,小小地推荐一下《深入理解Kotlin协程》,从源码和实例出发,结合图解,系统地分析 Kotlin 协程启动,挂起,恢复,异常处理,线程切换,并发等流程,只用一顿饭钱!感兴趣的朋友可以了解下,互相交流,不喜勿喷

相关推荐
偶是老李头1 小时前
Android - NDK:编译可执行程序在android设备上运行
android·ndk编译可执行程序·android ndk编译·android编译可执行程序
蜘蛛侠不会飞1 小时前
基于安卓14 的ANR dump信息原理
android·java·framework·安卓源码
阿岳3162 小时前
MySQL使用触发器进行备份
android·数据库·mysql
zhangjiaofa13 小时前
Android中的LoadedApk:使用指南与核心代码解析
android
m0_7482522315 小时前
万字详解 MySQL MGR 高可用集群搭建
android·mysql·adb
SoulKuyan15 小时前
Android系统默认开启adb root模式
android·adb
0wioiw018 小时前
逆向安卓抓包
android·linux·运维
zhangjiaofa18 小时前
深入理解 Android 中的 KeyguardManager
android
-代号952718 小时前
云计算中的可用性SLA
android·java·云计算
m0_7482304419 小时前
眼见不一定为实之MySQL中的不可见字符
android·数据库·mysql