实现一个 AngularJS 的固定表头指令

1 前言

在前端业务开发中经常需要在页面上创建一个内容超级多的 Table,用户操作时如果将页面滚动到页中就会导致不知道当前列的列头名称或者无法横向滚动到想找的列。

针对这种情况,我暂时想到了这几种处理方案:

  1. 将 Table 框定在一个固定宽高的 DIV 中,这个 DIV 会出现纵向或横向滚动条;
  2. 让 Table 有多宽就显示多宽,这样子页面可能会出现横向滚动条和纵向滚动条,在纵向滚动条滚动过程中如果列表头看不见了就显示一个固定列表头;
  3. 将 Table 框定在一个宽度固定,高度自适应的 DIV 中,这个 DIV 可能会存在横向滚动条,而页面将只存在一个纵向滚动条。在纵向滚动条滚动过程中如果列表头看不见了就显示一个固定列表头,另外如果 DIV 的横向滚动条看不见了也创建一个固定横向滚动条;

在用户体验方面:
* 方案 1,不能满足不同 PC 分辨率的变化,而且极易出现页面内纵向滚动条,影响了页面的布局流,整体效果不太好;
* 方案 2,出现了横向滚动条,如果想让页面在横向滚动过程中只有 Table 会动,其余元素不动,那么对于页面内元素比较多的页面就会导致复杂性增加;
* 方案 3,让页面只有纵向滚动条,是当前页面设计的主流,不过实现起来比较麻烦;

在自己的项目中,我针对方案 3 创建了一个 Angular 的指令(directive):fixedHeaderScroll。

2 环境和问题

在此交代一下前端环境:Bootstrap3.3.7 ,jQuery1.12.4,AngularJS1.5.11
需要固定表头的 table 放在 div.table-responsive 中,Table 的表头内容是不变的,由 ng-repeat 触发 tbody 内容的变化。

在编写过程主要遇到这几个问题:
1. 什么时候显示和隐藏固定元素
2. 横向滚动条滚动时需要将滚动位置同步给固定表头
3. 由于我的实现中,固定元素都是新建的独立元素,所以 Table 内容变化导致的宽度变化需要同步给固定元素,而这些还会有一些浏览器兼容性问题
4. Table 内容的变化需要触发固定元素的变化
5. 同一个页面存在多个 fixedHeaderScroll 时的切换问题

3 问题怎样解决

  • 通过对页面滚动位置的判断,可以知道什么情况下显示固定元素
  • 那什么时候触发对页面滚动位置的判断呢?通过监听 window 的 resize 和 scroll 事件触发
  • 通过监听横向滚动条的滚动事件,让滚动条滚动的位置同步给固定表头
  • Table 内容的变化可能不会导致 window 的 scroll 事件产生,此时需要通过 angular scope 的 $watch 监听 Table 的宽度变化来触发固定元素的变化
  • 对于同一个页面存在多个 fixedHeaderScroll 时需要约定 div.table-responsive 每次只能显示一个,而且它们的显隐是由 angular 控制的,这样可以通过监听 div.table-responsive 的显影试下固定元素的变化

4 总结

这个 AngularJS 的固定表头的指令花了我几个星期的闲暇时间修修改改,但现在实现上还可能会有一点小问题。
总结起来,这个指令的开发主要还是让我能够更好的融合 jQuery 和 AngularJS 结合的开发模式,一些 BUG 的出现也是告诫我对于复杂的组件开发必须要善于总结。

5 源码

demo.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8" />
    <title>Demo</title>
    <link href="/libs/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet" />
    <script src="/libs/jquery/1.12.4/jquery.min.js"></script>
    <script src="/libs/bootstrap/3.3.7/js/bootstrap.min.js"></script>
    <script src="/libs/angular/1.5.11/angular.min.js"></script>
    <script src="/libs/angular/1.5.11/angular-animate.min.js"></script>
    <style>
        body {
            padding-top: 65px;
        }

        .table > thead > tr > th, .table > tbody > tr > th, .table > tfoot > tr > th, .table > thead > tr > td, .table > tbody > tr > td, .table > tfoot > tr > td {
            vertical-align: middle;
        }

        .table-nowrap > thead > tr > th, .table-nowrap > tbody > tr > th, .table-nowrap > tfoot > tr > th, .table-nowrap > thead > tr > td, .table-nowrap > tbody > tr > td, .table-nowrap > tfoot > tr > td {
            white-space: nowrap;
        }

        tr.text-center > th {
            text-align: center;
        }

        tr.text-center > th {
            text-align: center;
        }
    </style>
    <script src="demo.js"></script>
