Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

一种生产环境可用的“滚动穿透”解决方案 #13

Open
luoway opened this issue Sep 5, 2019 · 1 comment
Open

一种生产环境可用的“滚动穿透”解决方案 #13

luoway opened this issue Sep 5, 2019 · 1 comment

Comments

@luoway
Copy link
Owner

luoway commented Sep 5, 2019

当你开发的移动端Web应用提交测试后,细心的测试者给你提“滚动穿透”体验问题时,你会怎么处理?

我会先告诉测试者:这是移动端Web开发中很常见的问题,不影响上线,先不考虑。

然后偷偷处理它。

滚动穿透是什么

“滚动穿透”现象:

当对话框中含有可滚动内容时,一旦滚动至对话框的边界,对话框下方的页面内容也开始滚动了——这被称为“滚动穿透”。实际上,不可滚动区域,也可以被穿透,常见于“透明背景遮罩”。

MDN中相关描述为:

when you have a dialog box with scrolling content on top of a page of scrolling content, once the dialog box's scroll boundary is reached, the underlying page will then start to scroll — this is called scroll chaining.

滚动穿透为什么存在

对于任何不可滚动的元素而言,滚动穿透是默认行为,用户在这类元素上滚动,浏览器就认为是需要滚动父元素。少数情况下,如带遮罩的弹窗,需要阻止默认行为。

而对于内部可滚动的元素而言,滚动穿透可能是多余的,但也可以视为一种浏览器的交互考虑:可滚动列表到边界了,应该滚父元素了。部分情况下,如固定定位的列表,不希望滚动穿透使页面发生滚动。

滚动的传播过程很像是冒泡过程,却不能通过阻止冒泡来阻止滚动穿透,滚动穿透因此成为一种移动端Web应用开发的经典问题。

怎么阻止滚动穿透

一种思路是阻止父元素及祖先元素滚动,一般是阻止body滚动,该思路的方案就有:

  • 需要阻止页面滚动时,给body设置overflow: hidden;,在移动端会导致已滚动位置(scrollTop)丢失,就得设法模拟和还原已滚动位置,并且必须避免在此时获取真实的已滚动位置(scrollTop = 0)。由于实现复杂,影响广泛,不建议在生产环境中使用。
  • 从页面布局考虑,保证可滚动元素的祖先元素不可滚动,也就避免了祖先元素响应滚动穿透。这一般是UI框架的解决方案,若项目使用的UI框架有布局容器,应当尽量使用框架提供的容器。

另一种思路是阻止滚动穿透这一默认行为,该思路方案有:

阻止浏览器touchmove默认行为:

targetElement.addEventListener('touchmove', e => {
  e.preventDefault()
})

该方案能够有效解决不可滚动的元素上的滚动穿透问题,但不适用于可滚动元素上,毕竟同样阻止了元素内滚动。

普遍的办法是,通过在touchmove事件监听回调中添加判断逻辑:列表滚动到边界后,根据touchstart初始位置判断touchmove方向,来决定是否阻止浏览器默认行为。

let cacheTouchY
targetList.addEventListener('touchstart', e=>{
  cacheTouchY = e.targetTouches[0].clientY
})
targetList.addEventListener('touchmove', e => {
  const {scrollHeight, offsetHeight, scrollTop} = e.currentTarget
  const difference = e.targetTouches[0].clientY - cacheTouchY
  if(scrollTop === 0){
    if(difference > 0) e.preventDefault()
  }else if(scrollTop === scrollHeight - offsetHeight){
    if(difference < 0) e.preventDefault()
  }
})

使用这种方案后,在iOS上就不能玩“触底回弹”效果了,即列表到底后不能够继续上滑。

本文提出的方案

对于touchmove不能简单处理可滚动列表的滚动穿透问题,本文的思路是:使列表滚动不会停止在边界

具体实现方案为:

targetList.addEventListner('scroll', e => {
  const {scrollHeight, offsetHeight, scrollTop} = e.target
  const offset = 1
  if(scrollTop === 0){
    e.target.scrollTop += offset
  }else if(scrollTop === scrollHeight - offsetHeight){
    e.target.scrollTop -= offset
  }
})

描述为:当列表滚动到边界的时候,回退一段距离,使得列表可以一直被滚动。

Demo页面,其中“无尽列表”使用了本文思路的方案、“可变长列表”使用了结合touchmove、“无尽列表”的方案。

优势

原理简单、实现简单。从触发条件出发,避免了滚动穿透。

缺点

滚动到边界结束时会有微弱的“吸附”效果,可被感知到;

视觉稿还原时有可能需要考虑腾出回退距离;

可滚动列表初始滚动位置为0,只要不开始滚动列表,就能通过下滑触发滚动穿透使页面滚动,可进一步使用touchmove方向判断阻止。


瑕不掩瑜,这种避免滚动穿透的方案,会不会成为你的新选择?

@luoway
Copy link
Owner Author

luoway commented Mar 6, 2020

本文提出的“使列表滚动不会停止在边界”方案,实测在Android机型上不生效,表现为:
Android机型上可以在一次滚动触摸中,从当前滚动容器穿透到其他容器。
在iOS机型上由于会触发“弹性滚动”而没有这种问题。

解决办法是对Android机型采用兼容方案:禁止touchmove默认行为。
实测touchmove事件在部分浏览器上passive默认为true,导致preventDefault不生效。

最终解决方案:

let cacheTouchY
export function touchStartHandler(e) {
    if(!isAndroid) return false
    cacheTouchY = e.targetTouches && e.targetTouches[0] && e.targetTouches[0].clientY
}

export function touchMoveHandler(e){
    const {scrollHeight, offsetHeight, scrollTop} = e.currentTarget
    //判断容器不可滚动,则禁止滚动穿透
    if(scrollHeight === offsetHeight) e.preventDefault()
    if(!isAndroid) return false
    //安卓机型下没有弹性滚动,且可以在一次滚动中从当前滚动容器穿透到其他容器
    const difference = e.targetTouches && e.targetTouches[0] && 
                        e.targetTouches[0].clientY - cacheTouchY
    if(scrollTop === scrollHeight - offsetHeight){
        if(difference < 0) e.preventDefault()
    }else if(scrollTop === 0){
        if(difference > 0) e.preventDefault()
    }
}

/**
 * 滚动容器使之不能滚动到顶、到底,则不会触发滚动穿透
 */
export function scrollHandler(e){
    const {scrollHeight, offsetHeight, scrollTop} = e.target
    const offset = 1
    if(scrollTop === 0){
        e.target.scrollTop += offset
    }else if(scrollTop === scrollHeight - offsetHeight){
        e.target.scrollTop -= offset
    }
}
<html>
  <body>
    <div id="list"></div>
    <script>
      const $list = document.querySelector('#list')
      $list.addEventListener('touchstart', touchStartHandler)
      $list.addEventListener('touchmove', touchMoveHandler, {passive: false})
      $list.addEventListener('scroll', scrollHandler, {passive: true})
    </script>
  </body>
</html>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant