position: sticky 在移动端的应用与实践

前面《iOS 与 弹性滚动》里讲到,iOS 的 UIWebkit 内核浏览器中启用弹性滚动后,滚动事件不会立即触发的问题。不过话说回来,绑定 scroll 本来就对整体 UI 性能影响很大,某些通常需要绑定 scroll 事件的东西其实有其他更为简便的实现方式。

比如说有这样一个很常见需求:一个长列表里分了很多小节,每个小节有一个头部标题。要求当各小节尚未完全滚动到屏幕外时,小节的头部标题始终固定在屏幕顶部。好多人一看到“滚动”这个词就直接监听 scroll 事件开始搞,其实对于这个需求有更好的、更方便的解决方式,这就是本文的主角:position: sticky

什么是 position: sticky

MDN 上的解释

The box position is calculated according to the normal flow (this is called the position in normal flow). Then the box is offset relative to its flow root and containing block and in all cases, including table elements, does not affect the position of any following boxes. When a box B is stickily positioned, the position of the following box is calculated as though B were not offset. The effect of ‘position: sticky’ on table elements is the same as for ‘position: relative’.  

我是看了几遍没看懂,但根据实践,sticky (下文我把他翻译为粘性定位)是这样一种定位方式。如果有

#one {
  position: sticky;
  top: 10px;
}
  1. #one 处于可视范围内时,#one 表现的就像一个普通的 static 元素(但是它仍非 static 定位,所以仍然是内部元素的 offsetParent)。

  2. #one 处于可视范围之外(相对于 #one 外层的第一个非 overflow: visible 元素而言,如果没有则为整个 window,这里以 #parent 代替),#one 即被“粘”在 #parent 的顶部。
    其中 10px#one 的上边框(border-box)至 #parent 的上边框(content-box)的距离。如果未设置,则粘性定位效果对于该边不起作用。

  3. #one#parent 中间还有其他静态定位的父级元素,#one 将被限制在其父级元素之内。当与 条款2 冲突时,本条款覆盖上一条。

  4. table 元素无效,相当于 position: relative

说起来比较抽象,下面以几个示例说明。

例子中,整个 window 为一个滚动区域,所有 dt 相对于 window 粘性定位。向下滚动时,所有 dt 会堆叠到窗口顶部。

例子中,#wrapper 嵌在 #parent 内,构成一片较大的滚动区域,#child 处于 #wrapper 的中心,相对于 #parent#child 外层第一个非 position: static 的元素)粘性定位。可以看到,无论怎样滚动,中间的黑框 #child 始终处于 #parent 的内部。

为了便于更好的说明 条款3,下面的例子对上面两例略加修改

本例将 例1 略微改动,把各个 dt 单独放入一个 dl 中。向下滚动时可以看到类似下面的 dl 把上面的 dl 顶上去的效果。 其实并非如此,多个粘性定位元素并无关联。产生这样效果的原因仅仅是因为 dt 的父元素 dl 整个都被滚动到了窗口外,dl 随之把粘性定位的 dt 给带走了

本例将 例2 略微修改,给 #child 包了一层大小一致的 div#wrapper1,同样是水平竖直居中,粘性定位却“失效”了。其实原因与 例3 一样,粘性定位的 #child 只是被 #wrapper1 牢牢地固定住了,定位效果并未失效。

position: sticky 能做什么

首先就是引题中的需求:固定列表头。示例 1、3 已经实现了这个效果。不仅仅是列表头,文档头、段标题,甚至两边的侧边栏都可以用——如果你想给侧边栏一个浮动效果的话。一个例外是表格的标题栏,可以看到 MDN 的最后一句话:position: sticky 对表格元素不起作用,当然你完全可以用别的方式模拟 table 布局。

position: sticky 相对于绑定 scroll 事件的优点

