wexin

列表组件抽象(4)-滚动列表及分页说明

这是我写的关于列表组件的第4篇博客。前面的相关文章有:

1. 列表组件抽象(1)-概述

2. 列表组件抽象(2)-listViewBase说明

3. 列表组件抽象(3)-分页和排序管理说明

本文介绍列表组件中我对滚动列表及滚动分页的实现思路。

在pc端,通过滚动进行翻页的需求非常常见;移动端也是,只不过移动端由于scroll事件触发有延迟,必须等到屏幕停止滑动后才会触发,而不是在用户的手指离开屏幕就立即触发,所以移动端最好是不用scroll事件直接做滚动翻页,而是用iscroll这类插件提供更实时的scroll事件更好。

不管是pc还是移动端,滚动翻页列表的特点都是差不多的:

1)基本上由以下几个部分组成:数据列表,顶部的加载中提示,底部的加载中提示,没有更多了,没有找到记录。正是按照这个思路,所以我把滚动列表的html结构设计成:

image

2)跟其它列表组件不同的是,滚动列表在请求新的数据后,有2种方式来渲染新的数据。一种是跟其它列表组件一样,直接把原来的列表内容替换;另一种是将新数据追加在原有的列表内容之后。第1种通常用于直接更改列表的查询条件时使用;第2种用于翻页查询或者刷新操作。

3)在前面的几个部分中,有两个加载中的提示,都是用来提升用户体验的东西。顶部加载提示用于条件查询,底部加载提示用于翻页查询。从它们在html中的位置也能看出来。

4)加载更多的按钮,一是防止滚动事件失效而准备的,二是有些场景可能会禁用掉滚动翻页,所以就要提供直接点击按钮的手工翻页。

5)没有更多了这个部分,在翻页查询后,根据数据结果判断没有更多的数据时显示。

6)没有找到记录的这个部分用于在列表首次查询时,如果数据为空时显示。

7)当通过滚动或者滑动操作,使得滚动列表隐藏于可视区域之下的部分不断往上滚动,并在达到某一个临界点的时候,触发翻页查询,将下一页的数据追加到数据列表后面进行显示。

针对以上的这些需求逻辑,考虑pc端和移动端的场景,我写了两个组件分别用于实现滚动列表。同时与这两个列表组件一起使用的还有另外两个分页组件,它们两两之间是配套使用的。

首先是用于实现pc端,可相对window或者某个DOM元素进行滚动分页的列表组件scrollListView以及它配套的分页组件scrollPageView组件,源码分别是:

https://github.com/liuyunzhuge/blog/blob/master/form/src/js/mod/listView/scrollListView.js

https://github.com/liuyunzhuge/blog/blob/master/form/src/js/mod/listView/scrollPageView.js

然后是用于移动端,结合iscroll一起使用的iscrollListView和iscrollPageView组件,源码分别是:

https://github.com/liuyunzhuge/blog/blob/master/form/src/js/mod/listView/iscrollListView.js

https://github.com/liuyunzhuge/blog/blob/master/form/src/js/mod/listView/iscrollPageView.js

针对以上组件有以下demo可以查看相关功能演示:

pc端相对window滚动分页demo:http://liuyunzhuge.github.io/blog/form/dist/html/listView_2.html

pc端相对某个DOM元素滚动分页demo:http://liuyunzhuge.github.io/blog/form/dist/html/listView_3.html

移动端滚动分页demo:http://liuyunzhuge.github.io/blog/form/dist/html/listView_4.html

后面的部分说明以上组件的要点。不过由于iscrollListView直接继承了scrollListView,实现非常简单;iscrollPageView的实现思路也跟scrollPageView差不多。所以后面只介绍scrollListView和scrollPageView的相关内容。

先来看scrollListView.js。

首先,代码结构还是跟前几篇博客介绍的组件都差不多,所以这里不再复述。defaults是这样定义的:

var DEFAULTS = $.extend({}, ListViewBase.DEFAULTS, {
    //列表容器的选择器
    dataListSelector: '.data_list',
    //顶部加载中的html
    loadingTopHtml: '<div class="loading_top">加载中...</div>',
    //没有结果的html
    noContentHtml: '<div class="no_content">没有找到相关记录:(</div>',
    //底部加载中的html
    loadingBottomHtml: '<div class="loading_bottom">加载中...</div>',
    //没有更多的html
    noMoreHtml: '<div class="no_more">没有更多了</div>',
    //加载更多的html
    loadMoreHtml: '<a href="javascript:;" class="btn_load_more">加载更多</a>'
});

主要是用来定义滚动列表的那几个组成部分。如果不想用默认值,那么在实例化组件的时候,传入想要设置的option就行了。

scrollListView继承了listViewBase,为了增加自己的初始化逻辑,所以用到了initMiddle这个模板方法,并在其中做了一些jq对象缓存,以及内部状态管理初始化的逻辑:

initMiddle: function () {
    var opts = this.options,
        $element = this.$element,
        $data_list = this.$data_list = $element.find(opts.dataListSelector),
        $load_more = this.$load_more = $(opts.loadMoreHtml).appendTo($element),
        $no_content = $(opts.noContentHtml).appendTo($element),
        $loading_top = $(opts.loadingTopHtml).prependTo($element),
        $loading_bottom = $(opts.loadingBottomHtml).appendTo($element),
        $no_more = $(opts.noMoreHtml).appendTo($element);

    $load_more.css('display', 'block');

    //状态管理:初始化完毕,顶部加载中,底部加载中,没有结果,没有更多,加载完毕
    var states = this.states = {
        init: function () {
            $data_list.show();
            $load_more.hide();
            $no_content.hide();
            $loading_top.hide();
            $loading_bottom.hide();
            $no_more.hide();
        },
        loading_top: function () {
            $data_list.show();
            $load_more.hide();
            $no_content.hide();
            $loading_top.show();
            $loading_bottom.hide();
            $no_more.hide();
        },
        loading_bottom: function () {
            $data_list.show();
            $load_more.hide();
            $no_content.hide();
            $loading_top.hide();
            $loading_bottom.show();
            $no_more.hide();
        },
        no_content: function () {
            $data_list.hide();
            $load_more.hide();
            $no_content.show();
            $loading_top.hide();
            $loading_bottom.hide();
            $no_more.hide();
        },
        loaded: function () {
            $data_list.show();
            $load_more.show();
            $no_content.hide();
            $loading_top.hide();
            $loading_bottom.hide();
            $no_more.hide();
        },
        no_more: function () {
            $data_list.show();
            $load_more.hide();
            $no_content.hide();
            $loading_top.hide();
            $loading_bottom.hide();
            $no_more.show();
        },
        'set': function (action) {
            this.curState = action;
            this[action]();
        },
        isNomore: function () {
            return this.curState == 'no_more';
        },
        isNoContent: function () {
            return this.curState == 'no_content';
        }
    };

    states.set('init');
},

以上代码中的那个states对象用来实现内部的状态管理,可以看作一个简单的状态机。采用这个做法的原因,一是为了满足最前面介绍滚动列表组件特点时描述的那些需求逻辑,二是为了让这些UI控制逻辑看起来更清晰。有了它,我只要在何时的时机,改变下组件的状态,就能列表组件显示不同的内容了。比较简单好理解。

然后通过createPageView来实现分页组件的初始化逻辑。这里就得使用scrollPageView来实例化了:

createPageView: function () {
    var pageView,
        opts = this.options;

    if (opts.pageView) {
        //初始化分页组件
        delete opts.pageView.onChange;
        pageView = new ScrollPageView($.extend(opts.pageView, {
            $loadMore: this.$load_more,
            $element: this.$element
        }));
    }
    return pageView;
},

然后把scrollPageView需要的几个dom对象以option的形式传给了它。

考虑到滚动列表组件的特殊性,我还用到了listViewBase的其它几个模板方法来实现滚动列表的需求。

首先是beforeQuery:

beforeQuery: function (clear) {
    //如果clear为true,则显示顶部的加载状态,表示正在进行新条件的列表查询
    //否则显示底部的加载状态,表示正在进行翻页查询
    this.states.set(clear ? 'loading_top' : 'loading_bottom');
},

这个方法会接受一个参数clear,为true则表示进行条件查询,否则表示进行翻页查询。这个方法的作用在于查询前显示加载提示。

然后是querySuccess:

querySuccess: function (html, args) {
    var pageView = this.pageView,
        rows = this.originalRows,
        method = args.clear ? 'html' : 'append';//根据查询类型,来决定要如何处理渲染新的数据

    //没有查到结果
    if (rows.length == 0 && pageView.pageIndex == 1) {
        this.states.set('no_content');
        return;
    }

    //没有更多
    if (rows.length < pageView.pageSize) {
        this.states.set('no_more');
        html.length && this.$data_list[method](html);
        return;
    }

    //加载完毕
    this.states.set('loaded');
    this.$data_list[method](html);
},

它用来实现请求成功的后的逻辑,最重要的当然是渲染数据。但是考虑到列表组件的需求,还得根据多方面的参数,判断该把列表设置为什么样的状态。请求成功后的结果无非三种,没有查到数据,没有更多,加载成功。这三个状态,根据分页信息和记录数就能判断清楚,见源码里面if逻辑的写法。

然后是queryError:

queryError: function () {
    this.states.set('loaded');
},

这个主要是在请求失败的时候,还原列表的状态而已。

最后是afterQuery:

afterQuery: function () {
    if (this.states.isNoContent() || this.states.isNomore()) {
        this.pageView.disable();
    }
}

它在请求完成之后,判断如果是没有数据或者没有更多的状态的话,就禁用掉分页组件,免得用户操作不慎导致还会发出一些查不到数据的请求。

以上就是scrollListView实现的核心了,只有100多行。

再来看scrollPageView。

它的defaults定义如下:

