ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android ScrollView 与 HorizontalScrollView 的滚动与嵌套避坑指南

Android ScrollView 与 HorizontalScrollView 的滚动与嵌套避坑指南 今天想认真聊聊 Android 里两个被用烂却很容易踩坑的滚动容器ScrollView和HorizontalScrollView。它们一个管纵向滚动一个管横向滚动几乎所有“内容比屏幕大”的页面都会遇到但很多开发者在真正上手时会碰上测量异常、事件冲突、滚动位置不对这些问题。这篇文章会把它们的继承关系、常用属性、控件监听、嵌套冲突、性能替代几个维度全部盘一遍顺便把我实际项目中踩过的坑和验证过的方案写出来刚接触这两个控件的朋友、以及被滚动嵌套折磨过的开发都可以直接拿去做参考。1. 先弄清楚ScrollView 和 HorizontalScrollView 到底是什么1.1 各自的职责与基本写法ScrollView 是 Android 负责纵向滚动的容器很多文章页面、表单页面、隐私协议页面都是用它包一层实现的。它内部其实继承自 FrameLayout在 FrameLayout 基础上加了滚动能力所以本质上还是一个“可以滚动的容器”。它最典型的使用姿势是这样的ScrollView android:layout_widthmatch_parent android:layout_heightmatch_parent android:fillViewporttrue LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical !-- 这里放大量内容 -- /LinearLayout /ScrollViewHorizontalScrollView 则刚好反过来它要解决的是横向内容超出屏幕的问题。比如一个 Tab 栏、一组横向排列的卡片、图片浏览横划都可以用 HorizontalScrollView 包一层。写法几乎一样HorizontalScrollView android:layout_widthmatch_parent android:layout_heightwrap_content LinearLayout android:layout_widthwrap_content android:layout_heightmatch_parent android:orientationhorizontal !-- 这里放横向内容 -- /LinearLayout /HorizontalScrollView两者除了滚动方向不同打开源码会发现类结构和内部处理逻辑几乎是一套模板复刻出来的很多规则可以共用。理解了其中一个另一个基本就会了大半。1.2 继承关系决定的行为差异这两个类的继承结构是很多人忽略的核心点。ScrollView 和 HorizontalScrollView 都是直接继承 FrameLayout因此它们天然具备 FrameLayout 的特性子视图默认放在左上角测量时子视图的大小决定自身 wrap_content 的大小。但是滚动容器的测量逻辑又被特殊处理过。ScrollView 在 onMeasure 里会把子视图的高度测量拿到一个很大的值去跑这样内容不管多高都能计算出一个总高度超出自身高度之后就开始走滚动流程。HorizontalScrollView 则在宽度方向做同样的处理。这个继承关系带来的两种直接影响是因为 FrameLayout 只能放一个直接子 view所以这两个控件内部不能直接写多个平级子 view。想放多个控件外面必须先包一层布局。因为它们具有 FrameLayout 的特性如果子 view 使用match_parent高度在 ScrollView 里不一定是你想的效果。有时候子布局高度被拉满整个 ScrollView 没法滚其实不一定是写错了而是我们对layout_height的语义理解不到位。实际开发中我见过最典型的错误就是直接在 ScrollView 下面写两个子控件想着它会像 LinearLayout 一样从上往下排结果编译通过但布局乱掉。正确思路永远是“ScrollView 里只有一个直接子 view”。1.3 适用场景到底什么时候该用它ScrollView 最适合的内容是长度不确定但总量可控的静态页面。例如文章详情、用户协议、注册表单、活动规则页。这些页面的内容不是无限列表也不需要元素复用让它们一次性 layout 出来反而简单可靠。HorizontalScrollView 适合的则是宽度未知但横向条目有限的场景。比如一个频道 Tab 导航几个 tab 可能超出屏幕用 HorizontalScrollView 包一下就能滑过去比 RecyclerView 写 adapter 轻量得多还有比如一组功能 icon横向排列数量不多的用 HorizontalScrollView 也更直观。反过来如果内容是成千上万条数据列表就别用这两个控件了。直接用 RecyclerView 会得到更好的复用和性能。很多人把 ScrollView 当成“万能容器”里面再塞一个 ListView这个操作在 Android 上属于经典雷区ListView 自身滚动和 ScrollView 的滚动完全冲突列表高度也会变得很诡异。文章第五部分我会专门对比替代方案。2. 布局细节和核心属性照着配置就成功一半2.1 必须掌握的 XML 属性这两个控件平时用到的属性不算多但有一批属性不能只用默认值硬扛。我整理了一个高频表格这些是我实测下来最常用的属性作用建议android:fillViewport内容不足一屏时让子 view 填充整个 ScrollView 的高度内容超出时正常滚动详情页、空状态页面通常设为 trueandroid:scrollbars控制滚动条是否显示可选 none / vertical / horizontal产品要求隐藏滚动条时设 noneandroid:overScrollMode控制边缘回弹效果never / ifContentScrolls / always想要整页像 iOS 那样回弹可选 alwaysandroid:clipToPadding让内容可以绘制到 padding 区域常用于配合顶部悬浮栏内容需要被 padding 挡住时设为 falseandroid:fadingEdgeLength控制边缘渐变消退的淡出长度视觉过渡需求时使用android:descendantFocusability处理内部可输入控件的焦点抢占问题有 EditText 时常用 blocksDescendantsfillViewport是我个人觉得最容易被忽略但价值最高的属性。默认情况下ScrollView 在内容不足一屏时高度就是 wrap_content子 view 不会把屏幕撑满设置了fillViewporttrue后子 view 至少会占满整个视口高度这样很多空状态页面、白底布局看起来就舒服很多。clipToPadding的坑也要单独说。ScrollView 如果设置了 padding比如想留出底部的安全距离内容滚到最底部时最后一个元素可能会被 padding 区域“裁剪掉”看起来像少了内容其实是被容器裁剪了。这时候把android:clipToPaddingfalse打开内容就能完整滚到 padding 区域内。2.2 单子视图限制与内容高度测量细节前面提过只能用单子 view但很多人仍然会踩。最常见的做法是ScrollView LinearLayout TextView/ ImageView/ /LinearLayout /ScrollView这其实是符合规范的因为 LinearLayout 是整个 ScrollView 的唯一直接子 view。如果你在 ScrollView 下面塞两个 TextView那第二个 TextView 基本不会按你想的排到第一个下面因为它们都是 ScrollView 的直接子 viewFrameLayout 会把它们错乱叠加。再往深处说内容高度测量是一个隐蔽的坑。当 ScrollView 的子 view 使用了动态布局比如运行时通过addView()不断往 LinearLayout 里追加条目很多人会发现 ScrollView 滚不到底部或者滚动范围还停留在第一次测量时的高度。原因是滚动容器的总高度在onMeasure阶段确定了后面子 view 内容变化后如果没有触发重新测量滚动范围就不刷新。我实际项目里的解法是在添加完数据后主动重置 ScrollView 的滚动范围contentLayout.requestLayout(); scrollView.post(() - scrollView.fullScroll(View.FOCUS_DOWN));先请求重新布局让 ScrollView 重新测量子内容再滚动到底。如果只是调用滚动方法不重新测量scrollView 的滚动范围仍然是旧的自然滚不过去。这条经验在聊天界面、动态高度列表里非常实用。2.3 滚动位置控制的小细节ScrollView 有四个常见方法scrollTo、scrollBy、smoothScrollTo、smoothScrollBy。很多人直接用后者做平滑滚动然后发现结果和自己想的不一样。scrollTo(x, y)不带动画直接把内容滚动到指定坐标。scrollBy(x, y)不带动画在当前坐标基础上相对偏移。smoothScrollTo(x, y)带平滑动画移动到指定坐标。smoothScrollBy(x, y)带平滑动画相对偏移。横向的 HorizontalScrollView 则是用 x 轴做同样的事。但是在自定义滚动时要注意如果先调用smoothScrollTo立刻又调用scrollTo前面的动画会被打断。因为scrollTo是直接改 scrollX/scrollY而平滑滚动本质是逐帧动画两者混用会导致跳动或停在中间。还有一个很容易踩的细节是fullScroll(int direction)只有 ScrollView 有HorizontalScrollView 没有。如果想让横向滚动到底需要自己计算内容宽度horizontalScrollView.post(() - { int contentWidth childView.getWidth() - horizontalScrollView.getWidth(); horizontalScrollView.scrollTo(contentWidth, 0); });不做这层计算就直接 scrollTo 一个很大的值结果不会报错但位置会不准。因为 scrollTo 的坐标上限是实际可滚动范围超过上限会被限制到最大滚动位置但在不同机型上这个计算时机不一样最好放到 post 里等布局完成。3. 真正好用的滚动监听与自动滚动方案3.1 监听滚动的三种方式想监听 ScrollView 的滚动事件至少有三条路可以走。第一种是直接注册OnScrollChangeListener这个监听从 API 23 开始提供写法最简单scrollView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) - { if (scrollY oldScrollY) { // 向下滚动 } else { // 向上滚动 } });这个回调的触发频率跟着滚动帧走回调里不要做耗时操作更不要在里面反复requestLayout否则很容易引起滚动卡顿。第二种是使用ViewTreeObserver.addOnScrollChangedListener()这个兼容低版本但所有可滚动 view 都会触发需要自己在回调里判断getScrollY()的变化。低版本项目里用这个比较常见。第三种是自己重写 ScrollView在onScrollChanged(int l, int t, int oldl, int oldt)里向外部暴露回调。这种方式最可控适合需要同时拿到 l/t 坐标的场景比如做视差标题、渐变背景等视觉效果。3.2 自动滚动与“到底部”判断自动滚到底部这个操作最常用的写法是scrollView.fullScroll(View.FOCUS_DOWN);但有时这个并不足以及时响应页面刚刚加载完的内容因为fullScroll也是在有滚动范围的前提下工作。我刚工作那会儿写聊天页面新消息来了直接调用fullScroll结果经常差一截滚不到最底部。排查之后发现是消息高度还没被 ScrollView 重新测量滚动范围没更新。正确的处理顺序是先发消息、再请求布局、然后在下一个布局帧滚动newMessageAdded(); scrollView.post(() - scrollView.fullScroll(View.FOCUS_DOWN));post 里的代码会在下一轮消息循环时执行此时布局一般已经完成。如果依然不稳定再加一层addOnGlobalLayoutListener回调里执行滚动同时记得移除监听避免每次全局布局都触发滚动。判断是否滚到底部可以用如下代码if (scrollView.getScrollY() scrollView.getHeight() scrollView.getChildAt(0).getHeight()) { // 到了底部 }这里减不减getPaddingBottom()要看你是否设置了 padding。如果设置了底部 padding需要把 padding 加上去否则会出现到不了底判断的边界问题。3.3 动态内容更新后的滚动位置修正动态内容更新最常见的就是加载更多、聊天消息新增、展开收起等场景。这里有一个很明显的经验不要试图在数据变化后立刻去滚动。数据变化只是改了内存模型View 树的测量和布局还没有发生。此刻读取 getChildAt(0).getHeight() 拿到的还是旧值。我现在的模式是先更新数据再执行contentWrapper.post(() - { int targetY contentWrapper.getHeight() - scrollView.getHeight(); scrollView.smoothScrollTo(0, targetY); });如果是在快捷回复、展开更多这种需要滚动到某个特定 child 的场景我会先拿到需要展示的 View再调用view.getTop()算出目标位置。但因为 Android 的布局变量和屏幕坐标不同还要考虑 ScrollView 当前 scrollY 的影响int targetY targetView.getTop() scrollView.getScrollY(); scrollView.smoothScrollTo(0, targetY);这样算的位移才是准确的位置。少了getScrollY()的话滚动位置会在第一次有效第二次开始明显偏差原因就是没有加上当前滚动造成的坐标偏移。4. 嵌套滚动与触摸事件冲突绕不开的硬骨头4.1 同向嵌套为什么一定会出问题很多需求会把 ScrollView 和 HorizontalScrollView 套在一起比如外层纵向滚动文章里面横向滑动一组图片。这种“垂直套横向”的组合一般问题不大因为手指滑动的方向不同事件分发天然能分流。真正的灾难是同向嵌套。外层 ScrollView 内再放一个 ScrollView或者横向套横向这种结构基本都会出现事件竞争。举个最经典的例子外层 ScrollView 用来滚文章里面放了一个同样纵向滚动的 RecyclerView。手指在 RecyclerView 上滑动时RecyclerView 想自己先响应用户滚动手势ScrollView 也想把滚动事件抢过来。最后表现就是卡顿、闪烁、内容跳位体验极差。Android 的事件分发机制里默认情况下父容器可以在子 View 没有消耗事件时拦截事件。子 View 消费了事件后父容器依然可以在后续的 ACTION_MOVE 中决定是否拦截只是要做一些判断。所以同向嵌套不是不能跑而是默认行为的判断逻辑很可能让你看起来像在乱跳。4.2 事件拦截机制与 requestDisallowInterceptTouchEvent如果同向嵌套无法避免先试最稳妥的写法在子 View 的onInterceptTouchEvent或者dispatchTouchEvent中调用requestDisallowInterceptTouchEvent(true)。这个方法的含义是“请父容器不要再拦截后续触摸事件”。但需要注意它只在当前触摸序列内有效手指抬起后就失效了所以要在 ACTION_DOWN 或 ACTION_MOVE 阶段合理调用Override public boolean dispatchTouchEvent(MotionEvent ev) { if (ev.getAction() MotionEvent.ACTION_DOWN) { getParent().requestDisallowInterceptTouchEvent(true); } else if (ev.getAction() MotionEvent.ACTION_UP || ev.getAction() MotionEvent.ACTION_CANCEL) { getParent().requestDisallowInterceptTouchEvent(false); } return super.dispatchTouchEvent(ev); }这样外层父容器在当前的滑动序列内就不能拦截事件了子 View 可以顺畅响应滚动。但也要注意条件不能在子 View 本来就不消费事件的场景里滥用否则会打断父容器正常的滑动响应。比如页面整体纵向滚动中间放了一个子 View 要横向滑动这种就不需要写requestDisallowInterceptTouchEvent让各个方向正常分流即可。还有一种思路是自定义父容器在onInterceptTouchEvent中根据滚动方向判断是否拦截。如果当前是子 View 应该响应的滚动方向父容器直接返回 false不拦截事件。这种方案更精细但代码量大一般我都不建议在业务里做优先改布局结构。4.3 利用 Nested Scrolling 优化交互从 Android 5.0 开始官方引入了 Nested Scrolling 机制android:nestedScrollingEnabled属性控制。RecyclerView 和 NestedScrollView 都实现了相关接口而传统 ScrollView 并没有完整实现这套协议。把普通 ScrollView 换成 NestedScrollView 可以解决很多嵌套问题。因为 NestedScrollView 会配合子 View 的嵌套滚动回调把滚动距离分发给父容器和子 View 协调处理。比如父容器是 AppBarLayout子内容变化时 AppBar 可以联动收起或展开这种效果用普通 ScrollView 很难做干净用 NestedScrollView 配合 CoordinatorLayout 反而自然。这里我踩过的一个比较深的坑是如果在 NestedScrollView 里放一个同向 RecyclerView表现虽然会比普通 ScrollView 放 ListView 好但依然可能出现滑动不流畅的情况。因为两个纵向滚动容器同时存在坐标换算会很复杂。真正的解法不是协议而是不要在纵向滚动容器里再放纵向滚动列表。把RecyclerView换成LinearLayout、或者用第三方库实现嵌套复用都比硬生生让两层滚动逻辑并存要稳定。5. 性能、替代方案与封装建议5.1 为什么大列表不能用 ScrollView 硬撑ScrollView 最大的性能问题是它会把内部所有子 View 一次性全部创建并测量。数据量大时比如 1000 条消息如果用 ScrollView LinearLayout 不断 addView光创建 View 对象的内存和耗时就很惊人。而且没有 ViewHolder 复用滑动没有回收机制页面很容易掉帧。RecyclerView 存在的意义就是解决这个问题。它只创建可视区域内需要展示的 View滑出屏幕的会被回收复用条目真正做到按需加载。所以只要内容是“列表型数据”并且数量可能超过 50 条我都建议直接用 RecyclerView。那 ScrollView 是不是完全没用也不是。核心区别在效率和结构。ScrollView 更适合内容本身就是一个整体文档、一个完整页面的场景而不是一堆重复数据卡片。区分它们就看一个问题这个页面内容的结构是固定的还是由一组同构数据动态渲染出来的。5.2 横向滚动到底选 HorizontalScrollView 还是 RecyclerView横向滚动同样存在两个选择。一个用 Android 原生 HorizontalScrollView一个用一个横向的 RecyclerView LinearLayoutManager。我把选择依据总结成表对比维度HorizontalScrollView横向 RecyclerView实现成本低几行 XML 就能跑高需要 Adapter 和 ViewHolder子 View 数量适合少量如几个 Tab适合大量无限滑动内存与复用无复用全部加载有回收复用性能好监听滚动自己设置 OnScrollChangeListener有 RecyclerView.OnScrollListener复杂交互不方便做 item 动画方便做 item 动画、拖拽、删除我做了几个 App 后形成的习惯是如果横向条目不超过 10 个并且以后也不会有大幅增长就用 HorizontalScrollView因为写法简单、调试直观如果横向列表数据来自接口、数量不可控或者以后要支持分页、无限滚动那就老老实实上 RecyclerView。这两个不是谁绝对替代谁的关系而是“规模”决定选型。5.3 一个可复用的双轴滚动容器思路真正常见的业务需求是外层纵向滚动内部有一个区域横向滚动。最标准的结构是外层用 NestedScrollView内部放 HorizontalScrollView 或横向 RecyclerView。外层 NestedScrollView 负责纵向整体滚动内层横向区域因为方向不同事件天然不冲突。这时候唯一要注意的是内层内容高度不要设为 match_parent否则会把外层撑得没法滚。给内层 HorizontalScrollView 一个固定高度再让内部内容横向排列这个结构基本就是稳的。如果需要更复杂的联动比如横向滚动改变顶部 Tab 的选中状态、纵向滚动驱动视差效果建议把滚动监听统一封装成一个工具类避免在每个 Activity 里重复写getScrollY()的计算。我通常的做法是封装一个ScrollListenerDelegate内部持有回调处理滚动方向判断、边界回调、以及 ScrollView / NestedScrollView / RecyclerView 三者的兼容。这种封装不是必须但项目里滚动回调散落各处后后面加需求会非常头疼。先抽出一个统一的接口后续不管是 ScrollView、RecyclerView 还是自定义滚动容器都可以直接套用。5.4 切换成 NestedScrollView 后要注意的差异如果你现在正被普通 ScrollView 的嵌套问题困扰最简单的升级方案是把ScrollView替换成NestedScrollView它继承自 FrameLayout同时实现了 NestedScrollingParent 和 NestedScrollingChild。但替换后有三点要检查setOnScrollChangeListener的 API 不同。NestedScrollView 自带setOnScrollChangeListener可以直接监听滚动变化别再用旧写法。fillViewport依旧有效但默认滚动范围包含内边距遇到判断底部时要重新核对边界值。NestedScrollView 和同向 RecyclerView 配合时RecyclerView 需要设置setNestedScrollingEnabled(false)这样整个列表高度才会被当成整体内容交给外层 NestedScrollView 统一滚动。很多人把setNestedScrollingEnabled(false)当成万能方案但它是用于“内层不再自己消费滚动让外层统一滚”的。如果你的内层 RecyclerView 本身需要独立滑动那这个设置就会适得其反。理解之后再去用才不会收到一堆莫名奇妙的卡顿反馈。讲了这么多核心其实就一句话开发中优先考虑结构调整别把滚动冲突交给事件分发硬扛。两个滚动容器都有自己的适用边界数据量大用 RecyclerView页面结构固定用 ScrollView嵌套时优先换 NestedScrollView 并保证方向不冲突。最后再分享一个小习惯凡是要在滚动容器里动态加内容的页面写完数据后记得用post再滚动这样 ScrollView 的滚动范围才是最新的这个细节我救过无数次聊天页面和动态加载页面的命。
返回列表