目录

图解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 协程启动,挂起,恢复,异常处理,线程切换,并发等流程,只用一顿饭钱!感兴趣的朋友可以了解下,互相交流,不喜勿喷

本文是转载文章,点击查看原文
如有侵权,请联系 xyy@jishuzhan.net 删除
相关推荐
隐-梵3 小时前
Android studio学习之路(六)--真机的调试以及多媒体照相的使用
android·学习·android studio
stevenzqzq3 小时前
Android Studio Logcat V2 使用指南(适配 2024 年版本)
android·ide·android studio
bytebeats3 小时前
改进 Jetpack Compose 中的 ModalBottomSheet API
android
bytebeats3 小时前
使用Dagger SPI 查找非必要组件依赖项
android·gradle·dagger
bytebeats3 小时前
在Kotlin中编写依赖于时间的可测试协程代码
android·kotlin·测试
_一条咸鱼_4 小时前
AI 大模型之 Transformer 架构深入剖析
android
QING6184 小时前
Kotlin 中 reified 配合 inline 不再被类型擦除蒙蔽双眼
android·kotlin·app
Yang-Never4 小时前
OpenGL ES -> SurfaceView + EGL实现立方体纹理贴图+透视效果
android·kotlin·android studio·贴图
QING6184 小时前
Android应用启动与退出监听方案——新手入门指南
android·架构·app
叫我龙翔4 小时前
【项目日记】高并发服务器项目总结
android·运维·服务器