var DEFAULTS = $.extend({}, PageViewBase.DEFAULTS, {
    //加载更多的按钮
    $loadMore: null,
    //滚动元素
    $element: null,
    //滚动区域的目标元素,如果没有传,默认就是window对象,用来注册scroll事件
    $target: null,
    //是否启用滚动翻页
    scrollPage: true,
    //滚动元素底边跟滚动可视区域底边的距离,它是滚动翻页的临界点
    offset: -100,
    //滚动事件的绑定时的延迟时间
    scrollBindDelay: 0,
    //滚动事件的节流间隔
    throttle: 100,
});

应该好理解。offset的作用后面会继续说明,scrollBindDelay是用来延迟滚动事件绑定的。为啥会搞这个,是因为chrome浏览器有个特性,如果在浏览网页的时候,刷新之后,滚动条会恢复到刷新前浏览的位置,并且它这个自动恢复也会触发滚动事件。那么当列表组件初始化完毕之后,很有可能会发出两次查询请求,一次是由初始化调用发出的,一次是由自动恢复的滚动事件发出的。所以加上这个option,有利于控制列表初始化后的首次请求。$loadMore用于注册点击事件,添加手动翻页的逻辑。$element表示滚动列表相关的dom对象。$target表示滚动相对的目标对象,如果不传,就指向window对象。

scrollPageView内部提供了简单的节流函数来做滚动事件回调的节流控制:

//简单函数节流
function throttle(func, interval) {
    var last = Date.now();
    return function () {
        var now = Date.now();
        if ((now - last) > interval) {
            func.apply(this, arguments);
            last = now;
        }
    }
}

也提供了获取css属性在浏览器重绘之后的值的函数:

//用来获取css某个属性经过浏览器重绘之后的值
function getComputedValue(element, prop) {
    var computedStyle = window.getComputedStyle(element, null);
    if (!computedStyle) return null;
    if (computedStyle.getPropertyValue) {
        return computedStyle.getPropertyValue(prop);
    } else if (computedStyle.getAttribute) {
        return computedStyle.getAttribute(prop);
    } else if (computedStyle[prop]) {
        return computedStyle[prop];
    }
}

其它代码倒是没啥好补充的,重点看下滚动事件相关的翻页控制逻辑,我就只贴了相关的匿名函数代码了:

function () {
    if (that.disabled) return;

    var targetHeight, bottom;

    //目标元素的clientHeight作为滚动区域的高度
    //bottom:滚动元素的底边到滚动区域顶边的距离

    if (!opts.$target) {
        targetHeight = document.documentElement.clientHeight;
        bottom = opts.$element[0].getBoundingClientRect().bottom;
    } else {
        targetHeight = opts.$target[0].clientHeight;

        var targetRect = opts.$target[0].getBoundingClientRect(),
            targetBorderTop = parseInt(getComputedValue(opts.$target[0], 'border-top-width')),
            elemRect = opts.$element[0].getBoundingClientRect();

        //如果target是其它的html元素,由于都是采用boundingClientRect来计算的,所以要减去目标元素顶部边框的宽度,这样才不会有误差
        bottom = elemRect.bottom - targetRect.top - (isNaN(targetBorderTop) ? 0 : targetBorderTop);
    }

    //bottom+ opts.offset等于一条临界线
    //如果opts.offset小于0,那么这条临界线就位于滚动元素底边再往上opts.offsets的距离的位置
    //如果Opts.offset大于0,那么这条临界线就位于滚动元素底边再往下|opts.offsets|的绝对距离的位置
    //翻页触发的条件是:这条临界线刚好出现在滚动区域里面的时候
    if ((bottom + opts.offset) < targetHeight) {
        pageIndexChange(that.pageIndex + 1, that);
    }
}

以上的思路也比较简单,只要判断列表元素的底部跟目标对象的可视区域的底部达到临界距离即可,这个临界距离就是defaults中定义的offset值。以相对window滚动为例说明如何来做这个判断:

image

根据上图,可以得知翻页临界的判断条件就是上图中临界线到目标对象可视区域顶边的距离小于目标对象可视区域的高度。这个图虽然是以相对window的滚动情况来说明问题的,但是相对DOM对象进行滚动的判断方式跟这个是一模一样的,只要我们能够得到DOM对象的可视区域高度以及临界线到DOM对象可视区域顶边的距离即可。我的代码中是利用getBoundingClientRect来求的,相当于还是以浏览器的可视区域的顶边作为参考线,不过考虑到普通的DOM对象可能也有顶部边框的问题,在计算最后的临界线到DOM对象可视区域的顶边距离时,减去了DOM对象顶部变宽的宽度。只有这样得出的临界线距离才是相对于DOM对象可视区域顶边而言的。

以上就是本文的全部内容,介绍了我想要补充说明的关于滚动列表组件的部分。这一块内容,我觉得没有特别广的适用性,毕竟各个产品对滚动翻页这种需求的逻辑可能也不尽相同,我这边提供的是我现有项目中的实现思路,可能只能对您有一定的参考价值。所以要是有不妥的,欢迎随时帮我指正出来。谢谢阅读:)

posted @ 2016-09-20 09:20  流云诸葛  阅读(2072)  评论(0编辑  收藏  举报