掌握 NestedScrolling 嵌套滑动:手写仿知乎折叠主页

前言

现在,我们要实现的效果是------可折叠的标题栏。它有很多日常的场景,例如知乎的个人主页:往上滑动时标题栏会先收起,到顶部后内容再开始滚动。

其实,这种折叠效果官方早就通过 CoordinatorLayout 提供了成熟的方案(具体可以看我的这篇博客:可折叠式标题栏),但为了深刻理解底层的嵌套机制,我们还是来手搓一个自定义容器。

传统事件分发为什么不行?

这涉及到了 NestedScrolling(嵌套滑动)协议。在了解它之前,我们先来思考一个问题:

如果在传统的事件分发机制(dispatchTouchEvent, onInterceptTouchEvent)中实现这个效果,会发生什么?

手指向上滑时,我们会在父容器中拦截并消费事件,让主页区(头部)先收起。等主页区完全收起后,再把事件交给子 View 消费,RecyclerView 列表开始滚动。

但你会发现一个致命的问题,滑动不是连贯的。为了将事件交给子 View,手指必须要先离开屏幕,再向上滑。这是因为一旦父容器拦截了事件,那么在这个手势周期中,后续的事件都不会传递给子 View。同样,如果子 View 消费了事件,父容器也无法干预,事件的处理是单向且独占的。

传统的机制根本无法实现同一个手势中无缝更换消费对象。所以就有了 NestedScrolling 协议,它的核心是由子 View 来驱动滑动协调

嵌套滑动的工作流与协议机制

这套机制共有两个核心接口,NestedScrollingChild3NestedScrollingParent3,父子视图通过它们来实现滑动中的沟通交流。

NestedScrollingChild3:支持滑动的子控件(如 RecyclerViewNestedScrollView),它会拿到触摸事件,然后按照协议主动去询问父 View。

NestedScrollingParent3:是外层的父容器(如 CoordinatorLayoutNestedScrollView、以及等下我们要写的自定义 View),它会接收子 View 的请求,决定自身是否要消费滑动距离。

具体的接口拆解会在代码中展示,这里先理清流程。我们以一次手势的完整周期,来讲解嵌套滑动的工作机制:

  • ACTION_DOWN

    当手指按下时,子 View 会收到 ACTION_DOWN 事件,然后就会调用 startNestedScroll() 沿着 View 树向上找,找一个愿意配合滑动的父容器。如果找到会返回 true,这表示连接建立成功。

  • ACTION_MOVE (滑动前)

    当手指移动时,子 View 会算出此次移动的距离(比如上滑 50px)。在自己要滑动之前,它会先调用 dispatchNestedPreScroll() 询问父容器:我要滑 50px,你要不要拿一部分去消费?

    父容器可能会全部消费掉,也可能只消费一部分,例如消费 30px 用来折叠头部。

  • 子 View 自身消费

    子 View 会得到父容器消费的距离 30px 以及剩下的距离 20px,然后将这剩下的 20px 距离用于自身列表的滑动。

  • ACTION_MOVE (滑动后)

    如果子 View 滑到顶/底了,但滑动距离还有剩(比如还剩下 10px),它会调用 dispatchNestedScroll() 再次询问父容器:我滑完后还剩下 10px,你要不要拿去用?(比如父容器可以拿去触发下拉刷新或展开头部)

  • ACTION_UP / CANCEL

    手指抬起或是事件被取消,子 View 会调用 stopNestedScroll() 通知父容器,此次滑动结束了,可以清理一下状态。

实战:打造 MyCollapsingLayout

接下来,我们来实现一个自定义的拦截父容器 MyCollapsingLayout,仿造知乎的主页,实现折叠主页区、详情头吸顶以及列表滚动。

首先我们对着官方的 Material 的写法,给出 Demo 的布局。

这里有一个小细节,为了防止滑动到边缘 时出现半透明的泛光效果,破坏嵌套滑动的视觉连贯性,我们在 RecyclerView 中加上了 android:overScrollMode="never"

