- 教程
- 技术博客
- 文档
【免费下载链接】YCBlogs
技术博客笔记大汇总,包括Java基础,线程,并发,数据结构;Android技术博客等等;常用设计模式;常见的算法;网络协议知识点;部分flutter笔记;还包括平时开发中遇到的bug汇总,当然也在工作之余收集了大量的面试题,长期更新维护并且修正,持续完善……开源的文件是markdown格式的!转载请注明出处,谢谢!
NestedScrollView 是 Android 官方提供的支持嵌套滚动的 ScrollView 增强版,是复杂页面的高频容器控件,但配套的滚动控制、嵌套 RecyclerView 冲突、底部监听、fillViewport 测量与 ViewPager 嵌套等问题层出不穷。本文以 YCBlogs 仓库中 android/08.复杂控件/01.NestedScrollView.md 的实战经验为骨架,结合 ScrollView 嵌套 RecyclerView 问题、RecyclerView 滑动冲突 与 onMeasure 测量原理 等仓库资料,系统梳理 5 类高频问题的成因与解决方案,读完即可直接落地到自己的项目中。
01. NestedScrollView 滚动到顶部:两种方式与消息队列陷阱
滚动到顶部是最常见的需求,原文档给出了两种写法。
第一种方式:scrollTo
nestedScrollView.scrollTo(0, 0);scrollTo(0, 0)直接把滚动偏移量归零,简单直接。注意它不带平滑动画,若需要滚动过程动画,可使用smoothScrollTo(0, 0)。
第二种方式:fullScroll
scrollView.fullScroll(ScrollView.FOCUS_DOWN); // 滚动到底部 scrollView.fullScroll(ScrollView.FOCUS_UP); // 滚动到顶部fullScroll会先查找焦点位置,再执行滚动。原文档特别强调了一个关键陷阱:
该方法不能直接被调用。Android 很多函数都基于消息队列同步,addView 完之后不等于马上就会显示,而是在队列中等待处理,虽然很快,但如果立即调用 fullScroll,View 可能还没有显示出来,所以会失败。
因此正确姿势是投递到消息队列尾部,等布局完成后再执行:
scrollView.post(new Runnable() { @Override public void run() { // ScrollView 滑动到顶部 scrollView.fullScroll(ScrollView.FOCUS_UP); } });实战要点:凡是"刚 setAdapter / addView 后立刻做滚动、测量、定位"的操作,都应先用post()或postOnAnimation()挂到下一帧再执行,这是 Android 消息驱动渲染机制下的通用纪律。
02. NestedScrollView 为何有时滚不到顶部:嵌套 RecyclerView 的焦点与滚动冲突
问题现象
NestedScrollView 嵌套 RecyclerView 时,常见两类症状:
- RecyclerView 滑动卡顿,没有惯性滑动效果;
- 页面无法滚动到顶部,或者一进入页面就被 RecyclerView 抢焦点自动下滑。
解法一:关闭 RecyclerView 的嵌套滚动
setNestedScrollingEnabled(false)让 RecyclerView 不再处理滚动事件,全部交由 NestedScrollView 统一处理,卡顿随之消失:
recycler.setNestedScrollingEnabled(false);结合仓库 ScrollView 嵌套 RecyclerView 问题 的源码解读,这一行背后的调用链是:
// RecyclerView.setNestedScrollingEnabled 的实现 public void setNestedScrollingEnabled(boolean enabled) { this.getScrollingChildHelper().setNestedScrollingEnabled(enabled); } // NestedScrollingChildHelper.setNestedScrollingEnabled 的实现 public void setNestedScrollingEnabled(boolean enabled) { if (this.mIsNestedScrollingEnabled) { // 通过 ViewCompat 关闭 RecyclerView 的嵌套滑动特性 ViewCompat.stopNestedScroll(this.mView); } this.mIsNestedScrollingEnabled = enabled; }有弊端吗?有。关闭嵌套滚动后,RecyclerView 的 item 会一次性全部加载完,不管是否显示在屏幕内。若列表条目过多,会显著拖累页面加载性能(详见下文"使用嵌套建议")。
解法二:处理焦点抢占(两种尝试)
网上流传的方案 A 是在最顶层 View 加上:
android:focusableInTouchMode="true" android:focusable="true"原文档明确验证过:并不好使。
方案 B 是设置descendantFocusability属性。该属性定义的是 ViewGroup 与其子控件之间的焦点获取关系,常用来解决父控件焦点或点击事件被子控件抢走的问题,取值有三档:
| 取值 | 含义 |
|---|---|
beforeDescendants | ViewGroup 会优先于其子控件获取焦点 |
afterDescendants | ViewGroup 只有当其子控件都不需要获取焦点时才获取焦点 |
blocksDescendants | ViewGroup 覆盖子类控件,直接获得焦点 |
原文档的写法有两种。第一种用 RelativeLayout 包裹 RecyclerView:
<RelativeLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:descendantFocusability="blocksDescendants"> <android.support.v7.widget.RecyclerView android:id="@+id/rv_hot_review" android:layout_width="match_parent" android:layout_height="wrap_content" android:foregroundGravity="center" /> </RelativeLayout>第二种是实测后精简的写法——不套一层布局,直接把属性写在 RecyclerView 上同样可行:
<android.support.v7.widget.RecyclerView android:id="@+id/rv_hot_review" android:layout_width="match_parent" android:layout_height="wrap_content" android:descendantFocusability="blocksDescendants" />延伸方案:重写 LayoutManager / 重写父容器
仓库 RecyclerView 滑动冲突 中还给出了另外两种思路,可作为对照:
a. 重写 LayoutManager,垂直方向永远返回不可滚动:
LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this) { @Override public boolean canScrollVertically() { return false; } };b. 封装可开关的 ScrollLayoutManager:
public class ScrollLayoutManager extends LinearLayoutManager { private boolean isScrollEnable = true; public ScrollLayoutManager(Context context) { super(context); } public ScrollLayoutManager(Context context, int orientation, boolean reverseLayout) { super(context, orientation, reverseLayout); } public ScrollLayoutManager(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } @Override public boolean canScrollVertically() { return isScrollEnable && super.canScrollVertically(); } /** 设置 RecyclerView 是否可以垂直滑动 */ public void setScrollEnable(boolean isEnable) { isScrollEnable = isEnable; } }c. 重写 NestedScrollView 拦截事件:在onInterceptTouchEvent中记录按下坐标,当纵向移动距离超过ViewConfiguration.get(context).getScaledTouchSlop()判定为滑动时直接拦截,不向下分发给 RecyclerView:
public class NoNestedScrollview extends NestedScrollView { private int downX; private int downY; private int mTouchSlop; public NoNestedScrollview(Context context) { super(context); mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); } public NoNestedScrollview(Context context, AttributeSet attrs) { super(context, attrs); mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); } public NoNestedScrollview(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); } @Override public boolean onInterceptTouchEvent(MotionEvent e) { int action = e.getAction(); switch (action) { case MotionEvent.ACTION_DOWN: downX = (int) e.getRawX(); downY = (int) e.getRawY(); break; case MotionEvent.ACTION_MOVE: // 判断是否滑动,若滑动就拦截事件 int moveY = (int) e.getRawY(); if (Math.abs(moveY - downY) > mTouchSlop) { return true; } break; default: break; } return super.onInterceptTouchEvent(e); } }03. NestedScrollView 监听滑动到底部:OnScrollChangeListener
NestedScrollView 嵌套 RecyclerView 时,由于滚动被父容器接管,RecyclerView 自身的 onScrolled 监听会失效,因此需要改在 NestedScrollView 上监听:
nestedScrollview.setOnScrollChangeListener(new NestedScrollView.OnScrollChangeListener() { @Override public void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) { if (scrollY > oldScrollY) { Log.i(TAG, "Scroll DOWN"); } if (scrollY < oldScrollY) { Log.i(TAG, "Scroll UP"); } if (scrollY == 0) { Log.i(TAG, "TOP SCROLL"); } if (scrollY == (v.getChildAt(0).getMeasuredHeight() - v.getMeasuredHeight())) { Log.i(TAG, "BOTTOM SCROLL"); } } });判断逻辑拆解:
scrollY > oldScrollY:向下滚动;scrollY < oldScrollY:向上滚动;scrollY == 0:到达顶部;scrollY == 子View测量高度 - NestedScrollView自身高度:到达底部(内容底部与可视区域底边重合)。
注意边界:底部判断依赖getChildAt(0).getMeasuredHeight()与getMeasuredHeight()的精确差值,若子布局存在 padding、margin 或 fillViewport 拉伸(见下节),应以实际内容高度为准,必要时配合getScrollRange()或监听末尾回调做兜底判断。
04. NestedScrollView 子布局显示不出高度:fillViewport 与二次测量
问题与解法
当 NestedScrollView 的子布局偶尔显示不出高度时,在 NestedScrollView 上加上这一行即可:
<androidx.core.widget.NestedScrollView android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" />fillViewport 到底是什么
原文档引用官方解释:子视图会充满用户可见的区域(可以理解为子视图会填满 NestedScrollView)。具体行为是:
- 假如 NestedScrollView 高度为 100dp,而子 View 只有 50dp,当该属性为 true 时,子 View 会被重新测量为 100dp;
- 该属性不会改变 NestedScrollView 本身的测量高度;
- 只有当某个子视图中的某个控件需要"沉底"(比如底部按钮始终贴在页面底部)时,才需要开启该属性。
背后的二次测量逻辑
从 View 测量机制看(详见仓库 onMeasure 介绍):
当子视图高度比自己小时,NestedScrollView 会让子视图重新测量一次,就是为了让子视图跟自己一样高。所以传入的 heightMeasureSpec 的 mode 为
MeasureSpec.EXACTLY,而传入的 size 就是 NestedScrollView 的高度减去 padding 的值。
Android 的测量本质是父布局向子 View 传递MeasureSpec(由 mode 和 size 两部分组成),其中三种模式对应关系为:
| 模式 | 常量值 | 对应布局场景 |
|---|---|---|
UNSPECIFIED | 0 << MODE_SHIFT | 不对 View 做任何限制,要多大给多大,一般用于系统内部测量 |
EXACTLY | 1 << MODE_SHIFT | 对应match_parent和具体数值,最终大小就是 SpecSize 指定的值 |
AT_MOST | 2 << MODE_SHIFT | 对应wrap_content,大小不能超过 SpecSize |
fillViewport 的二次测量本质上就是把子视图的 heightMeasureSpec 从宽松模式提升为EXACTLY,强制其与 NestedScrollView 可视高度一致,从而解决"子布局高度异常显示不出来"的问题。
05. NestedScrollView 中嵌套 ViewPager:高度塌陷与 WrapContentHeightViewPager
问题现象
NestedScrollView 嵌套 ViewPager 后,如果 ViewPager 中 Fragment 高度太长,会发现滑动不了;即使 Fragment 内部加了 ScrollView 也一样没效果。
原因:ViewPager 的 EXACTLY 罪魁祸首
原文档给出了 ViewPager 的测量源码(省略号处为无关代码):
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { ...代码省略... mChildWidthMeasureSpec = MeasureSpec.makeMeasureSpec(childWidthSize, MeasureSpec.EXACTLY); mChildHeightMeasureSpec = MeasureSpec.makeMeasureSpec(childHeightSize, MeasureSpec.EXACTLY); // 罪魁祸首 // Make sure we have created all fragments that we need to have shown. mInLayout = true; populate(); mInLayout = false; // Page views next. size = getChildCount(); for (int i = 0; i < size; ++i) { final View child = getChildAt(i); if (child.getVisibility() != GONE) { final LayoutParams lp = (LayoutParams) child.getLayoutParams(); if (lp == null || !lp.isDecor) { final int widthSpec = MeasureSpec.makeMeasureSpec( (int) (childWidthSize * lp.widthFactor), MeasureSpec.EXACTLY); child.measure(widthSpec, mChildHeightMeasureSpec); } } } }问题就出在这一行:
mChildHeightMeasureSpec = MeasureSpec.makeMeasureSpec(childHeightSize, MeasureSpec.EXACTLY);ViewPager 把子 View(即各 Fragment 的页面)的测量模式直接锁定为 EXACTLY,高度被限制在 ViewPager 当前的测量高度内。当 Fragment 内容超高时,高度被裁剪,外部 NestedScrollView 无法感知真实内容高度,自然就"滑不动"。原文档写道:"虽然不知道为啥 ViewPager 要这么设置,但是这么放着不管肯定不行。"
解法:重写一个自适配高度的 WrapContentHeightViewPager
核心思路是在onMeasure中遍历所有子页,用UNSPECIFIED模式先测量出每个页面的真实高度,取最大值作为 ViewPager 的测量高度:
public class WrapContentHeightViewPager extends ViewPager { public WrapContentHeightViewPager(Context context) { super(context); } public WrapContentHeightViewPager(Context context, AttributeSet attrs) { super(context, attrs); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int height = 0; for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); child.measure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); int h = child.getMeasuredHeight(); if (h > height) { height = h; } } heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY); super.onMeasure(widthMeasureSpec, heightMeasureSpec); } }与源码对照,可看出该方案做了三件事:
- 先调用一次
super.onMeasure走默认测量; - 对每个子 View 用
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)进行"无限高"测量,拿到内容真实高度并取最大值; - 用这个最大高度重新构造
EXACTLY的 heightMeasureSpec 再测一次,从而让 ViewPager 撑开与最高页面等高的高度。
使用注意:该方案适合页面高度可枚举(如固定几个 Tab 页)的场景,ViewPager 高度会跟随最高页面,切换短内容页时底部会有留白,这是"以高为准"的固有代价。
延伸:NestedScrollView 嵌套的使用建议
原文档及其姊妹篇 ScrollView 嵌套 RecyclerView 问题 都强调了一个重要结论:
尽管我们能够采用多种方式解决 ScrollView 嵌套 RecyclerView 所产生的一系列问题,但由于上述解决方式均会使得 RecyclerView 在页面加载过程中一次性显示所有内容,因此当 RecyclerView 下的条目过多时,将会影响整个应用的运行效率。基于此,在这种情况下我们应当尽量避免采用 ScrollView 嵌套 RecyclerView 的布局方式。
这一代价在上述所有方案中都存在:setNestedScrollingEnabled(false)、canScrollVertically() 返回 false、blocksDescendants抢焦点,本质都是让 RecyclerView 放弃滚动,把所有 item 一次性展开交给 NestedScrollView 滚动。因此:
- 列表条数少、结构简单(如详情页的评分列表、评论预览):可以嵌套,配合
setHasFixedSize(true)+setNestedScrollingEnabled(false)使用; - 列表条数多或数据动态变化(如长评论流、商品列表):应改用"单 RecyclerView 多类型布局"或"列表页 + 详情页"的页面结构,从根上规避嵌套;
- 仓库 RecyclerView 实现多 type 页面 与 RecyclerView 封装库和综合案例 提供了更多不依赖嵌套的实现思路,可作为替代方案参考。
小结
本文围绕 NestedScrollView 的五类高频问题给出了可直接复用的代码:
| 问题 | 核心解法 | 关键代码/属性 |
|---|---|---|
| 滚动到顶部 | scrollTo(0,0)或post后fullScroll(FOCUS_UP) | nestedScrollView.scrollTo(0, 0) |
| 嵌套 RecyclerView 滚不到顶部/卡顿 | 关闭嵌套滚动 + 处理焦点抢占 | recycler.setNestedScrollingEnabled(false)、android:descendantFocusability="blocksDescendants" |
| 监听滑动到底部 | 使用setOnScrollChangeListener | scrollY == child.getMeasuredHeight() - v.getMeasuredHeight() |
| 子布局显示不出高度 | 开启fillViewport | android:fillViewport="true"(二次测量为 EXACTLY) |
| 嵌套 ViewPager 滑不动 | 重写onMeasure自适配高度 | WrapContentHeightViewPager(UNSPECIFIED 量取真实高度) |
同时牢记两条底层纪律:一是"改完布局立刻滚动/测量"要记得post()到消息队列;二是所有让 RecyclerView 失效的嵌套方案都会带来"一次性加载全部 item"的代价,条数多时应优先考虑不嵌套的页面结构。文中所有代码与结论均可在仓库对应文档中溯源:NestedScrollView 实战记录、ScrollView 嵌套 RecyclerView 问题、RecyclerView 滑动冲突、onMeasure 介绍。
- 教程
- 技术博客
- 文档
【免费下载链接】YCBlogs
技术博客笔记大汇总,包括Java基础,线程,并发,数据结构;Android技术博客等等;常用设计模式;常见的算法;网络协议知识点;部分flutter笔记;还包括平时开发中遇到的bug汇总,当然也在工作之余收集了大量的面试题,长期更新维护并且修正,持续完善……开源的文件是markdown格式的!转载请注明出处,谢谢!
相关推荐
解决嵌套滚动冲突:Lenis实现复杂布局中的滚动优先级控制
解决嵌套滚动冲突:Lenis实现复杂布局中的滚动优先级控制 在现代Web应用开发中,随着界面复杂度提升,嵌套滚动(Nested Scroll)场景日益常见。当用
前端彻底解决Android嵌套滑动冲突:FlexboxLayoutManager与NestedScrollView组合全攻略
彻底解决Android嵌套滑动冲突:FlexboxLayoutManager与NestedScrollView组合全攻略 你是否还在为Android应用中的嵌套
移动开发UI组件前端3分钟搞定Burp Suite中文界面!让Web安全测试效率翻倍的终极指南
3分钟搞定Burp Suite中文界面!让Web安全测试效率翻倍的终极指南 还在为Burp Suite的英文界面而头疼吗?作为中文安全测试人员,每天面对复杂的渗
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考