打造Android万能下拉刷新上拉加载控件

本文介绍如何打造一个适用于AbsListView和RecyclerView的自定义下拉刷新、上拉加载更多控件。该控件支持自定义Header和Footer,通过监听列表顶部和底部状态实现自动刷新和加载。核心代码涉及触摸事件处理、冲突解决和滑动比例控制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

转载请注明出处:http://blog.youkuaiyun.com/binbinqq86/article/details/70159782

关于列表刷新加载的自定义控件,网上数不胜数,但别人的用起来始终不是那么得心应手,很早以前就想自己去实现一个属于自己的刷新控件,废话不多说,看图:
这里写图片描述

怎么样,感觉还不错吧~该控件支持AbsListview,Recyclerview,并且可以自己扩展其他类型的View,包括自动刷新,滑到底部自动加载更多,header和footer均可以自定义。

下面就说说实现的主要思路和原理:首先自定义一个View继承于ViewGroup,整个布局从上到下分为header,刷新的view,footer,默认header和footer不可见,这样当下拉的时候去判断是否在列表顶部,是的话就逐渐显示header,否则列表滚动,同理footer也是一样,简单吧!

关键代码如下:

private void init(Context mContext) {
        this.mContext = mContext;
        mScroller = new Scroller(mContext);
        screenHeight = getResources().getDisplayMetrics().heightPixels;
        preferences = PreferenceManager.getDefaultSharedPreferences(mContext);

        header = LayoutInflater.from(mContext).inflate(R.layout.refresh_header, null, false);
        progressBar = (ProgressBar) header.findViewById(R.id.progress_bar);
        arrow = (ImageView) header.findViewById(R.id.arrow);
        description = (TextView) header.findViewById(R.id.description);
        updateAt = (TextView) header.findViewById(R.id.updated_at);

        footer = LayoutInflater.from(mContext).inflate(R.layout.loadmore_footer, null, false);
        pbFooter = (ProgressBar) footer.findViewById(R.id.pb);
        tvLoadMore = (TextView) footer.findViewById(R.id.tv_load_more);

        touchSlop = ViewConfiguration.get(mContext).getScaledTouchSlop();
        refreshUpdatedAtValue();
        addView(header, 0);
    }

主要是初始化一些变量,可以看到有header,footer等~

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        for (int i = 0; i < getChildCount(); i++) {
            View childView = getChildAt(i);
            if(childView.getVisibility()!=View.GONE){
                //获取每个子view的自己高度宽度,取最大的就是viewGroup的大小
                measureChild(childView, widthMeasureSpec, heightMeasureSpec);
                maxWidth = Math.max(maxWidth,childView.getMeasuredWidth());
                maxHeight = Math.max(maxHeight,childView.getMeasuredHeight());
            }
        }
        //为ViewGroup设置宽高
        setMeasuredDimension(maxWidth+getPaddingLeft()+getPaddingRight(), maxHeight+getPaddingTop()+getPaddingBottom());
//        Log.e(TAG, "onMeasure: ");

        //处理数据不满一屏的情况下禁止上拉
        if(mView!=null){
            LayoutParams vlp=mView.getLayoutParams();
            if(vlp.height==LayoutParams.WRAP_CONTENT){
                vlp.height= LayoutParams.MATCH_PARENT;
            }
            if(vlp.width==LayoutParams.WRAP_CONTENT){
                vlp.width= LayoutParams.MATCH_PARENT;
            }
            mView.setLayoutParams(vlp);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
//        Log.e(TAG, "onLayout: ");
        if(!hasFinishedLayout){
            mView=getChildAt(1);
            addView(footer);
            hasFinishedLayout=true;

            if(canLoadMore&&canAutoLoadMore){
                setAutoLoadMore();
            }
        }
        if(hideHeaderHeight==0){
            hideHeaderHeight = -header.getHeight();
        }
        if(hideFooterHeight==0){
            hideFooterHeight=footer.getHeight();
//            Log.e(TAG, "onLayout: "+hideFooterHeight+"@"+hideHeaderHeight);
        }

        int top=hideHeaderHeight+getPaddingTop();
//        header.layout(0,top,maxWidth,top+header.getMeasuredHeight());
//        top+=header.getMeasuredHeight();
//        mView.layout(0,top,maxWidth,top+mView.getMeasuredHeight());
//        top+=mView.getMeasuredHeight();
//        footer.layout(0,top,maxWidth,top+footer.getMeasuredHeight());
        for (int i = 0; i < getChildCount(); i++) {
            View childView = getChildAt(i);
            if (childView.getVisibility() != GONE) {
                childView.layout(getPaddingLeft(), top, maxWidth+getPaddingLeft(), top+childView.getMeasuredHeight());
                top+=childView.getMeasuredHeight();
            }
        }
    }