</head>
<body ng-app="app">
<header class="navbar navbar-inverse navbar-fixed-top">
    <div class="container-fluid">
        <div class="navbar-header">
            <button class="navbar-toggle collapsed" type="button" data-toggle="collapse" data-target="#main-navbar"
                    aria-controls="main-navbar" aria-expanded="false">
                <span class="sr-only">Toggle navigation</span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
            </button>
            <a href="/" class="navbar-brand">Demo</a>
        </div>
        <nav id="main-navbar" class="collapse navbar-collapse">
            <ul class="nav navbar-nav">
                <li class="active">
                    <a href="javascript:" ng-click="update()">更新</a>
                </li>
            </ul>
        </nav>
    </div>
</header>
<section class="container-fluid">
    <div class="row">
        <div class="col-xs-12">Header here</div>
        <div class="col-xs-12">
            <div class="table-responsive" fixed-header-scroll="{id:'demo'}">
                <table class="table table-striped table-bordered table-nowrap">
                    <thead>
                        <tr class="text-center active">
                            <th ng-repeat="th in headers">{{th}}</th>
                        </tr>
                    </thead>
                    <tbody class="text-center">
                        <tr ng-repeat="tr in items">
                            <td ng-repeat="td in fields">{{tr[td] | number : 2}}</td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>
    </div>
</section>
<footer>
    <div class="container">
        <p>Footer here</p>
    </div>
</footer>
</body>
</html>

demo.js,因为跟我实际生产用的版本有点出入,导致这个 DEMO 版本某些时候还是会有一些 BUG 出现,后面有时间再修改吧。

'use strict';
angular.module('app', []).directive('fixedHeaderScroll', ['$timeout', FixedHeaderScroll]).run(['$rootScope', Run]);

