Sep 23

抽象并重制下拉刷新组件小记

Lrdcq , 2016/09/23 19:58 , 程序 , 閱讀(2103) , Via 本站原創
之前制作TabCalendar的过程中,在下拉部分的事件处理感觉还是写得比较稚嫩,因此自我感觉还需要再练练手。因此这次选择下拉刷新组件开刀,原因有3点:
1.在业务使用中有需求,实际app中很少有使用android自带下拉刷新的,都是仿iOS的重置版本,因此我们也需要一个。
2.虽然只是一个简单的交互,但能够在他上面耍的花样很多,值得抽象整理一次。
3.其涉及到双层滚动等各种触控问题,值得整理一次。

嗯,因此这次抽象了SwipeRefreshAbs作为基础抽象,并尝试了多个交互设计的实现。下面挑重点的记录。

触控事件拦截

首先,我们的SwipeRefresh和原生的一样是一个容器,里面用户可塞入一个子元素——就是我们的被滑动目标了,当我们收到触控事件的时候就滑它,一个粘手的操作——这是我们的基本设计。

最简单的实现

我们这不是要滑动么,那就直接在dispatchTouchEvent里,从触摸一开始就把事件收入囊中,然后慢慢处理不就好啦。(如果在onTouchEvent中处理事件,会有很大的风险很可能被子控件夺去或者压根传不到那儿)毕竟在android中事件一旦被一个元素消耗,就再也不能传达到其他元素手里了。具体的做法是在dispatchTouchEvent中始终判断每一个事件,当达到下拉的条件即y轴移动达到目标阈值,则开始吃掉事件一直到事件结束。

当然,这样写的错误也是显而易见的——一旦达到滑动条件,事件被粗暴的拦截了,如果内部控件有什么特别的交互——对,特别也是滑动,就会直接打断。确实曾经有控件是这么做,当显然这不是一个合理的选择。

我们采用的实现

因此,我们要谨慎的选择是否要拦截事件,同样更具api设计意图,我们在onInterceptTouchEvent里处理。

1.首先我们判断canChildScrollUp,大概是这样:
    private boolean canChildScrollUp() {
        if (android.os.Build.VERSION.SDK_INT < 14) {
            if (mTarget instanceof AbsListView) {
                final AbsListView absListView = (AbsListView) mTarget;
                return absListView.getChildCount() > 0 && (absListView.getFirstVisiblePosition() > 0 || absListView
                        .getChildAt(0).getTop() < absListView.getPaddingTop());
            } else {
                return mTarget.getScrollY() > 0;
            }
        } else {
            return ViewCompat.canScrollVertically(mTarget, -1);
        }
    }

这个方法同过一些辅助函数,可以大致判断出子元素是否可以向上滚动,如果可以,我们就return false不要拦截事件了。

2.同时,我们还有一个属性来标示mIsBeingDragged,另一边onTouchEvent如果还没开始拖动,也给返回false了。

3.在onInterceptTouchEvent继续监听move直到阈值,如果直到这里还没被拦截过,则mIsBeingDragged设为true

4.在onTouchEvent一旦mIsBeingDragged为true则开始吃掉所有的事件并触发业务逻辑

这样一来,我们可以安全的支持大部分安卓控件的内部滚动而不发生干扰,而当内部无法滚动的时候,就会触发下拉刷新逻辑了。经过在各个第三方库中查询,这也是最常见的解决方案。

最优雅的实现

然而,上面这种结局方案还有一个bug。内部消耗滚动到尽头之后,就会卡死,需要释放再次接着拉才会拉出下拉刷新。这显然是不够优雅的。参考谷歌的原生SwipeRefreshLayout的实现,原来android5.0开始为view引入了一套nestedScroll的api来专门处理视图嵌套滚动的这种问题,并在5.0引入的控件里边大量应用——其中最关键的是两个辅助接口:NestedScrollingChildHelper和NestedScrollingParentHelper分别处理作为嵌套滚动内部和外部的界面所进行的操作。5.0自带的可滚动的view都实现了这两个接口并进行了处理,所以我们只需要自己也实现这两个接口来处理就可以实现最优雅的效果了。

其它方面,为了考虑周全,在处理触控事件的时候,我们还考虑了多点触控的问题,主要针对多个手指交替滑动的时候。

1.第一个手指按下的时候记录其id作为关键滑动的触控点。

2.当某非最后一个手指抬起的时候,如果它是当前的那个手指,这记录抬起点,找另外一个手指记录为关键触控点,并算出坐标差来改变粘手的起始位置。

3.直到所有手指离开触控结束,清理触控缓存数据。

这一套逻辑可以保证在多点触控的同时滑动流畅不会闪现。