上面主要就是自定义view必须的两个步骤,onMeasure和onLayout,代码很简单,也没有什么好说的,主要就是测量每个子view的宽高,然后从上到下依次摆放header,刷新的view,footer。
下面来看关键代码:

/**
     * 根据当前View的滚动状态来设定 {@link #isTop}
     * 的值,每次都需要在触摸事件中第一个执行,这样可以判断出当前应该是滚动View,还是应该进行下拉。
     */
    private void judgeIsTop() {
        if (mView instanceof AbsListView) {
            AbsListView absListView = (AbsListView) mView;
            View firstChild = absListView.getChildAt(0);//返回的是当前屏幕中的第一个子view,非整个列表
            if (firstChild != null) {
                int firstVisiblePos = absListView.getFirstVisiblePosition();//不必完全可见,当前屏幕中第一个可见的子view在整个列表的位置
                if (firstVisiblePos == 0 && firstChild.getTop()-mView.getPaddingTop() == 0) {
                    // 如果首个元素的上边缘,距离父布局值为0,就说明ListView滚动到了最顶部,此时应该允许下拉刷新
                    isTop = true;
                } else {
                    isTop = false;
                }
            } else {
                // 如果ListView中没有元素,也应该允许下拉刷新
                isTop = true;
            }
        } else if (mView instanceof RecyclerView) {
            RecyclerView recyclerView = (RecyclerView) mView;
            View firstChild = recyclerView.getLayoutManager().findViewByPosition(0);//firstChild不必须完全可见
            View firstVisibleChild = recyclerView.getChildAt(0);//返回的是当前屏幕中的第一个子view,非整个列表
//            if(firstChild!=null){
//                Log.e("tianbin",firstChild.getTop()+"==="+recyclerView.getChildAt(0).getTop());
//            }else{
//                Log.e("tianbin","+++++++++");
//            }
            if (firstVisibleChild != null) {
                if (firstChild != null && recyclerView.getLayoutManager().getDecoratedTop(firstChild)-mView.getPaddingTop() == 0) {
                    isTop = true;
                } else {
                    isTop = false;
                }
            } else {
                //没有元素也允许刷新
                isTop = true;
            }
        } else {
            isTop = true;
        }
    }

