拓冰建站拓冰建站
首页 / 资讯中心 / 正文

手写 ListView 阻尼下拉刷新与上拉加载:从触摸事件到状态机

简介一份面向安卓学习者和毕业设计团队的列表视图交互源码示例围绕下拉刷新、上拉加载更多与阻尼回弹效果展开适合用来理解列表型界面的事件分发与动画反馈机制。压缩包共一百二十八个文件体积仅一点七八兆包含五十多个类文件、十二个Java源文件、十九个布局与配置XML、三十张图片资源并附有可安装的APK安装包和工程文件便于直接运行调试与二次修改。已有二百四十六人学习下载。通过分析项目可掌握安卓官方下拉刷新组件的标准接入方式、用滚动监听接口实现列表底部分页加载以及自定义阻尼动画的物理回弹思路对毕业设计而言这个示例能帮助快速搭建功能完整的列表展示页减少从零开发的工作量。同时工程中各类命名清晰与不同交互组合一一对应可对照查看刷新与加载功能的差别是巩固安卓界面和异步数据加载知识的实用资料。1. 这个需求还在给 ListView 自己实现下拉刷新和上拉加载阻尼感是关键ListView 不是新项目的主流选择但存量代码、老 App 维护、以及大量课程设计里它仍然是主力列表控件。默认 ListView 只提供滚动能力下拉刷新通常靠 SwipeRefreshLayout 包一层上拉加载则要自己监听滚动位置而这两部分的结合点——阻尼效果——恰恰是大多数示例代码里最单薄的地方。所谓阻尼是指手指拖拽列表时位移按比例衰减头部跟着手指移动但越来越“费力”松手后按惯性回弹或触发刷新。SwipeRefreshLayout 的下拉是相对线性的给不了这种渐进感。自己写这套方案需要接管触摸事件在 MOVE 中计算位移、在 UP 中判断状态最后用动画复位。这个过程没有黑科技但要处理的分支比想象中多。本文从触摸事件分发讲起逐步落到可复现的 DampListView 实现适合毕设需要手写刷新的同学也适合想在老项目里把手感做好的工程师。2. 阻尼下拉刷新的实现基础让 ListView 自己接管触摸事件2.1 为什么 SwipeRefreshLayout 做不出真正的阻尼感SwipeRefreshLayout 的下拉过程基本是 1:1 跟手手指移动多少距离指示器就移动多少距离超过阈值直接进入刷新状态。这种方式响应快但手感偏“硬”缺少 iOS 列表那种越拉越费力的渐进反馈。阻尼效果的实质是对手指位移做衰减映射让同一段物理滑动距离在列表顶部被“消化”一部分头部实际移动距离小于手指移动距离。实现这一点的前提是不能再用外部容器包住 ListView而是要让 ListView 自己处理下拉方向的触摸事件。要知道 ListView 自身的滚动也是在 onTouchEvent 里完成的只要我们能在 MOVE 阶段提前拦截并 parse 成头部位移ListView 的滚动逻辑就不会执行。这是整个自绘方案的基础也是和 SwipeRefreshLayout 最本质的区别一个是外层父容器拦截手势一个是在内部把手势“吃掉”并换一种表现方式。2.2 阻尼系数、最大位移与事件消耗的关系阻尼部分的计算公式并不复杂每次 MOVE 事件只处理手指移动的增量pullDistance dy * damping;dy 是手指相对于上一次事件移动的像素数damping 是阻尼系数取值范围通常在 0.3 到 0.5 之间。系数越小拉起来越费劲回弹动画越需要更长的时间来配合系数越大越接近 SwipeRefreshLayout 的直白跟手。对于普通列表我一般从 0.4 起步再根据真机效果调整。参数经验值手感表现damping0.35 - 0.45柔和跟随推荐用于普通数据列表damping0.2 - 0.3明显费力适合强调“拉出”动作的场景refreshThreshold60 - 80dp松手判定为刷新的距离maxPull90 - 120dp头部最大拉伸距离超过后不再跟随这里有一个关键点增量计算必须放在 MOVE 分支并且提前 return true否则 ListView 会把这次 MOVE 当作自身的滚动处理。事件被消费之后列表内容不会移动只有我们控制的头部视图在动。2.3 DampListView 的最小可运行实现下面这段代码是整套方案的核心骨架只处理下拉方向上拉加载在第 4 章扩展。public class DampListView extends ListView { private static final float DAMPING 0.4f; // 位移衰减系数 private static final int MAX_PULL dp2px(100); // 最大下拉距离 private static final int REFRESH_THRESHOLD dp2px(70); // 触发刷新距离 private static final int REFRESH_HOLD dp2px(60); // 刷新中停留位置 private float lastY; private float pullDistance; private boolean refreshing; private View headerView; private OnRefreshListener onRefreshListener; public DampListView(Context context, AttributeSet attrs) { super(context, attrs); // 关闭系统过滚光晕避免与自绘头部叠加出现双重动画 setOverScrollMode(OVER_SCROLL_NEVER); } public void attachRefreshHeader(View header) { headerView header; } Override public boolean onTouchEvent(MotionEvent ev) { switch (ev.getActionMasked()) { case MotionEvent.ACTION_DOWN: lastY ev.getY(); break; case MotionEvent.ACTION_MOVE: float dy ev.getY() - lastY; lastY ev.getY(); // 下拉 列表在顶部 头部已挂载 不在刷新中 if (dy 0 isAtTop() headerView ! null !refreshing) { // 阻尼核心位移乘以小于1的系数 pullDistance dy * DAMPING; pullDistance Math.min(pullDistance, MAX_PULL); // header 初始藏在列表上方下拉时让它慢慢露出来 headerView.setTranslationY(-headerView.getHeight() pullDistance); return true; // 消费事件ListView 自身不产生滚动 } break; case MotionEvent.ACTION_UP: if (pullDistance REFRESH_THRESHOLD onRefreshListener ! null) { refreshing true; // 不是回到 0而是停到“刷新中”的位置 headerView.animate() .translationY(-headerView.getHeight() REFRESH_HOLD) .setDuration(150) .start(); onRefreshListener.onRefresh(); } else { headerView.animate() .translationY(-headerView.getHeight()) .setDuration(300) .start(); } pullDistance 0; break; } return super.onTouchEvent(ev); } private boolean isAtTop() { if (getChildCount() 0) { return true; } View first getChildAt(0); // 位置 0 可见并且第一个 item 的 top 正好对齐列表顶部 return getFirstVisiblePosition() 0 first.getTop() 0; } public void stopRefresh() { refreshing false; headerView.animate() .translationY(-headerView.getHeight()) .setDuration(300) .start(); } private static int dp2px(int dp) { return Math.round(dp * Resources.getSystem().getDisplayMetrics().density); } public interface OnRefreshListener { void onRefresh(); } }逻辑说明ACTION_DOWN 只记录起始 Y 坐标不做任何位移计算。ACTION_MOVE 使用增量 dy 而不是“当前位置减去按下位置”这样处理的好处是即使中间发生了事件丢失或父容器拦截误差不会累积。pullDistance 被限制在 MAX_PULL 以内防止阻尼系数不够小时把头部拖出屏幕边界。返回 true 只发生在满足“下拉且列表在顶部”的条件时其余情况仍然走 super.onTouchEvent列表正常滚动。参数说明REFRESH_THRESHOLD 决定松手时是否触发刷新回调REFRESH_HOLD 是进入刷新状态后头部停留的位置通常比阈值稍小一点让 loading 动画停在更容易观察的高度。stopRefresh() 由外部在数据加载完成后调用它做的第一件事是关掉 refreshing 标志位再执行回弹动画。2.4 和 ListView 滚动状态共存的三个边界问题第一个坑是 isAtTop() 的判断条件。getFirstVisiblePosition() 0并不严格等价于“列表已经滚到顶部”如果通过 addHeaderView 添加了常驻顶栏位置 0 是顶栏而不是数据项。更稳妥的判断是加入第一个可见 child 的 top 校验也就是上面代码里first.getTop() 0这个条件。它确保位置 0 对应的 View 已经完全露出列表没有任何偏移。第二个坑是下拉过程中 item 的按压高亮。消费掉 MOVE 事件后ListView 的被按压状态不会自动清除屏幕上被按住的 item 会保持灰色高亮背景。需要在消费分支里调用setPressed(false)或者对当前 item 的 view 执行同样的操作。这个问题的排查方式很简单下拉时如果发现第一行背景变色就是这个原因。第三个坑是 fling 状态。列表处于快速滚动时手指按下的瞬间滚动速度会骤降但 ListView 的滚动状态还是 SCROLL_STATE_FLING。此时 firstChild.getTop() 可能已经是 0但列表实际上还在减速滑行。我的做法是额外记录滚动状态Override public void onScrollStateChanged(int scrollState) { state scrollState; } // isAtTop 中追加条件 if (state ! SCROLL_STATE_IDLE) { return false; }提示不要在 MOVE 阶段频繁调用 requestLayout头部位移用 setTranslationY 完成不需要重新测量列表条目。3. 把下拉距离转换为刷新状态机NORMAL、PULL、RELEASE、REFRESHING3.1 状态机的四个状态与转换路径刷新头不能只靠一个布尔值管理因为拖拽、松手、请求、结束四个阶段分别对应不同的 UI 表现。四态状态机是常见的实现方式状态进入条件典型动作NORMAL初始状态 / 刷新完成回弹结束头部完全隐藏在屏幕上方PULL下拉距离大于 0更新文案“下拉刷新”、进度圈角度随距离变化RELEASEpullDistance REFRESH_THRESHOLD文案切换为“松开立即刷新”REFRESHING松手且达到阈值停在 REFRESH_HOLD显示 loading 动画转换路径上PULL 和 RELEASE 可以互相切换因为手指可能在两个方向来回移动。REFRESHING 一旦进入必须等外部数据回调后执行 stopRefresh() 才能回到 NORMAL。这个外部回调是状态机唯一的出口因此不能被 ListView 内部逻辑绕过。3.2 HeaderView 的接口设计与进度计算为了让 DampListView 不依赖具体的头部 UI通常会定义一个接口把 UI 更新留给外部实现public interface RefreshHeader { void onPull(int pullDistance, int threshold); void onRelease(); void onRefreshing(); void onReset(); }在 ACTION_MOVE 分支里调用的是headerView.onPull((int) pullDistance, REFRESH_THRESHOLD)而不是直接对某个具体的 TextView 操作。这样 ListView 只负责计算距离头部怎么显示由外部决定。在默认实现里进度比例这样算float progress Math.min(1f, (float) pullDistance / threshold);以一个旋转的 ProgressBar 为例progress 对应旋转角度的比例。这里要注意 denominator 用的是 threshold 而不是 MAX_PULL。如果拿 MAX_PULL 做分母拉到一半进度条就已经满了继续下拉的反馈变得不明显视觉上会感觉刷新头“提前完成”。3.3 松手时的两路动画分支ACTION_UP 处理时根据 pullDistance 是否达到 REFRESH_THRESHOLD 分成两个分支。未达阈值时做回弹动画目标是让 header 完全隐藏动画时长通常在 250 到 350 毫秒之间插值器用 DecelerateInterpolator 比较合适因为“开始快、结束慢”的节奏符合手指松开的惯性。已达阈值时则执行两步操作先把头部停到 REFRESH_HOLD 位置再回调 onRefreshListener.onRefresh()。顺序不能反过来否则 loading 动画会在头部还没落稳时就启动出现一个明显的跳动。回弹动画不要用 scrollBy 实现。scrollBy 会改变 ListView 的内容偏移进而影响 isAtTop() 里first.getTop() 0的判定可能导致下一次下拉还没开始第一行就已经处于下拉状态。3.4 刷新完成后的复位顺序stopRefresh() 是外部数据回调的入口它的执行顺序有两个关键点。第一先把 refreshing 置为 false让下一次下拉可以被响应第二动画结束后调用 header.onReset()把内部文案和进度全部复位。public void stopRefresh() { refreshing false; headerView.animate() .translationY(-headerView.getHeight()) .setDuration(300) .withEndAction(() - { if (header ! null) header.onReset(); }) .start(); }如果 onReset() 放在动画开始前调用头部文案会先变成“下拉刷新”再经历 300ms 的回弹过程用户看到的是一段奇怪的文案切换。放在动画结束时才是正确的时序。另外回弹过程中如果用户再次下拉refreshing 已经是 false新的 MOVE 会重新计算 pullDistance此时正在执行的动画会和新手势冲突。稳妥的做法是在下拉开始时取消当前动画headerView.animate().cancel();这一行要放在增量计算之前保证每次下拉都重新开始。4. 上拉加载更多滚动监听、底部判断和 Footer 协作4.1 onScroll 中的三个位置参数ListView 上拉加载没有现成组件通常通过 OnScrollListener 实现。核心回调是 onScroll它提供三个关键参数firstVisibleItem 表示当前可见的第一个 item 的全局位置visibleItemCount 表示当前屏幕可见个数totalItemCount 表示整个 adapter 的条目总数。一个最基础的到底判断写法如下listView.setOnScrollListener(new AbsListView.OnScrollListener() { Override public void onScrollStateChanged(AbsListView view, int scrollState) { // 停在最后一屏时触发加载 if (scrollState SCROLL_STATE_IDLE isBottom() !loadingMore) { loadMore(); } } Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { } private boolean isBottom() { // 最后一个可见项正好是整个列表的最后一条 return listView.getLastVisiblePosition() listView.getCount() - 1; } });逻辑说明getLastVisiblePosition()返回当前最后一个可见 item 的全局位置getCount()返回 adapter 条目总数包括 header 和 footer。两者相等时说明最后一条数据已经出现在屏幕上此时处于列表底部。参数说明这个判断的触发条件比较严格要求最后一条完全可见才加载。对于条目高度不固定的列表可能用户已经滚到最后但最后一条只有几像素露出此时不满足SCROLL_STATE_IDLE条件。所以大多实现会加一个提前量。4.2 Footer 的 loading、noMore、error 三态上拉加载需要一个 FooterView 来反馈状态。常见结构是 ProgressBar 加一个 TextView状态有三种加载中、没有更多、加载失败点击重试。private void updateFooterState(int state) { if (footer null) return; progressBar.setVisibility(state STATE_LOADING ? View.VISIBLE : View.GONE); if (state STATE_LOADING) { footerText.setText(加载中...); } else if (state STATE_NO_MORE) { footerText.setText(没有更多数据了); } else if (state STATE_ERROR) { footerText.setText(加载失败点击重试); } }footer 的高度和布局用 inflate 生成注意 setVisibility 变化时不要触发 ListView 重新测量否则会在滚动过程中出现跳动。4.3 提前量滚动到倒数第 3 条就开始加载纯 IDLE 判断的缺点是不够“顺”用户快速滑动到底后还要等滚动完全停止才开始请求。更常见的做法是通过 onScroll 的连续回调做提前加载private static final int LOAD_AHEAD_COUNT 2; // 提前 2 条触发 private void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { if (totalItemCount 0) return; if (firstVisibleItem visibleItemCount totalItemCount - LOAD_AHEAD_COUNT !loadingMore !noMoreData) { loadMore(); } }逻辑说明firstVisibleItem visibleItemCount是当前最后一个可见项的全局位置当它接近 totalItemCount 时说明列表即将到底。LOAD_AHEAD_COUNT 是提前量设为 2 表示提前两条数据的位置触发加载用户滚到底部时数据已经请求完体验上更连贯。参数说明提前量不建议设置太大否则用户还在上滑半屏时就已经触发请求列表尾部缓存了大量未显示的数据内存压力反而增加。加载状态的边界靠 loadingMore 和 noMoreData 两个标志位控制它们与 4.2 节的 Footer 三态联动。4.4 addFooterView 的顺序坑和 wrapper adapter 问题addFooterView 必须在 setAdapter 之前调用。在已经 setAdapter 之后再添加 footerListView 不会重新触发 footer 的布局计算footer 直接不显示。它的内部原理是 ListView 用 HeaderViewListAdapter 包装传入的 adapteraddFooterView 操作的是这个 wrapper 上面的 footer 集合。如果外部用listView.getAdapter()拿到的是被包装过的 adapter再去做 notifyDataSetChanged 或数据类型强转会抛 ClassCastException。View footerView inflater.inflate(R.layout.item_footer, listView, false); listView.addFooterView(footerView); // 必须在 setAdapter 之前 listView.setAdapter(adapter);提示如果运行时代码重新 setAdapterfooter 会丢失需要重新 addFooterView。判断底部时getCount() 已经包含了 footer 数量。如果没有处理 footer 的存在isBottom()会在 footer 露出时就返回 true导致最后一次数据请求提前触发。加上 footer 数量补偿即可private boolean isBottom() { int footerCount listView.getFooterViewsCount(); return listView.getLastVisiblePosition() listView.getCount() - 1 - footerCount; }上拉方向的阻尼效果与下拉类似只是判断条件从“顶部”变成“底部”dy 方向相反。可以在 onTouchEvent 的 MOVE 分支中增加对底部条件的判断用 footerView.setTranslationY 实现尾部拉伸。需要注意底部阻尼和提前加载同时存在时的优先级手指拖动产生的阻尼位移应该优先展示等松手后如果满足加载条件再去请求数据否则一边拉一边弹出 loading视觉上会打架。5. 调参技巧用 Log 验证每一帧的位移把阻尼手感调到合适5.1 参数不要靠猜先把每帧数据打出来阻尼手感是抽象的东西但它的决定因素都可以量化。在 DampListView 的 onTouchEvent 中临时加一行 Log把每个 MOVE 事件的关键参数全部输出Log.i(DampDebug, String.format(Locale.CHINA, dy%.1f damp%.2f pull%.1f first%d top%d, dy, DAMPING, pullDistance, getFirstVisiblePosition(), getChildCount() 0 ? getChildAt(0).getTop() : -999));真机操作时观察几个指标dy 是否稳定在同一个数量级如果某一次 dy 特别大说明手指滑动太快需要考虑对过大的位移做降噪处理pullDistance 是否平滑递增出现突然跳变说明中途有事件丢失top 值在下拉开始前是否为 0不为 0 说明 isAtTop() 判断有偏差快速滑动场景下会误触下拉。5.2 四个手感验收标准从操作层面验证阻尼、回弹和加载的配合是否正常列表顶部快速滑动 20 次不能出现一次误触发下拉刷新下拉过程中第一行内容不上下抖动说明事件消费稳定松手后头部回弹到底不能残留 0.5px 的位移差刷新完成后立刻再次下拉回弹动画不能被新手势打断。其中第 4 条最容易出问题原因是 stopRefresh() 动画未执行完就收到新的事件。在 animation 开始前调用headerView.animate().cancel()可以解决大部分情况如果仍出现打断需要在 stopRefresh() 中加入动画状态检查。5.3 把最终参数暴露成自定义属性调参完成后把硬编码抽出来暴露成 attrs.xml 中的自定义属性方便不同页面配置不同手感declare-styleable nameDampListView attr namedamping formatfloat / attr namerefreshThreshold formatdimension / attr namemaxPull formatdimension / /declare-styleable在构造方法里通过 TypedArray 一次性读取这三个值替换掉代码开头的静态常量。对于上拉加载的提前量则放在 ListView 的滚动监听器或者 Footer 配置中单独声明。这样同一套 DampListView 可以被不同页面复用信息流页面用 0.4 的标准阻尼设置页面用 0.3 的轻阻尼在手感偏好差异较大的真机上也能单独微调而不用为每个页面复制一套代码是这套自定义方案里最值得收尾的一步。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门