首先最大的优点:有了它我们不用再绑定恶心、缓慢,还有各种兼容性问题的 scroll 事件了。其次:简单。设置一个 CSS 属性的事情干嘛要 JS 操心,布局的东西本来就应该使用纯 CSS 实现。最后:position: sticky-webkit-overflow-scrolling: touch 相性极佳,滚动效果无比顺滑,并非 scroll 事件可以模拟。

position: sticky 的浏览器兼容性

这是一个不可避免的问题。很不辛,Android 阵营全部阵亡。Chrome 当前的状态是 In development,canary 版本上已经可以体验到其初步实现,相信不久之后就会看到 Chrome(Blink) 的正式支持。除 Safari 阵营外,Firefox 也已经支持了此属性,建议调试粘性定位效果时在 Firefox 上调试,怎么说也比 Safari 的调试器好用。

另外还是由于 iOS 的限制,所有的 iOS 浏览器包括 Chrome 在内,和其他内置的比如微信内嵌浏览器全部支持此属性。

顺便一提 Edge 的状态是 Under Consideration,微软的浏览器怎样都好了。。。

position: sticky 的 fallback 实现

在 Chrome 的原生粘性定位实现来临前,我们仍需要一个 fallback 实现,使用 absolute 模拟 sticky 效果。简单起见,这里只考虑纵向滚动 window 的情况,以例 http://codepen.io/CarterLi/pen/qZmKzX 为基础做修改。

原始页面如下所示

- var n = 1
while n <= 20  
  dl
    dt= 'TITLE ' + (n++)

    each val in [1, 2, 3, 4, 5, 6, 7, 8, 9]
      dd= val + ' ' + val

将标题行用一个 div 包一层,用于绝对定位元素之后给原位置占位。

- var n = 1
while n <= 20  
  dl
    dt
      div= 'TITLE ' + (n++)

    each val in [1, 2, 3, 4, 5, 6, 7, 8, 9]
      dd= val + ' ' + val

首先需要检测浏览器是否支持 position: sticky

var elem = document.createElement('div');  
elem.style.position = '-webkit-sticky';  
elem.style.position = 'sticky';  
if (elem.style.position.indexOf('sticky') < 0) {  
  // 当前浏览器不支持粘性定位,需要 fallback 实现
}

预先把占位 div 的高度设置好

Array.prototype.forEach.call(document.querySelectorAll('dt'), function (elem) {  
  elem.style.height = elem.clientHeight + 'px';
});

监听 scroll 事件,遍历所有标题行,找到需要 sticky 效果的行,添加类名 sticky

addEventListener('scroll', function() {  
  var stickyElements = document.querySelectorAll('dt');
  for (let idx = 0; idx < stickyElements.length; ++idx) {
    var elem = stickyElements[idx];
    // 对于滚 window 而言,BoundingClientRect 就是元素的视口坐标值
    var clientRect = elem.getBoundingClientRect();
    // 如果标题行被滚到了窗口外
    if (clientRect.top < 0) {
      const parentBottom = elem.parentElement.getBoundingClientRect().bottom;
      if (parentBottom < 0) {
        // 如果父元素整个区域都滚动到了窗口外,则去除标题行的 sticky 类
        elem.classList.remove('sticky');
      } else {
        // 添加 sticky 类,将标题行固定在顶部
        elem.classList.add('sticky');
        // 动态计算 style.top,表现推上去的效果
        elem.style.top = Math.min(0, parentBottom - clientRect.height) + 'px';
      }
    } else {
      // 如果标题行还在窗口内部或下面,则中断循环,将其后的所有标题行的 sticky 类全部删除
      // 用于解决用户滚动太快时的问题
      for (let j = idx; j < stickyElements.length; ++j) {
        stickyElements[j].classList.remove('sticky');
      }
      break;
    }
  }
});

CSS 代码中添加 sticky 类,用于置顶标题栏

.sticky div {
  position: fixed;
  left: 0;
  right: 0;
  top: inherit;
}

完整示例:http://codepen.io/CarterLi/full/LNwJmq