下拉刷新交互状态

处理完最关键的问题,我们回来下拉刷新控件交互本身。经过抽象,首先我们有3个关键高度在整个交互中起到决定性作用:
點擊在新視窗中瀏覽此圖片

1.ready高度,在下拉过程中,到这个高度之前,释放后都是取消,否则就是下拉成功。

2.max高度,在ready高度之后下拉会越来越困难,因此设立一个最高高度作为下拉衰减无限趋近的阈值。

3.running高度,在成功下拉之后,下拉高度会收缩到这个高度保持刷新状态直到用户取消。
    protected abstract int getReadyOffsetDp();
    
    protected abstract int getMaxOffsetDp();
    
    protected abstract int getRunningOffsetDp();

除了这三个高度,其它状态控件总是趋势于收缩到高度0的位置。另外除了下拉,众所周知一般还提供setRefreshing方法来提供开启或者关闭刷新。通过这3个高度的中间状态拓展和广泛了解第三方下拉刷新进行整理和归纳,我们最终整理出了我们的下拉刷新控件的状态机于状态切换条件:

點擊在新視窗中瀏覽此圖片

一共有8个状态:

COLD : 未激活状态,并没有发生下拉事件,也是下拉刷新的默认状态和常态。
通过触摸下拉可以切换到START状态,通过setRefreshing(true)可以切换到GO状态。

START : 开始下拉,当并未到达ready高度的状态。
继续下拉可以拉到READY状态,此时释放可以到达CANCEL状态。

READY : 继续下拉,到达ready高度后到状态。
手势往上收缩,可以回到START状态,此时释放可以到达GO状态。

CANCEL : 取消下拉,动画回收回的状态。
收回完毕后充值到COLD状态。

GO : 确认刷新,动画收回或者释放到running高度的状态。
释放动画完成后切换到RUNNING状态。

RUNNING : 正在刷新到状态,是一个静态状态,持续时间无限长。
通过setRefreshing(false)可以切换到SUCCESS状态。

SUCCESS : 刷新完成状态,固定时间,用于展示刷新完成的表示。
在固定时间delay到达后切换到FINISH状态。

FINISH : 结束状态,动画回收回的状态。
收回完毕后充值到COLD状态。

在这些状态切换时,和做动画的过程中,就涉及到生命周期的回调了,这些抽象方法包括:
    protected abstract void onCreate(FrameLayout view);

    protected abstract void onStart(FrameLayout view);

    protected abstract void onStartProcess(FrameLayout view, float process, float offset);

    protected abstract void onReady(FrameLayout view);

    protected abstract void onReadyProcess(FrameLayout view, float process, float offset);

    protected abstract void onCancel(FrameLayout view);

    protected abstract void onCancelProcess(FrameLayout view, float process, float offset);

    protected abstract void onCanceled(FrameLayout view);

    protected abstract void onGo(FrameLayout view);

    protected abstract void onGoProcess(FrameLayout view, float process, float offset);

    protected abstract void onRunning(FrameLayout view);

    protected abstract void onSuccess(FrameLayout view);

    protected abstract void onFinish(FrameLayout view);

    protected abstract void onFinishProcess(FrameLayout view, float process, float offset);

    protected abstract void onFinished(FrameLayout view);

    protected abstract void onEnd(FrameLayout view);

其中onCreate和onEnd作为单次下拉生命周期的起止,作为单次下拉交互行为的开始和结束。一般在这两个方法中初始化和移除界面和各种行为的初始数据。其它onXxxxx的方法是每一个状态切换时的生命周期回调,而onXxxxxProcess的方法是有粘手和渐变动画时执行动画的回调,其中第二个参数是动画的百分比,第三个参数是下拉实际错位的距离,这个接口类似于viewPager的onScroll事件回调。当然要做动画特别是粘手,也是在Process方法里做,所以尽量不要在Process中进行相对耗时的操作比如new对象之类的,尽量保证在15ms内可以运行完成才不会跳帧。

其它交互中的可配置点

出了上面3个高度是必须可配置的,还有两个包括:
protected float getAnimationRate() {
    return 0.8f;
}
protected boolean shouldAnimationContentMove() {
    return true;
}

getAnimationRate控制的是动画的速率的,其实是一个收缩缓动的收缩率,因此数字应该小于1且越小的话收缩率越大。默认数值0.8的情况下每次收缩动画大概在400-500ms左右运行完成,暂时是比较常见且合理的速率。

shouldAnimationContentMove是指定下拉容器是否随着下拉移动。整个控件下有两个大容器,一个是用户界面,另一个就是下拉容器放置被拉下来的东西。在实际使用中,有两种情况,一种是随着下拉这个容器被拉下来;另一种情况是随着用户界面下拉,后面露出来了一个界面或者花样。因此通过这个配置来设定背后的界面是否要随着下拉移动被拉下来。

