简介:这是一份面向 Android 开发者的纵向滑动页面实现资源,重点讲解如何利用 ViewPager 完成上下滑动翻页、页码显示以及滑动细节优化,适合需要实现滚动列表、轮播图或翻页阅读器的中高级开发者。资源包内含 61 个文件,包括 Java 源码、XML 布局与资源配置、class 编译产物、jar 依赖库、图片资源以及可直接安装运行的 APK 与完整工程文件,整体约 1.31MB,目录结构清晰,便于直接导入开发工具验证效果。内容覆盖 ViewPager 的依赖配置、XML 布局设计、Fragment 适配器编写、TabLayout 关联页码等关键步骤,并针对 FlingPage 自定义滑动逻辑与手势检测进行了深入拆解,可帮助读者理解纵向滑动背后的实现原理。这份资源既能提供可运行的 Demo 演示,又能作为代码参考帮助读者根据自身需求扩展修改。目前已有 3776 人学习下载,是一份兼顾代码参考与原理剖析的实用资料。
1. 项目背景与方案选型思考
做Android开发绕不开一个基础交互——纵向滑动页面。不管是Feed流、商品列表、设置页面还是详情页,上下滑动几乎是每个App的标配操作。这个项目看起来只是实现一个“上下滑动效果”,但真正落地时会发现,滑动方案的选择直接决定了后续的扩展空间、性能表现和维护成本。
先说结论:Android里实现纵向滑动至少有四条路可走,分别是ScrollView、RecyclerView、NestedScrollView,以及基于ViewPager2的纵向翻页。我在实际项目中都试过,每种方案都有自己的适用场景,选错方案的代价往往不是当时能立刻发现的,而是在业务迭代到一半时突然卡住,不得不返工。
如果只是单纯展示一段固定内容,比如用户协议、隐私政策、活动规则,ScrollView是最简单直接的选择,一个TextView套进ScrollView,五分钟搞定。但如果内容是动态数据,数量不确定,甚至还需要下拉刷新、加载更多、点击跳转,RecyclerView几乎是唯一正解。它天生为大数据量列表设计,ViewHolder回收复用机制保证了滑动流畅性,不会因为item增多而卡顿。
NestedScrollView则是一个比较特殊的存在,它是ScrollView的增强版,核心价值在于支持嵌套滚动机制。当页面同时存在纵向滑动和内部横向滑动,或者需要协同处理父布局与子布局的滚动事件时,NestedScrollView是首选。经典场景就是CoordinatorLayout配合AppBarLayout实现折叠头部效果,列表滚动时标题栏逐渐收起,这个组合在项目里出现频率极高。
ViewPager2纵向化是比较容易被忽略的方案。它的默认方向是横向,但通过一行配置就能切换为纵向翻页,适合那种整页整页滑动的场景,比如短视频上下切换、小说阅读器的翻页体验。它和上述三种“连续滚动”的本质区别在于——ViewPager2是分页式滑动,一页就是一屏,松手后自动吸附到最近的页面。
这个项目之所以值得单独写一篇文章,是因为很多初学者在实现纵向滑动时只关注“能滚就行”,忽略了滚动嵌套冲突、滑动监听、性能优化这些真正影响用户体验的细节。接下来我会从滚动机制的原理说起,再到具体实现、常见问题排查,把纵向滑动页面这件事讲透。
2. 纵向滑动的核心机制与原理解读
2.1 触摸事件分发与滚动消费的底层逻辑
理解Android滑动,绕不开事件分发机制。用户手指在屏幕上滑动时,系统会依次调用dispatchTouchEvent、onInterceptTouchEvent和onTouchEvent这三个方法,它们构成了滚动消费的完整链路。
我习惯用一个比喻来解释:事件分发就像公司审批流程。手指按下(ACTION_DOWN)相当于提交申请,先到父布局(管理层)那里,父布局如果决定自己处理(onInterceptTouchEvent返回true),子View就收不到这个事件了;如果父布局不拦截,事件就交给子View自己处理(onTouchEvent),子View处理完了,流程结束,处理不了就再往上抛。
具体到纵向滑动场景,核心逻辑是:当子View(比如RecyclerView)判断出手指滑动的位移量主要发生在Y轴方向,且自身内容还有滚动空间,就消费掉这个事件,执行列表滚动;如果子View已经滚到底部了,父容器(比如NestedScrollView或CoordinatorLayout)会接管余下的滚动距离,实现联动效果。
这里有个关键概念叫“滑动距离分配”。在NestedScrollView嵌套RecyclerView的场景中,系统通过NestedScrollingChild和NestedScrollingParent接口协调两个View的滚动行为。子View先消费一部分滚动距离,消费不完的交给父View处理。这种协作机制避免了传统事件拦截方案中“父子互斥”的问题,让嵌套滚动成为可能。
2.2 为什么RecyclerView比ListView更流畅
老项目里很多还在用ListView,但新项目基本都转向RecyclerView了。从实现纵向滑动的角度看,RecyclerView的流畅性优势来自三个方面。
第一是ViewHolder机制。RecyclerView强制要求使用ViewHolder来缓存item的引用,滑动时只需复用缓存里的View,不需要重复findViewById。而ListView虽然也有convertView的概念,但没有强制约束,很多开发者偷懒直接重新创建View,性能自然差一截。
第二是布局差异。ListView只支持垂直方向的线性排列。RecyclerView通过LayoutManager抽象出布局策略,LinearLayoutManager支持纵向和横向,GridLayoutManager支持网格,StaggeredGridLayoutManager支持瀑布流。这是纵向滑动方案选型时的一个隐藏优势,后续想从列表改成双列瀑布流,只需要替换LayoutManager,不需要重写Adapter。
第三是动画支持。RecyclerView内置了ItemAnimator机制,item增删改时会自动播放动画,而ListView需要手动实现。滑动场景下的体验差异虽然不明显,但一旦涉及数据动态更新,有没有动画就是两个层级的体验。
2.3 嵌套滚动的核心:NestedScrolling接口体系
ScrollView和RecyclerView直接嵌套会有经典的滑动冲突问题——手指滑动时不知道应该滚外层还是内层。NestedScrollView的出现就是为了解决这个痛点。
NestedScrolling的运作机制可以理解成“先让子View滚,滚不动了再告诉父View帮忙”。具体流程是:手指滑动时,子View通过dispatchNestedPreScroll先把滚动距离通知父View,问父View要不要先消费一部分(比如AppBarLayout的收起动作);父View消费完剩下的距离后,子View自己再滚;子View滚到底了,通过dispatchNestedScroll把剩余距离返回给父View,让父View继续滚。
这个过程是双向协调的,和传统的onInterceptTouchEvent拦截机制完全不同。onInterceptTouchEvent是父View单方面决定“我要拦截”,而NestedScrolling是父子协商分配滚动距离。这也是为什么CoordinatorLayout + AppBarLayout + RecyclerView能做到那种丝滑的联动效果——头部折叠、列表滚动、底部加载是同一个滑动手势完成的三阶段动作。
3. 核心实操:三种纵向滑动页面的完整实现
3.1 基于ScrollView的简单纵向滚动
最基础的纵向滑动页面,用ScrollView就能实现。XML布局定义一个ScrollView,内部放一个LinearLayout承载内容。
<ScrollView android:id="@+id/scrollView" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:scrollbars="vertical"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <!-- 内容区域 --> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp" android:text="这是一段可以滚动的长文本内容" /> </LinearLayout> </ScrollView>有两个参数值得解释。fillViewport="true"的作用是让ScrollView内部布局的高度至少填充满整个屏幕,避免内容不够一屏时背景色或占位布局显示不完整。很多人在使用ScrollView做底部按钮固定时遇到问题,底部的按钮总是跟着内容一起滚走,就是没有理解fillViewport属性的作用,正确做法是让内部布局高度撑满视口,再把按钮放在布局底部。
ScrollView还有一个隐藏特性——它只能有一个直接子View。如果你在ScrollView里直接写了两个平行的TextView,运行时会直接崩溃。解决办法是先套一层LinearLayout或ConstraintLayout,再往里填充内容。这是新手最容易踩的坑之一。
如果你需要监听ScrollView的滚动位置来触发某些逻辑,比如滚动到顶部显示返回按钮,需要调用setOnScrollChangeListener,从回调里拿到当前滚动的Y坐标。
scrollView.setOnScrollChangeListener(new View.OnScrollChangeListener() { @Override public void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) { if (scrollY > dp2px(300)) { // 显示返回顶部按钮 } else { // 隐藏返回顶部按钮 } } });3.2 基于RecyclerView的Feed流纵向滑动
RecyclerView适合数据量大的列表场景。构建过程分五步:添加依赖、定义item布局、创建Adapter、设置LayoutManager、绑定数据。
首先在build.gradle里添加依赖,不同版本号对应不同AGP版本,这里用普遍兼容的版本:
implementation 'androidx.recyclerview:recyclerview:1.3.2'Adpter是RecyclerView的核心,这个示例展示了一个包含内容文本的简单item:
public class FeedAdapter extends RecyclerView.Adapter<FeedAdapter.FeedViewHolder> { private final List<String> mData; public FeedAdapter(List<String> data) { this.mData = data; } @NonNull @Override public FeedViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_feed, parent, false); return new FeedViewHolder(view); } @Override public void onBindViewHolder(@NonNull FeedViewHolder holder, int position) { holder.tvContent.setText(mData.get(position)); holder.itemView.setOnClickListener(v -> { // 处理item点击事件 }); } @Override public int getItemCount() { return mData == null ? 0 : mData.size(); } static class FeedViewHolder extends RecyclerView.ViewHolder { TextView tvContent; FeedViewHolder(@NonNull View itemView) { super(itemView); tvContent = itemView.findViewById(R.id.tvContent); } } }然后是Activity或Fragment里的设置:
RecyclerView recyclerView = findViewById(R.id.recyclerView); LinearLayoutManager layoutManager = new LinearLayoutManager(this); layoutManager.setOrientation(LinearLayoutManager.VERTICAL); recyclerView.setLayoutManager(layoutManager); recyclerView.setAdapter(new FeedAdapter(dataList));关键点在于setLayoutManager这一步。LinearLayoutManager指定了列表的排列方式,这个类非常核心,除了设置方向,还能控制是否自动测量(setAutoMeasureEnabled)、是否可以滑动(setSmoothScrollbarEnabled)。
这里我建议加上一个优化:如果item高度是固定的,设置setHasFixedSize(true)可以避免RecyclerView重复测量布局,提升滑动性能。但要注意,只有确定item高度不随内容变化时才能设置这个属性,否则会导致item显示异常,这个坑我踩过一次,排查了半天才发现是这里的问题。
3.3 基于NestedScrollView的复杂页面纵向滚动
实际项目中最常见的一种页面结构是:顶部是轮播Banner,中间是几个快捷入口图标,下面是一个信息流列表。这种页面如果整个用ScrollView包裹,数据量一大必然卡顿;如果只用RecyclerView,混合布局又很麻烦。NestedScrollView嵌套RecyclerView的组合刚好解决这个问题。
<androidx.core.widget.NestedScrollView android:id="@+id/nestedScrollView" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <!-- Banner区域 --> <androidx.viewpager2.widget.ViewPager2 android:id="@+id/bannerPager" android:layout_width="match_parent" android:layout_height="180dp" /> <!-- 功能入口区域 --> <LinearLayout android:id="@+id/quickEntryLayout" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="16dp" /> <!-- 信息流列表 --> <androidx.recyclerview.widget.RecyclerView android:id="@+id/innerRecyclerView" android:layout_width="match_parent" android:layout_height="wrap_content" android:nestedScrollingEnabled="false" /> </LinearLayout> </androidx.core.widget.NestedScrollView>这里有个非常关键的属性:android:nestedScrollingEnabled="false"。如果不在RecyclerView上关闭嵌套滚动,会出现滑动卡顿、阻尼感异常的问题。原因在于RecyclerView默认会尝试自己处理滚动事件,而外层NestedScrollView又希望接管滚动,两个滚动容器互相竞争,结果就是滚动不平滑。
关闭RecyclerView的嵌套滚动后,列表不再响应滑动,而是把滚动事件全部交给外层的NestedScrollView处理,整个页面只有一个滚动容器,事件流变得清晰,滑动自然就流畅了。
但这样做有一个副作用:RecyclerView的所有item会一次性全部加载,失去懒加载的优势。如果列表数据量特别大,会出现页面加载慢的问题。我的建议是:列表项控制在50条以内时用这种方式完全没有问题,超过50条建议使用一个RecyclerView + 多ItemType的方案替代嵌套结构。
3.4 基于ViewPager2的纵向翻页效果
ViewPager2纵向化实现起来非常简单,因为它本身就支持设置方向。核心代码就一行:
ViewPager2 viewPager = findViewById(R.id.viewPager); viewPager.setOrientation(ViewPager2.ORIENTATION_VERTICAL);设置完方向后,Adapter的写法和横向ViewPager2完全一样,不用做任何修改。这里值得展开的是它的LayoutManager和RecyclerView其实是同一个——ViewPager2内部本身就是基于RecyclerView实现的,它通过RecyclerView的LinearLayoutManager管理页面,再通过PagerSnapHelper实现吸附效果。
这意味着ViewPager2天然继承了RecyclerView的性能优势,同时也意味着它的每个页面都是一个独立的“item”。如果你想实现类似TikTok的上下滑动切换视频,用ViewPager2是最合理的方案。
不过我建议把ViewPager2的offscreenPageLimit属性一并设置,默认只缓存当前页面两侧各一页,如果视频加载有延迟,可以适当增大缓存页数来提前预加载:
viewPager.setOffscreenPageLimit(2); // 预加载当前页前后各2页对应的Adapter示例,这里演示了页面间通过Fragment来承载不同内容:
public class VerticalPagerAdapter extends FragmentStateAdapter { private static final int PAGE_COUNT = 10; public VerticalPagerAdapter(@NonNull FragmentActivity activity) { super(activity); } @NonNull @Override public Fragment createFragment(int position) { return PageFragment.newInstance(position); } @Override public int getItemCount() { return PAGE_COUNT; } }FragmentStateAdapter是androidx专门为ViewPager2封装的Adapter,和旧的FragmentPagerAdapter相比,它在页面销毁时能释放Fragment实例,节省内存,适合页数较多的场景。如果每个页面内容比较简单,也可以直接用RecyclerView.Adapter搭配Item布局来实现,性能会更好,因为没有Fragment的开销。
4. 纵向滑动场景的工具选型与性能优化
4.1 RecyclerView的diffing机制与刷新优化
纵向滑动列表在数据刷新时有一个常见的性能问题:数据返回后直接调用notifyDataSetChanged(),整个列表全部重绘,即使只有一条数据变化。在大列表场景下,这会造成明显的卡顿和闪烁。
合理的做法是使用DiffUtil或者它的协程版本ListAdapter。DiffUtil的核心原理是:在后台线程对比新旧数据集的差异,生成一个最小更新集(包括新增、删除、移动、修改操作),然后只对这些变化的位置执行动画更新,而不是全量刷新。
class FeedDiffCallback extends DiffUtil.ItemCallback<FeedItem>() { @Override public boolean areItemsTheSame(FeedItem oldItem, FeedItem newItem) { return oldItem.getId() == newItem.getId(); } @Override public boolean areContentsTheSame(FeedItem oldItem, FeedItem newItem) { return oldItem.equals(newItem); } }关键点是areItemsTheSame和areContentsTheSame两个方法的语义差别。areItemsTheSame比较的是“是不是同一条数据”,一般用id判断;areContentsTheSame比较的是“同一条数据的内容有没有变化”,一般用equals判断。如果id相同但内容不同,DiffUtil会执行item更新操作;如果id都不同,才会执行新增或删除。我在项目中习惯把业务数据封装成带equals方法的data class,这样areContentsTheSame直接调用equals即可。
上面的Callback配合AsyncListDiffer使用,或者直接继承ListAdapter:
public class FeedListAdapter extends ListAdapter<FeedItem, FeedListAdapter.VH> { public FeedListAdapter() { super(new FeedDiffCallback()); } // 其他方法和普通Adapter一样 }然后刷新数据时只需要一行:
adapter.submitList(newDataList);submitList内部会自动做diff计算和UI更新,整个过程异步执行,不会阻塞主线程,滑动体验会提升一个档次。
4.2 滑动监听的性能陷阱
实现纵向滑动页面时,很多场景需要监听滚动位置,比如实现“滚动到一定距离显示悬浮按钮”或者“首次滚动触发埋点上报”。最常见的做法是在OnScrollListener里计算当前滚动的Y坐标。
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { super.onScrolled(recyclerView, dx, dy); LinearLayoutManager lm = (LinearLayoutManager) recyclerView.getLayoutManager(); if (lm != null) { int firstVisible = lm.findFirstVisibleItemPosition(); int lastVisible = lm.findLastVisibleItemPosition(); int totalCount = lm.getItemCount(); // 判断是否滚动到底部 if (lastVisible == totalCount - 1) { // 触发加载更多 } } } });这里有一个性能陷阱:onScrolled回调在每次滚动像素变化时都会触发,如果在回调里做复杂的计算或IO操作,比如写入数据库、上传埋点数据,会造成严重的卡顿。正确做法是加一个节流机制,比如累计滚动距离超过一定阈值才执行操作,或者用Handler延迟执行,避免频繁触发。
另外,findFirstVisibleItemPosition和findFirstCompletelyVisibleItemPosition是两个容易被混淆的方法。前者只要item任何一个像素出现在屏幕上就会返回,后者要求item完全可见才返回。做“加载更多”判断时应该用findLastVisibleItemPosition,因为触底加载的时机是最后一个item刚露出来就触发,而不是完全显示后才触发。
4.3 FastScroll与滚动条的自定义
如果你的列表比较长,建议开启FastScroll快速滚动,让用户可以通过右侧的滑块快速定位。RecyclerView原生支持这个功能,但需要通过FastScrollLinearLayoutManager配合实现:
public class FastScrollLinearLayoutManager extends LinearLayoutManager { public FastScrollLinearLayoutManager(Context context) { super(context); } @Override public void scrollToPositionWithOffset(int position, int offset) { // 支持快速滚动定位 super.scrollToPositionWithOffset(position, offset); } }同时RecyclerView可以设置fastScrollEnabled和fastScrollStyle属性,配合一个自定义的FastScrollPopup显示当前位置。这个功能在联系人列表、文件管理器这类长列表场景非常实用。实现方法不复杂,核心是设置横竖滑块背景和字体样式。
相对而言,如果列表只有几十条数据,FastScroll的意义不大,反而会占用屏幕空间。建议列表超过一屏且数据项超过200条时再开启。
4.4 坐标系与scrollY的兼容问题
ScrollView和NestedScrollView获取滚动位置时用的是getScrollY(),但RecyclerView没有这个用法,它需要找LayoutManager拿第一个可见item的位置和偏移量。这导致在做一些跨View类型的通用逻辑时,比如“页面滚动到多少像素后上报阅读进度”,需要区分处理不同的滚动容器。
int getScrollY(View scrollView) { if (scrollView instanceof NestedScrollView) { NestedScrollView nsv = (NestedScrollView) scrollView; return nsv.getScrollY(); } else if (scrollView instanceof RecyclerView) { RecyclerView rv = (RecyclerView) scrollView; LinearLayoutManager lm = (LinearLayoutManager) rv.getLayoutManager(); if (lm != null) { int firstVisiblePosition = lm.findFirstVisibleItemPosition(); View firstVisibleView = lm.findViewByPosition(firstVisiblePosition); int offsetY = firstVisibleView == null ? 0 : firstVisibleView.getTop(); return firstVisiblePosition * lm.getHeight() + offsetY; } } return 0; }这种跨界面的兼容处理在组件化工程里尤其重要,因为一个公共的滚动进度上报组件可能被多个页面复用,而不同页面使用的容器可能完全不同。
4.5 坐标系与行为联动:协调布局
如果你要做的是一个复杂页面,头部有折叠效果,列表滚动时头部逐渐收起,那么CoordinatorLayout和AppBarLayout是标配。AppBarLayout通过layout_scrollFlags属性控制折叠行为,常用的标志位组合是scroll|enterAlways|snap。
<androidx.coordinatorlayout.widget.CoordinatorLayout android:layout_width="match_parent" android:layout_height="match_parent"> <com.google.android.material.appbar.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:id="@+id/appBarLayout"> <androidx.appcompat.widget.Toolbar android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" app:layout_scrollFlags="scroll|enterAlways|snap" /> </com.google.android.material.appbar.AppBarLayout> <androidx.recyclerview.widget.RecyclerView android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" /> </androidx.coordinatorlayout.widget.CoordinatorLayout>这里面的行为机制是:RecyclerView通过layout_behavior指定了appbar_scrolling_view_behavior,当列表滚动时,这个Behavior会接收到滚动事件的回调,然后根据scrollFlags计算出AppBarLayout的偏移量。Toolbar设置了scroll|enterAlways,意味着向下滑动时Toolbar可以先于列表进入视野(enterAlways),列表停止滚动时Toolbar会自动吸附到展开或收起状态(snap)。
这个机制实现起来代码很简单,但很多人不理解为什么RecyclerView滚动时AppBarLayout会跟着动。核心就在于那个layout_behavior属性,它告诉CoordinatorLayout“这个View是AppBarLayout的滚动伙伴”。去掉这个属性,AppBarLayout就失去联动效果了,这是排查联动失效bug时的第一个检查点。
4.6 工具链选型与AGP版本适配
在Android Studio中创建项目时,AGP版本和Gradle版本的匹配是一个经典配置问题。构建失败提示“Could not load compiled classes for settings file”或者AGP版本不兼容时,可以先检查gradle-wrapper.properties里的Gradle版本是否与AGP匹配。
记录一下我常用的版本对照和选择思路:AGP 8.0要求Gradle 8.0以上,AGP 8.1对应Gradle 8.0+,AGP 8.2对应Gradle 8.2+。如果你在Android Studio Hedgehog(2023.1.1 Patch 2)上创建项目,它默认支持AGP 8.0~8.1系列,如果用Tag太新的AGP版本(比如8.5+)可能出现兼容性问题。
如果遇到构建卡在下载依赖的问题,优先检查是否使用了国内镜像仓库,可以在settings.gradle里配置阿里云镜像:
pluginManagement { repositories { maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } maven { url 'https://maven.aliyun.com/repository/public' } mavenCentral() google() } }纵向滑动页面的开发不涉及特别新的API,所以即使你的开发环境版本较旧,核心方案依然可以用。但如果要做ViewPager2的纵向翻页,需要确保依赖版本在1.0.0以上。至于RecyclerView和NestedScrollView,这些都是androidx里的基础组件,兼容性比较好,不同版本间差异不大,可以直接使用。
5. 常见问题与排查技巧实录
5.1 滑动冲突的三种经典表现和解决方案
滑动冲突在纵向滑动页面中是最常见的问题,总结下来有三种表现形式:
第一种是“外层要滚,内层也要滚”。典型场景是NestedScrollView嵌套了RecyclerView,手指滑动时页面卡顿、跳动,两个滚动容器在抢事件。解决办法是内层RecyclerView设置nestedScrollingEnabled为false,把滚动权交给外层,或者统一使用CoordinatorLayout + AppBarLayout的联动方案。
第二种是“横向事件被纵向容器抢走”。比如RecyclerView的item里有一个横向滑动的Banner,手指横向滑动时却被外层纵向列表拦截了。解决办法是让外层容器在判断出水平滑动距离大于垂直滑动距离时不拦截事件,通过onInterceptTouchEvent里的滑动角度判断来实现。
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_MOVE) { float dx = ev.getX() - lastX; float dy = ev.getY() - lastY; if (Math.abs(dx) > Math.abs(dy)) { // 横向滑动不拦截 return false; } } return super.onInterceptTouchEvent(ev); }第三种是“ListView嵌套在ScrollView里无法滚动”。这是老项目中常见的问题,本质上是ListView自身没有测量高度,在ScrollView里被无限撑开。如果是新项目,直接换RecyclerView,然后设置nestedScrollingEnabled为false即可;如果必须用ListView,可以自定义一个不可滑动的ListView,高度设置为wrap_content,让外层ScrollView接管滚动。
5.2 RecyclerView滑动卡顿的定位思路
滑动卡顿是纵向列表的高频问题,排查思路一般从三条线展开。
第一条线是布局层级。item布局层级过深会导致measure和layout耗时变长。可以用Layout Inspector查看item的视图层级,原则是能拍平就拍平,所有不需要嵌套的布局尽量用flat结构,合理使用ConstraintLayout控制层级深度。
第二条线是主线程耗时操作。onBindViewHolder里不要做耗时操作,图片加载要使用异步库(Glide或Coil),不要用BitmapFactory直接加载大图。如果你的列表滑动时出现掉帧,优先排查onBindViewHolder里的代码。
第三条线是item高度是否固定。如果item高度固定,设置setHasFixedSize(true)可以跳过列表变化时的重新测量;如果item高度不固定,不要在onBindViewHolder里频繁修改布局参数,这会导致同一item被反复measure。
滑动卡顿有一个共性经验:用Android Studio自带的Profiler录制一段滑动的CPU和GPU调用,看到主线程有长时间任务的,基本就是onBindViewHolder里的重复创建对象或磁盘操作导致的。
5.3 底部按钮或ViewPager2的滑动冲突
底部固定按钮和纵向滑动的组合很常见,比如详情页底部有一个“立即购买”按钮,页面内容可以滚动,按钮固定在底部不跟着滚。这个需求的实现方式是根据内容容器选择不同的策略。
如果外层是ScrollView,内部布局设置fillViewport为true,高度撑满屏幕,按钮放在内部布局底部,就能实现“内容滚动、按钮固定”的效果。
如果外层是RecyclerView,固定底部按钮和列表滚动之间的冲突比较少见,最常见的是ViewPager2嵌套在NestedScrollView内部时出现的滑动问题。ViewPager2内部也是RecyclerView,和NestedScrollView嵌套同样会引发滚动冲突,处理方法也是在ViewPager2上关闭nestedScrollingEnabled,但关闭后ViewPager2的页面切换手势会受影响,需要把ViewPager2的高度设置为wrap_content并对其内容进行测量。
这个场景我建议做适配处理,不要直接把两个滚动容器嵌套,而是重构页面结构:外层用CoordinatorLayout,ViewPager2作为独立区域,下方的内容列表单独使用RecyclerView,通过Behavior建立联动关系。这样既保留了纵向滑动的体验,又避免了嵌套滚动冲突。
5.4 为何adapter.notifyDataSetChanged()后列表没反应
这个问题的出现频率很高,第一次遇到时容易一头雾水。排查方向优先级:首先确认数据源是否真的更新了——有同事在adapter外新建了一个List,把新数据赋给这个新List,但adapter内部持有的还是旧List引用,notifyDataSetChanged后自然没有任何变化。解决办法是用adapter的setData方法更新数据源并通知刷新,或者让数据源List的引用保持不变,直接修改其内容。
第二个可能的原因是onCreateViewHolder里返回了错误的布局,导致数据更新后显示的还是旧布局。这种错误不易发现,建议通过把viewType参数传入加载逻辑来规避。
第三个可能性比较隐蔽——数据更新发生在子线程,直接调用了notifyDataSetChanged,而RecyclerView必须在主线程操作。Android会抛出 CalledFromWrongThreadException,但有些项目里异常被吞掉,表现为列表没刷新。规范做法是子线程更新数据后,通过runOnUiThread或Handler切回主线程再刷新。
5.5 滑动到底部加载更多的正确姿势
很多人在RecyclerView上实现加载更多时会写这样的逻辑:在onScrolled回调里判断最后可见item和总item数的关系。这个思路本身没错,但有几个注意点。
第一,每次onScrolled都会触发判断,需要加一个isLoadingMore标志位防止重复请求。第二,网络请求是异步的,请求回来后要重新判断列表状态,如果数据已经更新,要正确计算新增数据后的总item数。
这里我推荐一个更省心的方案:用ListAdapter配合Paging 3库。Paging 3是Google官方的分页加载组件,它把加载状态、重试、占位item都封装好了,只需要实现DataSource和配置PagingConfig即可。纵向滑动列表的分页加载逻辑会简单很多。
如果不想引入Paging 3的重型依赖,自己实现加载更多的模板代码也不复杂:
// 在Adapter中增加一个footer item type private boolean mIsLoadingMore = false; private static final int TYPE_FOOTER = 0x01; @Override public int getItemViewType(int position) { if (position == getItemCount() - 1) { return TYPE_FOOTER; } return super.getItemViewType(position); }footer item可以显示一个加载中的进度条,或者“没有更多数据”的提示文案,这是比较常见的交互设计。
6. 实际项目中的体会与扩展建议
做了这么多年的Android开发,我对纵向滑动页面最大的体会是:永远不要把一个滑动方案固定为唯一解。每个项目都有自己的业务特征、数据量级和交互预期,选型时要综合评估。
如果团队里都是新人,优先选择最稳妥的方案——RecyclerView + LinearLayoutManager,约定好Adapter的写法,减少花活儿。如果是处理复杂页面,NestedScrollView + RecyclerView是一个短期见效最快的方案,但要注意控制列表数据量。如果产品要求极高的滑动性能和复杂的联动效果,可以投入成本做自定义Behavior和布局优化。
项目后期如果要加入新的交互,比如下拉刷新、上拉加载更多,可以在现有方案上集成SwipeRefreshLayout或者第三方库SmartRefreshLayout,它们的核心都是通过嵌套滚动机制与RecyclerView协作。如果你理解了NestedScrolling的工作流程,这些库的适配会非常顺手。
最后分享一个小技巧:纵向滑动页面在真机上的测试非常重要,模拟器无法还原真机的触摸采样率和屏幕刷新率差异。尤其是快速甩动手指时,列表的惯性滑动是否跟手、是否掉帧,这些体感问题必须上真机才能发现。我习惯在测试时把开发者选项里的“显示布局边界”和“GPU渲染分析”打开,能快速定位布局过度绘制和渲染耗时的问题,对滑动流畅度的提升有直接帮助。
本文还有配套的精品资源,点击获取