如果保留默认的泛光效果,当列表滑到顶时会触发拉伸动画,阻碍事件马上回传给父容器,导致嵌套滑动卡顿。

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<!--
  结构
  MyCollapsingLayout
    ├─ contentList      RecyclerView
    ├─ profileSection   主页区(上滑整块收起)
    ├─ stickyHeader     详情吸顶头(回答 / 动态 / 文章)
    └─ toolbar          固定标题栏(最后声明,盖在最上层)
-->
<com.example.demoview.widget.MyCollapsingLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/collapsingRoot"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_marginTop="32dp"
    android:background="#F6F6F6">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/contentList"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipToPadding="false"
        android:overScrollMode="never" />

    <!-- 可折叠的主页区 -->
    <LinearLayout
        android:id="@+id/profileSection"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#FFFFFF"
        android:orientation="vertical"
        android:paddingStart="16dp"
        android:paddingTop="12dp"
        android:paddingEnd="16dp"
        android:paddingBottom="16dp">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center_vertical"
            android:orientation="horizontal">

            <View
                android:layout_width="64dp"
                android:layout_height="64dp"
                android:background="#FF6D00" />

            <LinearLayout
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginStart="12dp"
                android:layout_weight="1"
                android:orientation="vertical">

                <TextView
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="雨白"
                    android:textColor="#121212"
                    android:textSize="20sp"
                    android:textStyle="bold" />

                <TextView
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="4dp"
                    android:text="学习Android中的嵌套滚动"
                    android:textColor="#8590A6"
                    android:textSize="13sp" />
            </LinearLayout>
        </LinearLayout>

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="12dp"
            android:text="主页简介:上滑先收这段主页,收完后下方「回答 / 动态 / 文章」吸顶,再滚列表。"
            android:textColor="#444444"
            android:textSize="14sp" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:orientation="horizontal">

            <TextView
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:gravity="center"
                android:text="128\n关注"
                android:textColor="#121212"
                android:textSize="13sp" />

            <TextView
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:gravity="center"
                android:text="3\n关注者"
                android:textColor="#121212"
                android:textSize="13sp" />

            <TextView
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:gravity="center"
                android:text="56\n赞同"
                android:textColor="#121212"
                android:textSize="13sp" />
        </LinearLayout>
    </LinearLayout>

    <!-- 详情固定头:主页收完后吸在标题栏下方 -->
    <LinearLayout
        android:id="@+id/stickyHeader"
        android:layout_width="match_parent"
        android:layout_height="48dp"
        android:background="#FFFFFF"
        android:elevation="2dp"
        android:gravity="center_vertical"
        android:orientation="horizontal"
        android:paddingVertical="16dp"
        android:paddingStart="8dp"
        android:paddingEnd="8dp">

        <TextView
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:gravity="center"
            android:text="回答"
            android:textColor="#121212"
            android:textSize="15sp"
            android:textStyle="bold" />

        <TextView
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:gravity="center"
            android:text="动态"
            android:textColor="#8590A6"
            android:textSize="15sp" />

        <TextView
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:gravity="center"
            android:text="文章"
            android:textColor="#8590A6"
            android:textSize="15sp" />
    </LinearLayout>

    <!-- 固定标题栏:始终钉在顶部;折叠进度高时显示用户名 -->
    <FrameLayout
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="56dp"
        android:background="#FFFFFF"
        android:elevation="4dp"
        android:paddingVertical="16dp"
        android:paddingTop="8dp">

        <TextView
            android:id="@+id/toolbarTitle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:alpha="0"
            android:text="雨白"
            android:textColor="#121212"
            android:textSize="17sp"
            android:textStyle="bold" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="start|center_vertical"
            android:paddingStart="16dp"
            android:paddingEnd="16dp"
            android:text="←"
            android:textColor="#121212"
            android:textSize="20sp" />
    </FrameLayout>