function FixedHeaderScroll($timeout) {
  function Handle(scope, el, attrs) {
    var self = this;
    var opt = scope.$eval(attrs.fixedHeaderScroll) || {};
    self.$onInit(scope, el, opt);

    el.scroll(function() {
      el.trigger('fixed.header');//触发一个自定义事件,避免冲突
    });

    var onEvent = function(e) {
      if (self.el.is(':hidden')) return;//当前对象被隐藏,不用处理
      console.debug(self.id, e.type || e.name);

      var type = e.type || e.name;
      var top = self.table.offset().top;
      var scrTop = self.win.scrollTop();

      if (self.isNeedHeader(top, scrTop)) {
        if ('scroll' === type) {
          $timeout(function() {
            //已显示的情况下不处理
            if (!self.header || self.header.is(':hidden')) {
              self.headerShow();
            }
          });
        } else {
          $timeout(function() {
            self.headerShow();
          });
        }
      } else {
        self.headerHide();
      }

      if (self.isNeedScroll(top, scrTop)) {
        if ('scroll' === type) {
          $timeout(function() {
            //已显示的情况下不处理
            if (!self.scroll || self.scroll.is(':hidden')) {
              self.scrollShow();
            }
          });
        } else {
          $timeout(function() {
            self.scrollShow();
          });
        }

      } else {
        self.scrollHide();
      }
    };

    scope.$watch(function() {
      return self.table.width();
    }, function(now, old) {
      if (now < old) {//只针对小于的情况处理
        $timeout(function() {
          onEvent({type: 'rewrite'});
        });
      }
    });

    //监听显示和隐藏
    scope.$watch(function() {
      return el.is(':visible');
    }, function(now, old) {
      if (now && !old) {
        console.log('visible', now, old);
        self.headerHide();
        self.scrollHide();
        $timeout(function() {
          onEvent({type: 'show'});
        }, 1000);
      }
    });

    self.win.resize(onEvent);//监听窗口变化
    self.win.scroll(onEvent);//监听滚动条变化
    scope.$on('fixed.header.scroll.' + self.id, onEvent);//监听自定义事件
  }

  Handle.prototype.isNeedHeader = function(top, scrTop) {
    // console.log('Header', scrTop, top, this.top);
    // console.log('Header', scrTop, top - this.top);
    return scrTop > top - this.top;// 表格头不在页面上时显示固定表头
  };

  Handle.prototype.isNeedScroll = function(top, scrTop) {
    var self = this;
    var tabH = self.table.height();
    var winH = window.innerHeight;
    // console.log('Scroll', scrTop, winH, self.bottom, top, tabH);
    // console.log('Scroll', scrTop + winH - self.bottom, top + tabH);
    return scrTop + winH - self.bottom < top + tabH;// 滚动条不在页面上时显示固定滚动条
  };

  Handle.prototype.$onInit = function(scope, el, opt) {
    var self = this;
    self.el = el;
    self.scope = scope;
    self.id = opt.id || '0';
    self.top = opt.top != null ? opt.top : 51;
    self.bottom = opt.bottom != null ? opt.bottom : 20;
    self.table = el.children('table');
    self.thead = self.table.children('thead');
    self.tbody = self.table.children('tbody');
    self.win = $(window);
    self.header = null;
    self.scroll = null;
  };

  Handle.prototype.headerShow = function() {
    console.debug(this.id, 'headerShow');
    var self = this;
    $timeout(function() {
      if (!self.header) {//不存在则创建
        self.headerAdd();
      } else if (self.header.is(':hidden')) {//被隐藏则显示和开启事件
        self.header.show();
      }
      self.headerBind();
    });
  };

  Handle.prototype.headerHide = function() {
    var header = this.header;
    //已显示则关闭事件和隐藏
    if (header && header.is(':visible')) {
      console.debug(this.id, 'headerHide');
      this.el.off('fixed.header');
      header.hide();
    }
  };

  Handle.prototype.headerAdd = function() {
    console.debug(this.id, 'headerAdd');
    var self = this, table = self.table, thead = self.thead;
    var header = self.header = $('<table></table>');
    header[0].style = table[0].style;
    header[0].className = table[0].className;
    header.css({
      position: 'fixed',
      top: self.top + 'px',
    });

    var child = $('<thead></thead>');
    child[0].style = thead[0].style;
    child[0].className = thead[0].className;
    child.html(thead.html());
    header.append(child);
    header.$child = child;
    self.el.append(header);
  };

  Handle.prototype.headerBind = function() {
    console.debug(this.id, 'headerBind');
    var self = this, header = self.header, table = self.table;
    var width = table.width();
    header.width(width);
    header.css('min-width', width + 'px');

    var ths = self.thead.find('tr').children();
    var tgs = header.$child.find('tr').children();
    for (var i = 0; i < ths.length; i++) {
      var w = $(ths[i]).width();
      var tag = $(tgs[i]).width(w);
      if (window.mozPaintCount) {//fixed firefox bug
        tag.css('min-width', w + 'px');
      }
    }

    var left = table.offset().left;
    header.css('left', left + 'px');

    self.el.on('fixed.header', function() {
      var left = table.offset().left;
      header.is(':visible') && header.css('left', left + 'px');
    });
  };

  Handle.prototype.scrollShow = function() {
    console.debug(this.id, 'scrollShow');
    var self = this;

    $timeout(function() {
      if (!self.scroll) {//不存在则创建
        self.scrollAdd();
      }
      if (self.scroll.is(':hidden')) {//被隐藏则显示和开启事件
        self.scroll.show();
      }
      self.scrollBind();
    });
  };

  Handle.prototype.scrollHide = function() {
    var scroll = this.scroll;
    //已显示则关闭事件和隐藏
    if (scroll && scroll.is(':visible')) {
      console.debug(this.id, 'scrollHide');
      scroll.off('scroll');
      scroll.hide();
    }
  };

  Handle.prototype.scrollAdd = function() {
    console.debug(this.id, 'scrollAdd');
    var self = this, el = self.el;
    var scroll = self.scroll = $('<div class="table-responsive none-border"></div>');
    scroll.css({position: 'fixed', bottom: 0, 'margin-bottom': 0});

    var div = $('<div></div>');
    div.css('height', '20px');
    scroll.append(div);
    scroll.$child = div;
    el.after(scroll);
  };

  Handle.prototype.scrollBind = function() {
    console.debug(this.id, 'scrollBind');
    var self = this, scroll = self.scroll, el = self.el;
    scroll.width(el.width());
    scroll.$child.width(self.table.width());
    scroll.scrollLeft(el.scrollLeft());
    scroll.on('scroll', function() {
      scroll.is(':visible') && el.scrollLeft(scroll.scrollLeft());
    });
  };

  return function(scope, el, attrs) {
    new Handle(scope, el, attrs);
  };
}

function Run($rootScope) {
  $rootScope.headers = [];
  $rootScope.fields = [];
  $rootScope.items = [];

  $rootScope.init = function() {
    for (var i = 1; i < 31; i++) {
      $rootScope.headers.push('Header ' + i);
      $rootScope.fields.push('field' + i);
    }
  };

  $rootScope.update = function() {
    var items = [];
    var size = window.parseInt(Math.random() * 50, 10);
    for (var i = 0; i < size; i++) {
      var item = {};
      for(var j = 1; j < 31; j++){
        item['field' + j] = Math.random() * 999999999999;
      }
      items.push(item);
    }
    $rootScope.items = items;
  };

  $rootScope.init();
}

6 效果截图

20171022235322.png

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值