一步步打造自己的通用上拉加载布局

简介: 背景下拉刷新是App交互中非常常见的场景,而与其对应的上拉加载,在很多场景中也已经是用户意识中理所当然的一种交互了。在很久之前的项目开发中,就已经有上拉加载的这个需求。但是那时苦于没有找到一个合适的上拉加载的库,而项目迭代又紧,那时自己实现恐时间上来不及或者引入其他bug,就暂时用了秋百万的cube-sdk中的点击加载。

背景

下拉刷新是App交互中非常常见的场景,而与其对应的上拉加载,在很多场景中也已经是用户意识中理所当然的一种交互了。

在很久之前的项目开发中,就已经有上拉加载的这个需求。但是那时苦于没有找到一个合适的上拉加载的库,而项目迭代又紧,那时自己实现恐时间上来不及或者引入其他bug,就暂时用了秋百万的cube-sdk中的点击加载。
在今年该项目的又一次迭代开发中,由于使用到了RecyclerView,而对应的RecyclerView.Adapter又无法使用cube-sdk中的adapter,因此用不了其点击加载,考虑到自己这两年所积累的相关知识及对上拉加载的思考应已足够,就花了些时间,实现了一个相对简单的上拉加载布局。

思考

我对上拉加载的思考受影响于两年前读过的秋百万的一篇文章《我眼中的下拉刷新》。但是上拉加载与下拉刷新的差异,不止是拉的方向不同,它们所拉出来的Header或Footer在加载完成后的消失方式也会不同,这就导致了在实现层面上会有些区别。

先说下拉刷新,通常是先让一个HeaderView位于ContentView外部而不显示出来,然后在下拉的时候让它与ContentView(或只有HeaderView)跟着移动下来,然后到一定距离触发刷新,HeaderView回滚到顶部停留,等刷新完成再慢慢滑动出去。

而上拉加载,通常的场景是用于AbsListView或RecyclerView。它与下拉刷新的最大不同是,所加载出来的内容会插入到当前所显示的AbsListView或ReyclcerView中,并显示在原来最后显示的内容与FooterView之间。
以RecyclerView举例,当我们在上拉加载更多的布局里放一个RecyclerView与一个FooterView,并把FooterView设置在布局底部范围之后,然后让它随着RecyclerView一起上拉,并显示出来,这点并没有问题。这时的界面如下图:

这里写图片描述
这时我们思考一个问题:当数据加载完成,更新到RecyclerView中时,界面应该如何处理?
通常而言,这时候应该是新加载的数据从FooterView的位置开始显示,而FooterView消失。但我们让FooterView消失(移出显示范围之外),而让RecyclerView移回来,所加载的新内容就会在屏幕外面,需要用户再去手动滑动上来才能看到。这种体验就很不好了。
因此我个人觉得,这个FooterView不应该由我们的上拉加载的布局去控制,而是交由具体场景去实现,在上拉加载的布局当中,应只做ContentView的位移,以及相关的界面及功能接口的回调。而除此外我们需要做的,是提供一些接口,来实现上拉UI需求上的灵活性及可定制化。

基本接口

为了让UI上有更大的灵活性,我们需要对上拉加载的UI变化进行一些解耦。参考秋百万的下拉刷新的库,又考虑到目前实现比较简单的上拉加载,所以我先定义了以下两个接口:
一是上拉加载的UI回调接口,它应该至少有三个状态变化的回调:可以上拉,已经触发加载回调,上拉完成。除此之外,为配合实现一些更好的提示或动画,它至少需要提供两个值:能够触发加载的位移量,以及当前的位移量。当然,多一些其他参数,比如当前的位移方向、速度等的话,可以实现更多的效果,不过这里只是先完成基本功能,所以实现上就先简单点。根据所需要的这些回调,LoadMoreUIHandler接口定义如下:

/*
 * Copyright (c) 2017. Xi'an iRain IOT Technology service CO., Ltd (ShenZhen). All Rights Reserved.
 */
package com.githang.hiloadmore;