</com.example.demoview.widget.MyCollapsingLayout>

接着来一步步实现这个自定义父容器。

骨架搭建:实现接口

首先实现 NestedScrollingParent3 接口

kotlin 复制代码
class MyCollapsingLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr),
    NestedScrollingParent3 { // 实现NestedScrollingParent3接口

    init {
        // 裁剪子 View 超出父容器顶部的部分,避免画到状态栏外
        clipChildren = true
        clipToPadding = true
    }

     // 后续方法逐步填充...
}

测量逻辑:确定列表的真实高度

然后是测量代码。这里有个很关键的细节:列表的高度必须是折叠后的屏幕剩余空间,否则头部折叠完成后,列表底部会有一片空白。

这也意味着,我们的 MyCollapsingLayout 最外层应该是一个有确切高度的容器。不应该包裹在 ScrollView 这种高度不确定(UNSPECIFIED)的容器中。

kotlin 复制代码
private lateinit var toolbar: View      // 顶部固定标题栏
private lateinit var profile: View      // 主页信息区
private lateinit var sticky: View       // 吸附悬浮头
private lateinit var scrollChild: View  // 底部滑动列表

// 记录各个区域的高度
private var toolbarHeight = 0
private var profileHeight = 0
private var stickyHeight = 0

// 可折叠的最大距离
private var maxCollapse = 0

override fun onFinishInflate() {
    super.onFinishInflate()
    // 布局加载后,找到对应的子控件
    toolbar = findViewById(R.id.toolbar)
        ?: error("can't find @id/toolbar")
    profile = findViewById(R.id.profileSection)
        ?: error("can't find @id/profileSection")
    sticky = findViewById(R.id.stickyHeader)
        ?: error("can't find @id/stickyHeader")
    scrollChild = findViewById(R.id.contentList)
        ?: error("can't find @id/contentList")
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // 获取父容器给我们的确切空间
    val width = MeasureSpec.getSize(widthMeasureSpec)
    val height = MeasureSpec.getSize(heightMeasureSpec)
    // 保存自身尺寸
    setMeasuredDimension(width, height)

    // 测试规格:宽度必须铺满(EXACTLY),高度由子View决定(无限制)
    val exactWidth = MeasureSpec
        .makeMeasureSpec(width, MeasureSpec.EXACTLY)
    val wrapHeight = MeasureSpec
        .makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)

    // 测量子 View
    toolbar.measure(exactWidth, wrapHeight)
    profile.measure(exactWidth, wrapHeight)
    sticky.measure(exactWidth, wrapHeight)

    // 获取各组件的测量高度
    toolbarHeight = toolbar.measuredHeight
    profileHeight = profile.measuredHeight
    stickyHeight = sticky.measuredHeight

    // 最大折叠距离就是主页区的高度
    maxCollapse = profileHeight

    // 测量底部滑动列表
    // 列表的高度 = 总高度 - (永远可见的 Toolbar) - (永远可见的吸顶头)
    val listHeight = (height - toolbarHeight - stickyHeight).coerceAtLeast(0)
    scrollChild.measure(
        exactWidth,
        MeasureSpec.makeMeasureSpec(listHeight, MeasureSpec.EXACTLY)
    )
}

布局摆放与滑动位移

然后是布局摆放,我们要按照完全展开的坐标来摆放 。因为我们的折叠是通过 translationY 偏移来实现的,而不是通过改变 height 高度。

这样就不会因为滑动时的频繁重新测量、重新布局(requestLayout),导致页面抽搐和列表高度抖动。

