Android一种可滚动并隐藏顶栏的下拉刷新控件的实现


一、意义

      图片社交app的列表往往都是大图,有时候整个屏幕甚至容纳不下一个用户的动态,因此顶栏占据的屏幕空间会影响到用户浏览图片的体验。目前已知能让用户全屏去浏览动态流的是Voso Cam,仅仅大致判断了用户滚动列表时的方向和速度来确定顶栏隐藏和重现的逻辑,通过布局可以看出是把顶栏层叠在ListView的空白header上方,不过在使用Voso Cam时发现有缺陷,指尖快速上下滑动时,由于顶栏没有来得及检测ListView滚动速度,可以很明显看到下方一块空白的ListView header。如果要做到更好的顶栏隐藏重现逻辑,需要满足两点:①准确计算列表滚动距离;②改写下拉刷新控件。以下是LOFTER上的大致实现:
二、思路
  
        图1 开源控件PullToRefreshListView                    
 
    图2 顶栏可滚动的下拉刷新控件PullToRefreshMovingListView
     
       图1 PullToRefreshListView只能在顶栏下方区域滚动,控件上方是一块RelativeLayout用于展现刷新进度条,顶栏位于RelativeLayout之上的固定位置用于展现logo等用途,注意当位于正在刷新这个状态,RelativeLayout被隐藏,ListView Header被显示。该控件已经无处不在用了,但缺点是布局灵活性差,想要产生顶栏移动效果不可能。千万不要在这里用滑动时动态修改顶栏和ListView的topMargin的方法去做,这是个坑,ListView会抖得你发毛,尝试用一些滤波的方法去平滑去抖,有一定效果但不能完全消除。
      图2 PullToRefreshMovingListView继承自PullToRefreshListView,差别是去掉了RelativeLayout,ListView顶到了屏幕顶部,原来RelativeLayout这块位置变成了ListView Header,顶栏位置不变。这样只要能准确算出ListView滚动距离,再让顶栏跟随ListView滚动,就能实现这个效果。这样“下拉刷新”、“释放刷新”、“正在刷新”等状态都要在ListView Header里做,也比较清晰(当然PullToRefreshListView分两块去完成那些状态还是有好处的,那就是empty view逻辑支持比较简单),要解决很多UI展现的细节。
三、准确计算任意列表滚动距离
     为了让顶栏能跟随列表滚动而离开屏幕,往往都给ListView加载一块和顶栏同样高度的空白header,再让顶栏的z-order位于header之上。只有能准确知道列表滚动距离,才能让这个空白header一直档在顶栏下方。那如何计算这个距离?可能会先想到视图的基类方法getScrollY(),但此方法给ScrollView使用没有问题,因为ScrollView没有复用机制。ListView的父容器里永远只有可见的item,整个容器其实并没有相对屏幕移动,因此getScrollY()总是为0。
1) StackOverFlow上常见方法:
public int getScrollY() {
    View c = mListView.getChildAt(0);
    if (c == null) {
        return 0;
    }
    int firstVisiblePosition = mListView.getFirstVisiblePosition();
    int top = c.getTop();
    return -top + firstVisiblePosition * c.getHeight() ;
}
这是假设列表每项的高度相等,来算y轴方向的滚动距离,这是比较常用的做法,已能解决大多数问题。
2) 开源控件QuickReturnHeader的做法:
        if (lastFirstVisibleItem == firstVisibleItem) {
            delta = lastTop - top;
        } else if (firstVisibleItem > lastFirstVisibleItem) {
            skipped = firstVisibleItem - lastFirstVisibleItem - 1;
            delta = skipped * height + lastHeight + lastTop - top;
        } else {
            skipped = lastFirstVisibleItem - firstVisibleItem - 1;
            delta = skipped * -height + lastTop - (height + top);
        }
这和上面的getScrollY()并无实质不同。
3) 以上的方法的ListView类型比较简单,实际应用中,ListView往往有很多类型,每个类型的高度都不一样,这种情形还有没有比较便捷的方法能准确计算滚动距离?也许会想到扩展一下getScrollY()函数,分配一个数组,有新的item显示在屏幕上就记下它的高度,然后累加。那如果不断往下滑加载新数据,这个数组又得分配多大才够呢?
其实不需要单独存储每个item的高度,只要求出两次onScroll()回调时刻之间滑过的距离并累加,就是y轴方向的滚动距离,具体实现可以根据每个应用自己的需求。比如Lofter每个人的动态都是文字、图片、视频类型,item高度至少在60dp以上,xxhdpi屏幕上,假设要在两次onScroll()之间(假设间隔50ms,实际更快)滑过3个item,指尖速度至少达到10800px/s,这是不可能的,因此只要滑动时存储前3个item高度完全够用了。
            if (direction == Direction.SCROLL_UP) {
                if (lastFirstVisibleItem == firstVisibleItem) {
                    dY = Math.abs(lastTop - nowTop);
                } else {
                    dY = lastFirstHeight - Math.abs(lastTop) + Math.abs(nowTop);
                }
            } else if (direction == Direction.SCROLL_DOWN) {
                if (lastFirstVisibleItem == firstVisibleItem) {
                    dY = Math.abs(nowTop - lastTop);
                } else {
                    dY = firstHeight - Math.abs(nowTop) + Math.abs(lastTop);
                }
为了方便以后调用,可以写成一个更通用的库,只要根据需求给出item个数N(如以上N=3),就能按上面思路算出任意ListView的滚动距离了。
这样计算距离虽然麻烦些,但对提高用户体验是有好处的,比如QuickReturnBar操作起来会经常误触发,原因在于没有对滑动做较准确的阈值处理。
四、改写下拉刷新框架
主要的坑如下:
1. 继承PullToRefreshListView,并override掉相应方法,像setRefreshingInternal(), refreshHeader(), scroll2Top(), pullEvent(), setAdapter(), onRefreshComplete()等都要重写。为何连setAdapter()都要改?为了适配魅族机型,flyme的列表比较奇葩,除了下拉悬停,还会反弹滚动,需要禁掉。
2. 滚动条偏移位置,不让ListView Header的也计算在滚动条内,需要重写computeVerticalScrollOffset()。
3. empty view逻辑的特殊处理。
4. 如果顶栏比较高,“正在刷新”条的高度会比较高,产品对视觉要求高,实现细节中需要动态修改ListView Header高度。
5. 主要还是体验的难点:①用动画来优化体验,控制好“顶栏一半以上隐藏”、“一半以上可见”、“全部可见”、“全部隐藏”这4个状态,并对状态的过渡使用动画,还有viewpager里切换时相应动画;②为了防止顶栏频繁的拉出和隐藏,我们还加入了下拉一屏以上的距离才将顶栏拉出的逻辑,这样用户在下拉浏览时仍可以全屏浏览。
现在下拉刷新更时髦的似乎是Gmail的ActionBar刷新风格,如果用ActionBar方式,那么滚动并隐藏顶栏的刷新逻辑实现起来会简单得多。
本文来自网易实践者社区,经作者范晨灿授权发布。