/**
 * @author Geek_Soledad (msdx.android@qq.com)
 * @since 2017-05-03 0.1
 */
public interface LoadMoreUIHandler {
    void onPrepare();

    void onBegin();

    void onComplete(boolean hasMore);

    void onPositionChange(int offsetY, int offsetToLoadMore);
}

第二个接口是触发加载的回调接口,只有一个方法,如下:

/*
 * Copyright (c) 2017. Xi'an iRain IOT Technology service CO., Ltd (ShenZhen). All Rights Reserved.
 */
package com.githang.hiloadmore;

/**
 * @author Geek_Soledad (msdx.android@qq.com)
 * @since 2017-05-02 0.1
 */
public interface LoadMoreHandler {

    void onLoadMore();
}

具体实现

我们首先来实现上拉。注意,由于API 14已能适配目前市场上所有Android设备,所以这里像判断是否可以上下拉动或对View进行位移操作,会直接使用到一些API 14以上才有的接口。

首先布局直接继承自FrameLayout。其次,上拉过程需要知道当前的状态,能触发拉动的位移量,当前位移量,是否可以上拉等,所以定义变量,构造方法及一些基本的getter和setter方法如下:

public class LoadMoreLayout extends FrameLayout {

    private static final byte STATUS_INIT = 0;
    private static final byte STATUS_PREPARE = 1;
    private static final byte STATUS_LOADING = 2;
    private static final byte STATUS_COMPLETE = 3;

    private byte mStatus = STATUS_INIT; //上拉状态

    View mContent;
    private int mCurrentOffsetY; //当前位移量
    private int mOffsetYToLoadMore = 200; // 触发加载至少需要的位移量
    private float mResistance = (float) Math.PI; // View实际的位移量=手指拖动的量/它

    private float mDownY; //手指按下时的Y坐标

    private int mDragSlop; //判断触发拖动操作的阙值

    private boolean mHasMore; // 是否可以加载更多

    private LoadMoreHandler mLoadMoreHandler;
    private LoadMoreUIHandler mLoadMoreUIHandler;

    public LoadMoreLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        mDragSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
    }

    public void setHasMore(boolean hasMore) {
        mHasMore = hasMore;
    }

    protected boolean hasMore() {
        return mHasMore;
    }

    public void setOffsetYToLoadMore(int offsetYToLoadMore) {
        mOffsetYToLoadMore = offsetYToLoadMore;
    }

    public void setResistance(float resistance) {
        mResistance = resistance;
    }

    public void setLoadMoreHandler(LoadMoreHandler loadMoreHandler) {
        mLoadMoreHandler = loadMoreHandler;
    }

    public void setLoadMoreUIHandler(LoadMoreUIHandler loadMoreUIHandler) {
        mLoadMoreUIHandler = loadMoreUIHandler;
    }
    //...
}

接下来,我们需要找到我们的ContentView,这里提供两种方式:一是获取布局里的第一个子View,二是提供一个设置ContentView的方法:

    public void setContentView(View view) {
        mContent = view;
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        final int childCount = getChildCount();
        if (childCount < 1) {
            throw new IllegalStateException("LoadMoreLayout needs at least one child");
        }
        if (mContent == null) {
            mContent = getChildAt(0);
            mContent.bringToFront();
        }
    }

接下来重写onLayout方法,确保在整个过程当中不会因layout操作导致内容位移位置不正确。

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int offsetY = mCurrentOffsetY;
        int paddingLeft = getPaddingLeft();
        int paddingTop = getPaddingTop();

        if (mContent != null) {
            MarginLayoutParams lp = (MarginLayoutParams) mContent.getLayoutParams();
            final int left = paddingLeft + lp.leftMargin;
            final int top = paddingTop + lp.topMargin + offsetY;
            final int right = left + mContent.getMeasuredWidth();
            final int bottom = top + mContent.getMeasuredHeight();
            mContent.layout(left, top, right, bottom);
        }
    }

接下来就是对手指的事件处理了,这也是完成上拉加载的关键之一。

首先是事件拦截,我们要先判断是否可以进行上拉或由LoadMoreLayout下拉,如果可以,则拦截事件,不让事件再往下传递,所以这里重写onInterceptTouchEvent(MotionEvent ev)方法:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (!isEnabled() || mContent == null || !mHasMore) {
            return super.onInterceptTouchEvent(ev);
        }

        boolean intercept = false;
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mDownY = ev.getY();
                // TODO 停止往回滑动
                break;
            case MotionEvent.ACTION_MOVE:
                int offsetY = (int) (ev.getY() - mDownY);//当前拖动距离
                if (Math.abs(offsetY) < mDragSlop) {
                    //小于可判定为拖动的阙值则不处理
                    break;
                }
                boolean moveUp = offsetY < 0;
                boolean canMoveDown = mCurrentOffsetY < 0;
                if (moveUp && mContent.canScrollVertically(1)) {//如果子View可以继续往下滑动,则不拦截
                    break;
                }
                if (moveUp || canMoveDown) {
                    intercept = true;
                }
                break;
        }

        return intercept || super.onInterceptTouchEvent(ev);
    }

然后重写onTouchEvent(MotionEvent ev)方法,进行上拉加载的逻辑,以及移动ContentView的位置。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                float offsetY = event.getY() - mDownY;
                if (mStatus != STATUS_LOADING && mStatus != STATUS_PREPARE) {
                    mStatus = STATUS_PREPARE;
                    mLoadMoreUIHandler.onPrepare();
                }
                movePos((int) (offsetY / mResistance));
                if (mStatus == STATUS_PREPARE) {
                    mLoadMoreUIHandler.onPositionChange(mCurrentOffsetY, mOffsetYToLoadMore);
                }
                return true;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                onRelease();
                return true;
        }
        return super.onTouchEvent(event);
    }

movePos(int)实现对ContentView的位移,如下:

    private void movePos(int offsetY) {
        if (offsetY > 0 && mCurrentOffsetY == 0) {
            return;
        }
        if (offsetY > 0) {
            offsetY = 0;
        }
        mContent.setTranslationY(offsetY);
        mCurrentOffsetY = offsetY;
    }

onRelease()是手放开后判断是否触发加载,以及让ContentView归位的操作:

    private void onRelease() {
        performLoadMore();
        // TODO 让ContentView归位
    }

    private void performLoadMore() {
        if (mStatus != STATUS_PREPARE) {
            return;
        }
        if (Math.abs(mCurrentOffsetY) >= mOffsetYToLoadMore) {
            mStatus = STATUS_LOADING;
            mLoadMoreHandler.onLoadMore();
            mLoadMoreUIHandler.onBegin();
        } else {
            mLoadMoreUIHandler.onPrepare();
        }
    }

以上完成了上拉时对ContentView的位移,以及回调加载方法。但这只是完成了从最初的状态到开始的状态,我们还需要知道加载完成,这样才能让状态重置,以及知道是否还可以继续加载。所以还需要有如下方法:

    public void loadMoreComplete(boolean hasMore) {
        mHasMore = hasMore;
        mLoadMoreUIHandler.onComplete(hasMore);
        mStatus = STATUS_COMPLETE;
    }

除此之外,我们还增加一个方法,用于外界触发它开始加载,可用于自动加载的实现。

    public void triggerToLoadMore() {
        if (!mHasMore || mStatus == STATUS_LOADING) {
            return;
        }
        mStatus = STATUS_LOADING;
        mLoadMoreHandler.onLoadMore();
        mLoadMoreUIHandler.onBegin();
    }

到这里,我们已经完成了从初始状态到上拉到加载到完成的整个过程。但是如果你够细心会发现,目前为止并没有提到如何让ContentView回去,并且上面的代码中有两处TODO的标记。因此如果一直上拉,最终是会把ContentView给拉出外面的。所以,我们接下来还要实现让ContentView回来的代码。