kotlin 复制代码
// 当前已经折叠的距离
private var collapseOffset = 0

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
    val w = right - left

    // 按完全展开坐标摆放
    toolbar.layout(0, 0, w, toolbarHeight)
    profile.layout(0, toolbarHeight, w, toolbarHeight + profileHeight)
    sticky.layout(
        0,
        toolbarHeight + profileHeight,
        w,
        toolbarHeight + profileHeight + stickyHeight
    )
    val listTop = toolbarHeight + profileHeight + stickyHeight
    scrollChild.layout(0, listTop, w, listTop + scrollChild.measuredHeight)

    // 修改绘制层级 (Z轴)
    scrollChild.elevation = 0f  // 列表在最下层
    profile.elevation = 1f
    sticky.elevation = 2f
    toolbar.elevation = 3f      // Toolbar在最高层

    // 初始应用当前折叠偏移,横竖屏切换等场景也适用
    applyCollapseOffset(collapseOffset, notify = false)
}

// 折叠进度,0.0 ~ 1.0,方便外部调整标题栏的透明度
var onCollapseProgressChanged: ((Float) -> Unit)? = null

/**
 * 应用折叠偏移
 * @param offset 折叠偏移距离
 * @param notify 是否通知回调
 */
private fun applyCollapseOffset(offset: Int, notify: Boolean) {
    // 0 表示完全展开,maxCollapse 表示完全折叠
    collapseOffset = offset
    // 往上推,所以是负值
    val ty = -offset.toFloat()
    // 三者同步上移:主页收走,吸顶头贴到标题栏下,列表跟上
    profile.translationY = ty
    sticky.translationY = ty
    scrollChild.translationY = ty
    
    // 计算进度回调,0f 是全展开,1f 是全收起
    if (notify) {
        val progress = if (maxCollapse == 0) 0f else offset.toFloat() / maxCollapse
        onCollapseProgressChanged?.invoke(progress)
    }
}

核心逻辑:拦截与消费滑动事件

接着来看看嵌套滑动中最核心的几个回调,首先是参与滑动前的校验建立。

这里官方提供了一个帮助类 NestedScrollingParentHelper,让我们可以快速实现一些固定繁琐的逻辑。

kotlin 复制代码
// -------------------------------------------------------------------------
// NestedScrollingParent3
// -------------------------------------------------------------------------
private val parentHelper = NestedScrollingParentHelper(this)

/**
 * 滚动开始,决定是否参与此次嵌套滑动(建立连接)
 * @param child 触发滚动的子 View
 * @param target 真正产生滚动的子 View,一般等于 child
 * @param axes 滑动方向,水平或垂直,位掩码,也可能同时是两者
 * @param type 滑动类型,手指触摸屏幕或是惯性滚动
 */
override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
    // 只响应垂直方向的嵌套滑动
    return (axes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0
}

/**
 * 参与此次嵌套滑动,完成初始化
 */
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
    // 保存滑动方向状态
    parentHelper.onNestedScrollAccepted(child, target, axes, type)
}

然后是 onNestedPreScroll()onNestedScroll()

注意:我们在给 consumed[1] 赋值时,它必须等于实际消耗的像素值,否则可能导致子 View 收到错误的剩余距离,发生滑动错乱。

kotlin 复制代码
/**
 * 子 View 滑动前,父容器先拦截
 * @param target 真正产生滚动的子 View
 * @param dx 子 View 本次想要滚动的水平偏移,>0 表示手指向右移动
 * @param dy 子 View  本次想要滚动的垂直偏移,>0 表示手指向上移动
 * @param consumed 分别表示水平和垂直方向上父消耗的偏移量
 * @param type 滑动类型,手指触摸屏幕或是惯性滚动
 */
override fun onNestedPreScroll(
    target: View,
    dx: Int,
    dy: Int,
    consumed: IntArray,
    type: Int
) {
    // 如果手指向上滑,并且主页还未完全收起
    if (dy > 0 && collapseOffset < maxCollapse) {
        // 消费掉一段距离来折叠头部
        // 记录到 consumed[1] 中,明确告诉子 View 我们消费了多少垂直像素
        consumed[1] = applyCollapseDelta(dy)
    }
}