这里主要是用来判断当前是否处在列表的顶部,这是一个关键点,就像前面所说的,如果处于顶部,往上滑则列表进行滚动,往下拉则显示header,里面我处理了AbsListview和RecyclerView,而其他情况则可以自己去扩展,同理判断底部也是一样,这里就不贴出代码了,最后我会给出源码下载地址。。。

    @Override
    public boolean dispatchTouchEvent(final MotionEvent event) {
        //每次首先进行判断是否在列表顶部或者底部
        judgeIsTop();
        judgeIsBottom();

        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_POINTER_DOWN:
                isUserSwiped=false;
                startPress=System.currentTimeMillis();
                if(event.getPointerId(event.getActionIndex())==0){
                    mLastY = event.getY(0);
                    mFirstY = event.getY();
                    isTouching=true;
                    canDrag=true;
                }else{
                    return false;
                }
                break;
            case MotionEvent.ACTION_MOVE:
                if(!canDrag){
                    return false;//false交给父控件处理
                }
//                int pointerIndex=event.findPointerIndex(0);
//                float totalDistance = event.getY() - mFirstY;
//                float deltaY = event.getY(pointerIndex) - mLastY;
//                mLastY = event.getY(pointerIndex);

//                Log.e(TAG,touchSlop+"$$$"+Math.abs(event.getY() - mFirstY) );
//                Class<?> clazz=View.class;
//                try {
//                    Field field=clazz.getDeclaredField("mHasPerformedLongPress");
//                    field.setAccessible(true);
//                    Log.e(TAG, "dispatchTouchEvent: "+field.get(this));
//                } catch (NoSuchFieldException e) {
//                    e.printStackTrace();
//                } catch (IllegalAccessException e) {
//                    e.printStackTrace();
//                }
                break;
            case MotionEvent.ACTION_POINTER_UP:
            default:
                if (Math.abs(event.getY() - mFirstY) > touchSlop) {//判断是否滑动还是长按
                    //滑动事件
//                    Log.e(TAG,"===dispatchTouchEvent===ACTION_POINTER_UP==yyyyyyyy");
                    isUserSwiped=true;
                }else{
                    //点击或长按事件
//                    Log.e(TAG,"===dispatchTouchEvent===ACTION_POINTER_UP==zzzzzzzz");
                }
                //重置==============================================
                if(event.getPointerId(event.getActionIndex())==0){
                    canDrag=false;
                }
                ratio = DEFAULT_RATIO;
                isTouching=false;
                break;
        }
        return super.dispatchTouchEvent(event);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getAction()){
            case MotionEvent.ACTION_MOVE:
                float deltaY = ev.getY() - mLastY;
                if (Math.abs(ev.getY() - mFirstY) > touchSlop) {//只要有滑动,就进行处理,屏蔽一切点击长按事件
                    if(getScrollY()<0&&currentStatus==STATUS_REFRESHING){//正在刷新并且header没有完全隐藏时,把事件交给自己处理
                        return true;
                    }
                    if(getScrollY()>0&&currentFooterStatus==STATUS_LOADING){//正在刷新并且footer没有完全隐藏时,把事件交给自己处理
                        return true;
                    }
                    if(getScrollY()==0&&((isTop&&deltaY>0)||(isBottom&&deltaY<0))){//header footer都隐藏时,顶部下拉或者底部上拉都把事件交给自己处理
                        return true;
                    }
                }else{
                    if(System.currentTimeMillis()-startPress>=ViewConfiguration.getLongPressTimeout()){
                        //说明长按事件发生,禁止任何滑动操作
//                        Log.e(TAG, "onInterceptTouchEvent: "+"======longclick happened======" );
                        canDrag=false;
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
                if (isUserSwiped) {//点击事件发生在onTouchEvent的ACTION_UP中,所以此处进行处理:如果属于滑动则拦截一切事件,禁止传递给子view
                    return true;
                }
                if(isRefreshing||isLoading){//正在刷新或者加载的时候,禁止点击事件
                    return true;
                }
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (ev.getAction()){
            case MotionEvent.ACTION_MOVE:
                float deltaY = ev.getY() - mLastY;
                mLastY = ev.getY();

                boolean showTop=deltaY>=0 && isTop;
                boolean hideTop=deltaY<=0 && getScrollY()<0;
//                boolean noMove=deltaY==0;//当不动的时候屏蔽一切事件,防止列表滚动
                boolean showBottom=deltaY<=0 && isBottom;
                boolean hideBottom=deltaY>=0 && getScrollY()>0;

//                Log.e(TAG, "dispatchTouchEvent: "+ratio+"+++"+isTop+"###"+getScrollY()+"$$$"+deltaY);
                if((showBottom&&canLoadMore)||hideBottom){
                    if(deltaY<0){
                        if(getScrollY()>=hideFooterHeight){
                            ratio += 0.05f;
                        }
                    }else{
                        ratio=1;
                    }
                    int dy=(int) (deltaY / ratio);
                    if(deltaY>0 && Math.abs(dy)>Math.abs(getScrollY())){
                        //当滑动距离大于可滚动距离时,进行调整
                        dy=Math.abs(getScrollY());
                    }
                    scrollBy(0, -dy);
                    return true;
                }else if ((showTop&&canRefresh)||hideTop) {
                    //说明头部显示,自己处理滑动,无论上滑下滑均同步移动(==0代表滑动到顶部可以继续下拉)
                    if (deltaY < 0) {//来回按住上下移动:下拉逐渐增加难度,上拉不变
                        ratio = 1;//此处如果系数不是1,则会出现列表跳动的现象。。。暂未解决!!!
                    } else {
                        if(Math.abs(getScrollY())>=-hideHeaderHeight){
                            ratio += 0.05f;//当头部露出以后逐步增加下拉难度
                        }
                    }
                    int dy=(int) (deltaY / ratio);
                    if(deltaY<0 && Math.abs(dy)>Math.abs(getScrollY())){
                        //当滑动距离大于可滚动距离时,进行调整
                        dy=-Math.abs(getScrollY());
                    }
//                    Log.e(TAG, "dispatchTouchEvent: "+"###"+getScrollY()+"%%%"+dy);
                    scrollBy(0, -dy);
//                    Log.e(TAG, "dispatchTouchEvent: "+"###"+getScrollY()+"&&&"+dy);
                    if (currentStatus != STATUS_REFRESHING){
                        if (getScrollY() <= hideHeaderHeight) {
                            currentStatus = STATUS_RELEASE_TO_REFRESH;
                        } else {
                            currentStatus = STATUS_PULL_TO_REFRESH;
                        }
                        // 时刻记得更新下拉头中的信息
                        updateHeaderView();
                        lastStatus = currentStatus;
                    }
                    return true;
                }else{
                    return super.onTouchEvent(ev);
                }
            case MotionEvent.ACTION_UP:
                //处理顶部==========================================
                if (currentStatus == STATUS_RELEASE_TO_REFRESH) {
                    // 松手时如果是释放立即刷新状态,就去调用正在刷新的任务
                    backToTop();
                } else if (currentStatus == STATUS_PULL_TO_REFRESH) {
                    // 松手时如果是下拉状态,就去调用隐藏下拉头的任务
                    hideHeader(false);
                } else if (currentStatus == STATUS_REFRESHING) {
                    if (getScrollY() <= hideHeaderHeight) {
                        //回弹
                        backToTop();
                    }
                }
                //处理底部===========================================
                if(getScrollY()>0 && getScrollY()<hideFooterHeight && !isLoading){
                    //松手时隐藏底部
                    hideFooter();
                }else if(getScrollY()>=hideFooterHeight){
                    //显示底部,开始加载更多
                    showFooter();
                }
                return true;
        }
        return super.onTouchEvent(ev);
    }

以上代码就是处理整个触摸事件的核心,也是老生常谈的触摸事件三部曲:dispatchTouchEvent,onInterceptTouchEvent,onTouchEvent。

第四行可以看到,每次触摸事件发生时,首先进行顶部和底部的判断,这样便于后面在move发生的时候去判断到底该如何滑动。

isUserSwiped:这个变量主要用来区分用户的滑动和点击,在44行可以看到,如果用户滑动距离超过了最小识别距离,就认为用户是滑动了,这样就屏蔽点击事件,可以看到在onInterceptTouchEvent中拦截了触摸事件,这样就屏蔽子view发生点击事件,为什么isUserSwiped的判断要在ACTION_POINTER_UP中判断呢,这是因为源码中的点击事件发生在这里,这样就解决了滑动和点击事件的冲突。

canDrag:这个变量主要用来判断控件本身及列表是否可以滑动。当长按事件发生后,整个界面应该不允许操作,可以看第79-82行代码,长按事件主要就是在ACTION_DOWN的时候发送一个延迟消息,我就利用这一点去判断长按事件的发生,然后就很好的解决了这个冲突问题。

另外在onTouchEvent中主要就是做了一些滑动的操作,以及头部底部松手后的处理,这里我加入了一个ratio变量用来控制下拉的难度系数。


    /**
     * 是否支持下拉刷新
     */
    private boolean canRefresh=true;
    /**
     * 是否支持上拉加载
     */
    private boolean canLoadMore=true;

    /**
     * 是否支持滑动到底部自动加载更多
     */
    private boolean canAutoLoadMore=false;

    private void autoLoadMore(){
        if (mListener != null && !isLoading) {
            currentFooterStatus=STATUS_LOADING;
            updateFooterView();
            mScroller.startScroll(0, 0, 0, hideFooterHeight);
            invalidate();
            isLoading = true;
            mListener.onLoadMore();
        }
    }
    /**
     * 自动刷新
     */
    public void autoRefresh(){
        if (mListener != null && !isRefreshing) {
            currentStatus = STATUS_REFRESHING;
            updateHeaderView();
            mScroller.startScroll(0, 0, 0, hideHeaderHeight);
            invalidate();
            isRefreshing = true;
            autoRefresh=true;//放在updateHeaderView后面
            mListener.onRefresh();
        }
    }

上面几个变量用来控制自动刷新和滑动到底部自动加载更多。。。

至此整个的控件就讲解完了,怎么样,简单吧!其中主要的难点就是上面所说的两点:

  • 列表和整个控件滑动的冲突处理
  • 点击长按事件和滑动的冲突处理

如果还有不明白的地方,大家可以在下面留言~

源码下载

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值