我们知道,让一个View产生位移有多种方式,比如设置它的margin,设置父布局的padding,调用它的layout方法,或者是如上面我们的实现中使用setTranslationY(float) 方法。而让View滑动回去,由于此过程当中并不需要跟着手指来移动,所以也会有几种选择。
首先,既然前面我们是使用setTranslationY(float)来设置它的位置,那么最终肯定也是需要调用这个方法来恢复原位的。而在中间的过程当中,可供选择的处理方式至少有:

  • 先调用该方法直接设置回去,然后播放一个位移动画。简单粗暴。
  • 使用Scroller计算每次的位移量,然后调用这个ContentView的setTranslationY(float)方法设置它的位置让它慢慢回去。

由于第二种方式它所处的位置与我们所记录的位移量是对应上的,并且在回滚过程当中当我们的手指按下去,是可以让它停住的,相对而言更为真实,所以这里选用第二种方式。
参考了秋百万的下拉刷新的库,这里定义了一个内部类,代码如下:

    class ScrollChecker implements Runnable {
        private static final int MOVE_DELAY = 12;

        private final Scroller mScroller;

        private int mStart;
        private boolean mIsRunning;

        ScrollChecker() {
            mScroller = new Scroller(getContext());
        }

        @Override
        public void run() {
            boolean isFinish = !mScroller.computeScrollOffset() || mScroller.isFinished();
            int curY = mScroller.getCurrY();
            if (!isFinish) {
                movePos(curY + mStart);
                postDelayed(this, MOVE_DELAY);
            } else {
                reset();
            }
        }

        private void reset() {
            mIsRunning = false;
            mStart = 0;
        }

        void tryToScrollTo(int to, int duration) {
            if (mCurrentOffsetY == to) {
                return;
            }
            removeCallbacks(this);
            if (!mScroller.isFinished()) {
                mScroller.forceFinished(true);
            }
            mStart = mCurrentOffsetY;
            mScroller.startScroll(0, 0, 0, to - mStart, duration);
            post(this);
            mIsRunning = true;
        }

        void abortIfRunning() {
            if (mIsRunning) {
                if (!mScroller.isFinished()) {
                    mScroller.forceFinished(true);
                }
                reset();
            }
        }
    }

它的代码很简单,首先有一个Scroller,用于计算位移量。然后当触发回滚时,我们每12毫秒就执行我们的这个Runnable的回调,获取当前Scroller的结果,设置到位移中去。并且它还提供了一个方法abortIfRunning(),用于在回滚过程中当手指继续操作我们的LoadMoreLayout时让ContentView暂停下来。
最后,我们修改一下前面的代码,实现ContentView的归位。

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (!isEnabled() || mContent == null || !mHasMore) {
            return super.onInterceptTouchEvent(ev);
        }

        boolean intercept = false;
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mDownY = ev.getY();
                mScrollChecker.abortIfRunning();//当手指继续按下时,取消回滚
                break;
        //...这里代码和前面一样
    }

    private void onRelease() {
        performLoadMore();
        mScrollChecker.tryToScrollTo(0, mDuration);
    }

最终成果

完整代码已经上传到Github,项目地址为:https://github.com/msdx/hi-loadmore
项目运行效果如下:
运行效果

后续扩展

我在前面提到,上拉加载的Footer可能不适合在LoadMoreLayout里实现,所以在我的实现当中也是不包含这一方面的代码的。一般可以实现LoadMoreUILayout接口,来自定义自己的FooterView。而对于像ListView或RecyclerView,个人倾向于使用ListView的FooterView或在RecyclerView的Adapter中添加FooterView来实现。后续会更新Github上的项目,补充对LoadMoreLayout的扩展以实现RecyclerView的上拉加载。但是否会再写一篇,视补充的内容多少而定,若可写内容较少或简单,则只更新项目。有相关疑问或建议请移步github该项目上提issue。

参考资料

目录
相关文章
|
3天前
|
人工智能 自然语言处理 安全
阿里云AI数智鉴密:AI 生成内容如何拿到一张"防篡改的身份证"
隐形水印 + C2PA签名:让AI生成内容“持证上岗”。
1107 0
|
12天前
|
人工智能 自然语言处理 安全
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
本文聚焦阿里云2026年推出的三款自研AI办公产品,清晰拆解千问办公、Qoder Teams、Qoder CN的差异化定位与能力边界:千问办公主打职场全场景提效,支持自然语言指令一键完成PPT生成、数据分析等高频办公任务;Qoder Teams面向程序员团队,深度整合AI代码生成、团队协同与企业知识库能力;Qoder CN则专为金融、政务等强合规场景打造,实现数据不出境与VPC私有化部署。文章同步给出分场景选型指南与最新活动定价,帮助不同类型的企业按需组合产品,实现业务岗、研发岗与强合规场景的AI能力全覆盖。
3701 3
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
|
24天前
|
人工智能 缓存 前端开发
DeepSeek Harness 首发实测 + 入门教程,夯爆了!梁神我错了
DeepSeek Harness + DeepSeek V4 Pro 项目实战保姆级教程!手把手带你从零安装开源 AI 编程工具,开发架构图、知识讲解网站、3D 网页游戏、全栈 AI 应用 4 个项目,覆盖运行模式选择、插件安装与开发,看看能不能对标 Claude。
13498 93
DeepSeek Harness 首发实测 + 入门教程,夯爆了!梁神我错了
|
17天前
|
Web App开发 人工智能 API
16 个超火的 DeepSeek Harness 插件,大肥鱼已经落后 N 个版本了。。。
DeepSeek Harness 精选插件推荐合集,从图片识别、浏览器操控、多 Agent 协作到手机远程控制,一口气带你看完 DSH 社区热门的十几个插件,覆盖技能扩展、UI 界面增强、整活玩法三大类,让你的鲸鱼变得更强。
1967 5
|
3天前
|
人工智能 运维 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8,六大核心能力重构企业全自动化工作流与计费选型指南
传统AI办公工具大多停留在对话问答、文档摘要、简单文案生成层面,只能完成单点碎片化任务,无法自主拆解复杂业务流程,很难串联多工具、多文档、外部业务系统完成端到端完整工作交付。很多企业在落地AI办公的时候,需要组合多款不同工具,来回切换界面,手动复制粘贴中间结果,智能化改造落地门槛居高不下。千问办公QwenWork是整合多款智能体产品能力打造的一体化企业办公智能体平台,底层基座依托Qwen3.8大模型,打通桌面端Agent、云端Agent、企业协同Agent三种运行形态,不再局限简单问答,接收业务目标之后自主拆解任务步骤,调用各类工具,处理文档、表格、浏览器自动化、数据查询,直接输出可交付的办公
968 0
|
13天前
|
人工智能 Linux iOS开发
Ollama使用教程:Ollama官网下载、Ollama本地部署大模型(2026最新)
Ollama 是一款免费开源的本地大模型运行工具,支持在 Windows/macOS/Linux 上离线运行 Qwen、DeepSeek、Llama 等主流开源模型,数据不出本机、隐私安全。提供 OpenAI 兼容 API,命令行一键拉取/运行/管理模型,无需联网,无调用限制,是开发者与 AI 爱好者部署本地 AI 助手的理想选择。(239 字)
|
9天前
|
人工智能 并行计算 数据可视化
秋叶ComfyUI-AKI最新整合包|完整部署教程+核心指令手册
秋叶ComfyUI-AKI一键整合包,国内适配最优、稳定性最强的商用/学习级版本:全封装虚拟环境、预装90%常用节点、内置绘世启动器与成熟工作流,免配置、零依赖、解压即用,完美兼顾新手入门与专业批量生产需求。(239字)
|
10天前
|
人工智能 监控 测试技术
Qwen3.8-Flash 来了,100万上下文、Agent、Coding 都加强了
8月26日,通义千问发布Qwen3.8-Flash-Next:125B参数、每Token仅激活6B,原生支持26万Token、可扩展至100万上下文;Coding、Agent与工具调用能力显著增强,面向真实软件工程任务,推动大模型从“回答问题”迈向“完成工作”。

热门文章

最新文章