/**
 * 子 View 滑到顶了,把剩下的距离交给父容器
 * @param target 真正产生滚动的子 View
 * @param dxConsumed 子 View 消费的水平偏移量
 * @param dyConsumed 子 View 消费的垂直偏移量
 * @param dxUnconsumed 子 View 剩余的水平偏移量
 * @param dyUnconsumed 子 View 剩余的垂直偏移量
 * @param type 滑动类型,手指触摸屏幕或是惯性滚动
 * @param consumed 分别表示水平和垂直方向上父消耗的偏移量
 */
override fun onNestedScroll(
    target: View,
    dxConsumed: Int,
    dyConsumed: Int,
    dxUnconsumed: Int,
    dyUnconsumed: Int,
    type: Int,
    consumed: IntArray
) {
    // 如果手指向下滑,列表已经滑到顶了(dyUnconsumed < 0),且主页还没有完全展开
    if (dyUnconsumed < 0 && collapseOffset > 0) {
        // 消费掉一段距离来展开主页
        consumed[1] += applyCollapseDelta(dyUnconsumed)
    }
}

/**
 * 接收滑动的变化量,应用偏移,返回真实消费的像素值
 * @param delta >0 继续收起;<0 展开
 * @return 实际生效的 delta
 */
private fun applyCollapseDelta(delta: Int): Int {
    if (maxCollapse == 0 || delta == 0) return 0

    val old = collapseOffset
    val newOffset = (collapseOffset + delta).coerceIn(0, maxCollapse)
    val actual = newOffset - old // 计算出真实生效的像素差
    if (actual == 0) return 0

    applyCollapseOffset(newOffset, notify = true)
    return actual // 返回真实消耗量给上层的 consumed 数组
}

Fling 的平滑转换与杂项补全

最后补全剩下的旧版 API 兼容代码和 Fling 方法。

我们的 Fling (惯性滑动) 方法直接返回了 false,这是因为在 V2/V3 协议中,RecyclerView 的惯性滑动会被底层转换为 TYPE_NON_TOUCH 的 Scroll 事件不断派发。

我们无需去实现,直接用上面写好的 onNestedPreScroll 就能完美无缝衔接,这也是 V2 版本解决 V1 版本痛点的核心体现。

kotlin 复制代码
/**
 * 获取滑动方向
 */
override fun getNestedScrollAxes(): Int = parentHelper.nestedScrollAxes

/**
 * 滑动结束
 */
override fun onStopNestedScroll(target: View, type: Int) {
    parentHelper.onStopNestedScroll(target, type)
}

// --- 下面全是旧版 API 兼容代码,直接带上 TYPE_TOUCH 去调用 V3 方法 ---
override fun onStartNestedScroll(child: View, target: View, axes: Int): Boolean =
    onStartNestedScroll(child, target, axes, ViewCompat.TYPE_TOUCH)

override fun onNestedScrollAccepted(child: View, target: View, axes: Int) =
    onNestedScrollAccepted(child, target, axes, ViewCompat.TYPE_TOUCH)

override fun onStopNestedScroll(target: View) =
    onStopNestedScroll(target, ViewCompat.TYPE_TOUCH)

override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray) =
    onNestedPreScroll(target, dx, dy, consumed, ViewCompat.TYPE_TOUCH)

override fun onNestedScroll(
    target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int
) = onNestedScroll(
    target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
    ViewCompat.TYPE_TOUCH, IntArray(2)
)

override fun onNestedScroll(
    target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int,
    dyUnconsumed: Int, type: Int
) = onNestedScroll(
    target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, IntArray(2)
)

// 忽略 Fling 的手动拦截,依赖 V2/V3 底层的 TYPE_NON_TOUCH 派发机制来平滑转换
override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float) = false
override fun onNestedFling(
    target: View,
    velocityX: Float,
    velocityY: Float,
    consumed: Boolean
) = false

/**
 * 生成默认的 LayoutParams
 */
