iOS 与 惯性滚动

注:以下所有例子均 在 iOS 的微信中测试过,但对于饿了么APP的内置浏览器同样适用(两者使用相同内核)

引题

工作中常常有需要显示大量信息的情况,列表超出一屏就涉及到滚动的问题。例如

- var n = 1
ul  
  while n <= 100
    li= n++

在 iOS 中用微信打开,滚动非常顺滑,so far so good!但某天产品需求有变,要求加一个固定在头部的标题,于是改成这样:

- var n = 1
h1= "Momentum Scrolling on iOS"  
ul  
  while n <= 100
    li= n++
body, ul {  
  margin: 0;
}
html, body {  
  height: 100%;
}
body {  
  display: flex;
  flex-direction: column;
}
h1 {  
  flex-shrink: 0;
}
ul {  
  flex: 1;
  overflow: scroll;
}

直接用 flex 盒模型实现,动态适应标题的高度,很简单不是么。但是这时在 iOS 上打开后测试,发现有问题,下半部分区域滚动起来感觉很不顺滑,用老板的话说就像“卡齿轮”

这时就有大牛推荐了传说中的神器:-webkit-overflow-scrolling: touch

ul {  
  flex: 1;
  overflow: scroll;
  -webkit-overflow-scrolling: touch;
}

很简单的一个属性,顺滑滚动效果就回来了!虽然不太明白是怎么回事,解决问题就好。 但是产品经理又说了,需要在滚动时获取滚动条的位置做些其他操作。太简单了,加个 scroll 事件搞定。

document.querySelector('ul').addEventListener('scroll', function() {  
  this.previousElementSibling.textContent = 'ScrollTop: ' + this.scrollTop;
})

随手写好在浏览器中测试通过,然而在手机上测试就不太对劲:那个值是会变,然而滚动的时候不变,只有在滚动结束后变一次。

整个滚动过程中 scroll 事件只在滚动结束后会被触发一次,问题是出在这个所谓的神器 -webkit-overflow-scrolling 上面

-webkit-overflow-scrolling 究竟是什么鬼?

一个只有 iOS 设备支持的非标准属性。苹果自己的解释:指定是否在 overflow: scroll 的元素中使用“原生”的滚动方式

他包含两个可选值:autotouch

  • auto:就是普通的无惯性滚动效果
  • touch:原生的滚动效果。使用此效果会构造一个 stacking context

什么是 stacking context?这可以说是CSS里一个阴暗面,极其晦涩。有兴趣的朋友可以去看高人的解释,这里不做讨论(其实笔者自己也不是非常明白:cry:),总之所有的坑都是由此而起。

-webkit-overflow-scrolling 引发了那些坑?

下面列出我遇到过的坑:

滚动中 scrollTop 属性不会变化。

严格来说,上面的 scroll 事件不触发只是本坑的一个副作用,所以说不必考虑通过 touchmove 事件转发 scroll 事件等点子,scroll 事件触发了一样检测不到 scrollTop 属性的变化(当然检测手指的移动距离另说)。同样,检测滚动区域内部元素的 getBoundingClientRect 同样无效。

例中起了一个无限的rAF循环不停地获取 scrollTop 的值,然并卵。

手势可穿过其他元素触发元素滚动

这个更奇葩。例中用一个半透明的 div 盖在了滚动区域 ul 上面(实践中可能是一个弹框的背部蒙版),甚至给 ul 自己加上了 pointer-events: none,手指在 div 上滑动仍然会触发 ul 的滚动。你可以在显示半透明蒙版时将 ul-webkit-overflow-scrolling: touchoverflow: scroll 去掉,但是会造成屏幕明显的闪烁。如果给 bodytouchmove 事件 preventDefault() 可以防止触发滚动,但是是所有滚动区域都会失效。

运行时通过 JS 动态添加元素溢出高度导致滚动失效

Google 上一搜一片,但是笔者没有遇到过,或许在新版本系统中已经修正,这里不展开讨论。

滚动时暂停其他 transition

还有没有其他未踩的坑呢?

……

有没有什么好的解决方案

使用 WKWebView 替换 UIWebView 内核

可能有些读者已经发现,scroll 事件不能触发的坑在 iOS Safari 和 iOS Chrome 浏览器中不存在,为什么呢?这里要从 iOS 上浏览器的发展史说起。

由于苹果公司对安全性等原因的考虑,苹果公司静止第三方浏览器在 iOS 设备上使用自己的浏览器的内核,换句话说,使用自己内核的浏览器都被禁止上架 AppStore。各大厂商无奈,于是长久以来,包括 Chrome 在内的所有第三方浏览器,都只是使用 iOS 系统内置的浏览器控件包一层外壳,这个控件就是 UIWebView。这个 UIWebView 不仅速度差,HTML5 支持率低,占用内存高,还有各种各样奇怪的问题。然而苹果公司却给自己的 Safari 浏览器开了后门。首先 Safari 使用的支持 JIT 编译的 JS 引擎内核 NitroUIWebView 里老旧的解释性 JavaScriptCore 内核速度搞数倍,然后 HTML5 支持度也比 UIWebView 高,还少了某些奇葩bug。久而久之就形成了 iOS 设备上 Safari 浏览器全面碾压其他第三方浏览器的现象。

在乔帮主撒手人寰不久之后,苹果公司口气终于松动,虽然没有放开第三方浏览器内核的限制,但把 Safari 的浏览器内核提取了出来开放第三方浏览器使用,那就是如今的 WKWebView(WK 即 Webkit 的缩写)。但由于 WKWebView 只支持 iOS8 以上系统,各大浏览器厂商并未立刻跟进。直到最近的 iOS9 时代,Chrome 成为第一个吃螃蟹的 APP,使用了 WKWebView 内核。测试数据表明,使用 WKWebView 内核的 Chrome 浏览器在速度和 HTML5 支持率上已经与 Safari 浏览器不相上下。紧接着 Mozilla 公司宣布 Firefox 登录 iOS 平台,使用的也是 WKWebView 内核(于是有了第一款基于 Webkit 内核的火狐浏览器 :)

不知苹果做了什么手脚,也许苹果的开发人员认为 WKWebView 的效能已经足以支撑在 scroll 事件中执行额外代码而不造成 UI 卡顿,总之在 WKWebView 内核中滚动可以正常触发 scroll 事件,当然也能正常获得 scrollTop 的值。然而经过测试第二个问题仍然存在。

在这里笔者强烈建议各个 APP 迁移内嵌浏览器至新的 WKWebView 内核。但是就我看到的,包括微信和饿了么在内,几乎所有的国产 APP 都还在使用 UIWebView 内核,这不得不说是一大前端开发之殇。

自己实现一套滚动逻辑

比如前段时间很火的 iScroll,笔者曾近也使用过一段时间。最后得出的结论是:iScroll 挖出的坑不比它填上的坑少,比如在 iScroll 里加个 click 事件都要小心翼翼、特别对待(因为绝大多数情况绑定 touch 事件的回调函数里第一件做的事情就是 preventDefault)。

最值得一提的是 iScroll 的速度问题,比原生实在相差太多,在中低端安卓机型上卡顿明显,如果还要绑定 scroll 事件做些别的事情就更卡了。

总之笔者并不建议使用。

放弃 H5,拥抱 Native

也许这才是真正的终极解决之道