一个经典的实现

既然进行了抽象,当然我进行了多个典型的下拉刷新的控件的实现。一个典型的实现就是模仿安卓qq的主界面列表的下拉刷新交互。视频如下:



通过实现以上的接口,我们就可以实现这个控件实现了。首先我们分析这个交互中出现的控件:1.最主要的文本框。2.下拉时左边出现的图标。3.加载中时左边出现的加载bar。
首先,我们在onCreate里就初始化出我们这时候需要的几个控件:
    private TextView tv;
    private View icon;
    private ProgressBar loading;

    private String actionText = "刷新";

    public void setActionText(String actionText) {
        this.actionText = actionText;
    }

    @Override
    protected void onCreate(FrameLayout view) {
        view.setBackgroundColor(0xff333333);

        //text
        tv = new TextView(getContext());
        tv.setTextColor(0xffffffff);
        LayoutParams lp = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        lp.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL;
        lp.bottomMargin = (int) (16f * getResources().getDisplayMetrics().density);
        tv.setLayoutParams(lp);
        tv.setGravity(Gravity.CENTER);
        view.addView(tv);

        //
        icon = new View(getContext());
        icon.setBackground(getResources().getDrawable(R.drawable.ic_arrow_downward_grey_400_36dp));
        LayoutParams lp2 = new LayoutParams((int) (32f * getResources().getDisplayMetrics().density), (int) (32f *
                getResources().getDisplayMetrics().density));
        lp2.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL;
        lp2.bottomMargin = (int) (10f * getResources().getDisplayMetrics().density);
        lp2.rightMargin = (int) (120f * getResources().getDisplayMetrics().density);
        icon.setLayoutParams(lp2);
        view.addView(icon);
    }

    @Override
    protected void onStart(FrameLayout view) {
        icon.setRotation(0);
        tv.setText("下拉" + actionText);
    }

因为我们选择的下拉界面随着下拉移动,因此这些控件都应该在下拉容器中靠下。
其次,在从start状态切换到ready状态时,做了一个翻转180度的动画,对应的做法是:
    private Animation arrAnimation = null;

    @Override
    protected void onReady(FrameLayout view) {
        icon.setRotation(180);
        if (arrAnimation != null) {
            arrAnimation.cancel();
        }
        arrAnimation = new RotateAnimation(180, 0, (int) (16f * getResources().getDisplayMetrics().density), (int)
                (16f * getResources().getDisplayMetrics().density));
        arrAnimation.setDuration(300);
        icon.startAnimation(arrAnimation);
        tv.setText("释放立刻" + actionText);
    }

对发起的固定时间动画,最优还是做一下统一的管理和冲突或取消时cancel。
当到达running状态时,去掉左边的图标界面,加入加载bar控件:
    @Override
    protected void onRunning(FrameLayout view) {
        tv.setText("正在" + actionText + "...");
        view.removeView(icon);

        loading = new ProgressBar(getContext());
        loading.getIndeterminateDrawable().setColorFilter(0xFFFFFFFF, android.graphics.PorterDuff.Mode.MULTIPLY);
        LayoutParams lp = new LayoutParams((int) (18f * getResources().getDisplayMetrics().density), (int) (18f *
                getResources().getDisplayMetrics().density));
        lp.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL;
        lp.bottomMargin = (int) (16f * getResources().getDisplayMetrics().density);
        lp.rightMargin = (int) (52f * getResources().getDisplayMetrics().density);
        loading.setLayoutParams(lp);
        view.addView(loading);
    }

最后当完成和结束后,暴力的移除界面上的所有子控件就可以了:
    @Override
    protected void onSuccess(FrameLayout view) {
        tv.setText("✔️ " + actionText + "成功     ");
        view.removeView(loading);
    }
    @Override
    protected void onEnd(FrameLayout view) {
        view.removeAllViews();
        tv = null;
        icon = null;
        loading = null;
    }

可以看到,我们只需要简单的在接口中添加对应的界面逻辑操作,即可完成一个逻辑完善的下拉刷新控件。就算业务意义不大,也能让我们很方便的发挥创意进行自由的控件设计嘛~

最后sample使用起来和原生的下拉刷新控件保持一致:
XGSwipeRefreshAbs swipeRefresh = (XGSwipeRefreshAbs) findViewById(R.id.swiperefresh);
swipeRefresh.setOnRefreshListener(() -> swipeRefresh.postDelayed(() -> swipeRefresh.setRefreshing(false), 3000));
logo