override fun generateDefaultLayoutParams(): LayoutParams {
    return LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
}

最后是 Activity 跑起来的测试代码:

kotlin 复制代码
class MyCollapsingDemoActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_my_collapsing_demo)
        supportActionBar?.hide()
        title = "仿知乎折叠主页"

        val toolbarTitle = findViewById<TextView>(R.id.toolbarTitle)
        val root = findViewById<MyCollapsingLayout>(R.id.collapsingRoot)
        root.onCollapseProgressChanged = { progress ->
            // 主页收得越多,标题栏用户名越明显
            toolbarTitle.alpha = (progress * 1.2f).coerceIn(0f, 1f)
        }

        val list = findViewById<RecyclerView>(R.id.contentList)
        list.layoutManager = LinearLayoutManager(this)
        list.adapter = FeedAdapter(buildFakeFeed())
    }

    private fun buildFakeFeed(): List<FeedItem> {
        return (1..40).map { index ->
            FeedItem(
                title = "回答 #$index:嵌套滚动里 dy 先给谁?",
                body = "Parent 在 PreScroll 先收主页;主页收完后 Child 才滚列表。" +
                    "到顶后的下滑剩余量再在 NestedScroll 里展开主页。"
            )
        }
    }

    private data class FeedItem(val title: String, val body: String)

    private class FeedAdapter(
        private val items: List<FeedItem>
    ) : RecyclerView.Adapter<FeedAdapter.Holder>() {

        class Holder(view: View) : RecyclerView.ViewHolder(view) {
            val title: TextView = view.findViewById(R.id.tvItemTitle)
            val body: TextView = view.findViewById(R.id.tvItemBody)
        }

        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
            val view = LayoutInflater.from(parent.context)
                .inflate(R.layout.item_zhihu_feed, parent, false)
            return Holder(view)
        }

        override fun onBindViewHolder(holder: Holder, position: Int) {
            val item = items[position]
            holder.title.text = item.title
            holder.body.text = item.body
        }

        override fun getItemCount(): Int = items.size
    }
}

补充:协议的演进史

你看到代码中 NestedScrollingParent3 有个数字 3,其实这是官方演进的第三个版本,我们刚刚在解释 Fling 时也有提到,最后用一张表来总结这三个版本的进化史:

版本 核心痛点与改进 变化细节
V1 痛点:惯性滑动 (Fling) 不连贯。 Fling 是一次性丢给父或子的,无法像普通的 Scroll 那样按像素来分配。
V2 改进:将 Fling 转化为普通滑动。 接口所有方法增加了 type 参数(TYPE_TOUCH 手指滑动, TYPE_NON_TOUCH 惯性滑动),让 Fling 也能完美联动。
V3 改进:精确的滑动后 (PostScroll) 消费反馈。 dispatchNestedScroll 增加了 consumed 数组参数,父容器兜底滑动后,能确切告诉子 View 自己到底消费了多少像素。
相关推荐
Xzaveir6 小时前
别把所有“认证”都塞进 AuthService:实名、一键登录与号码身份的领域拆分
android·人工智能
BerrySen1786 小时前
KMP全栈开发:从Android到AI Agent的技术演进与实践
android·人工智能
AFinalStone7 小时前
Android 7系统休眠唤醒(一)电源管理架构全景图
android·powermanager·电源管理
YM52e8 小时前
鸿蒙Flutter Center居中组件:Align对齐详解
android·学习·flutter·华为·harmonyos·鸿蒙
时间的拾荒人8 小时前
MySQL 视图详解
android·数据库·mysql
祉猷并茂,雯华若锦9 小时前
Appium 3.x安卓按键与通知栏操作全指南
android·appium
码农coding10 小时前
android 12 SurfaceFlinger开机启动分析
android
hunterandroid10 小时前
Paging 3 RemoteMediator 实战:构建离线优先的分页列表
android·前端
码农coding10 小时前
android12 开机启动PMS
android