着移动端市场的份额越大,需求就越多样化。今天讨论的是移动端的滚动穿透问题。需求中弹窗浮层还是挺常见的,那这个和滚动穿透有什么联系呢?
先解释下什么是滚动穿透:
页面滑出了一个弹窗,我们用手指触摸屏幕滑动时,会发现弹窗下面的内容还是在滚动。这个现象就是滚动穿透。
需求
需求: 希望在点击图片的时候,从下方弹一个全屏的弹框来描述这张图片的详情。
方案
接到这个需求觉得没有难度,很快就提测了。突然意识到写弹窗的时候忘记处理滚动穿透的问题了。
第一个方法就是当弹窗触发的时候,给 overflow: scroll的元素加上一个 class (一般都是 body 元素)。退出的时候去掉这个 class。为了方便,会直接用 body 元素来代指弹窗下方的元素。
// css 部分 modal_open { position: fixed; height: 100%; } // js 部分 document.body.classList.add('modal_open'); document.body.classList.remove('modal_open');
上面的这个方法可以解决滚动穿透问题,却也会带来新的问题。
即:
body 的滚动位置会丢失,也就是body 的 scrollTop 属性值会变为 0。
这个新问题比起滚动穿透本身来说更加麻烦,所以这个方案是要进行优化的。
既然添加 modal_open 这个 class 会使 body 的滚动位置会丢失,那么我们为什么不在滚动位置丢失之前先保存下来,等到退出弹窗的前在將这个保存下来的滚动位置在设置回去。
// css 部分 .modal_open { position: fixed; height: 100%; } // js 部分 /** * ModalHelper helpers resolve the modal scrolling issue on mobile devices * https://github.com/twbs/bootstrap/issues/15852 */ var ModalHelper=(function(bodyClass) { var scrollTop; return { afterOpen: function() { scrollTop=document.scrollingElement.scrollTop || document.documentElement.scrollTop || document.body.scrollTop; document.body.classList.add(bodyClass); document.body.style.top=-scrollTop + 'px'; }, beforeClose: function() { document.body.classList.remove(bodyClass); document.scrollingElement.scrollTop=document.documentElement.scrollTop=document.body.scrollTop=scrollTop; } }; })('modal_open'); // method modalSwitch: function(){ let self=this; if( self.switchFlag==='close' ){ ModalHelper.afterOpen(); self.switchFlag='open'; }else{ ModalHelper.beforeClose(); self.switchFlag='close'; } }
jQuery:
方案二可以达到以下效果:
方案二可以适应绝大多数的弹窗需求,提测后测试方也没有在提其他问题,这个问题算是完美的解决了。不过有一个疑问:
IOS 自有的橡皮筋效果会导致页面会出现短暂卡顿现象,暂时没有找到原因,请教各位。
其他方案:
使用 preventDefault 阻止浏览器默认事件:
var modal=document.getElementById('modalBox'); modal.addEventListener('touchmove', function(e) { e.preventDefault(); }, false);
这个方案只适用于这个弹窗本身的高度小于屏幕的高度,即不可滚动的时候。touchmove 比 touchstart 更加合适。因为 touchstart 会连点击事件都阻止。
使用插件:
除非是自己实现起来太复杂,否则还是自己花点时间去实现。
原因有二:
这个方法理论上是最简单效果也最好的方法。
CSS中有个background-attachment属性,当我们设置属性值为fixed的时候,背景图片相对于窗体定位,不受滚动影响。
于是,我们的实现就很简单:信息流列表HTML中插入个广告<a>链接,然后广告图作为背景图呈现,设置background-attachment:fixed效果就可以实现了,就这么简单。
HTML和CSS代码示意:
<div class="list">信息流列表1</div> <div class="list">信息流列表2</div> <a href="#" class="ad" target="_blank">广告</a> <div class="list">信息流列表3</div> <div class="list">信息流列表4</div> .ad { display: block; height: 600px; background: url(./ad.jpg) no-repeat top fixed; background-size: 100%; }
iOS Safari很早时候position:fixed也不支持,后来妥协了,支持了;但是background-attachment:fixed还是老样子,不支持,怕是嫌弃background-attachment:fixed烧性能,对于一个连IE6,IE7浏览器都支持良好的CSS声明,Safari不支持(包括iOS微信),我也无力说些什么。
因此,我们还需要额外做些功夫,兼容下iPhone手机浏览器。
我的做法是如果是iPhone手机,广告图片postion:fixed定位,配合JS实时clip剪裁。核心JS如下:
// ele就是广告元素DOM对象 window.addEventListener('scroll', function () { var bound=ele.parentElement.getBoundingClientRect(); var clip='rect('+ [bound.top + 'px', ele.parentElement.clientWidth + 'px', bound.bottom + 'px', 0].join() +')'; ele.style.clip=clip; });
查了下浏览器兼容性资料,发现Android4.4+版本开始,放弃了对background-attachment:fixed的支持,但是Android Chrome浏览器却支持,这有些令人不解(见下图)。
?
我用家里人的Android手机测试,背景效果表现为scroll,看来JS补丁要多个Android设备。
position:fixed也可以实现藏在后面的信息流广告,实现原理就是藏在其他信息流元素的背后,以及头部或者底部元素(如果有)的底部,关键就是z-index层级控制了。虽然原理简单,但是实际操作还是有些啰嗦的,通常信息流页面的HTML结构都比较复杂,此时再z-index属性各种设置,很容易造成z-index混乱。
效果大致如下GIF截屏:
?
HTML和CSS代码原理示意:
<div class="list">信息流列表1</div> <div class="list">信息流列表2</div> <a href="#" class="ad" target="_blank"> <img src="./ad.jpg"> </a> <div class="list">信息流列表3</div> <div class="list">信息流列表4</div>
.list { background-color: #fff; position: relative; z-index: 1; } .ad { display: block; height: 576px; } .ad img { position: fixed; top: 0; width: 400px; }
优点和不足
基于position:fixed实现的优点在于:
1. 我们的广告内容可以支持复杂HTML,而不仅仅是一张图片;
2. 所有浏览器都兼容,包括iPhone Safari浏览器。
不足在于:
1. 需要其他元素进行层级配合,相互耦合增加了CSS的复杂度。
如果实际开发时候发现z-index层级控制比较麻烦,可以试试第一个demo中使用的CSS clip剪裁,直接只显示当前广告区域内容,不过需要JS配合,不是纯CSS实现了,自己权衡。
采用position:fixed固定定位实现的时候,我们还可以把广告元素从信息流列表中抽离,直接放在整个容器的后面,然后借助visibility属性实现点击穿透,如下示意:
<a href="#" class="ad">广告</a> <ul> <li>信息流列表1</li> <li>信息流列表2</li> <li></li> <!-- 撑开高度 --> <li>信息流列表3</li> <li>信息流列表4</li> </ul>
.ad { position: fixed; } ul { position: relative; visibility: hidden; } li:empty { /* 撑开高度,实际开发请使用类名控制,这里精简HTML才使用:empty */ height: 576px; } li:not(:empty) { visibility: visible; }
具体就不展开了。
英格兰凉了,比利时很强。
希望本文内容可以帮助需要的人。
然后,如果你有更好地实现方法,欢迎不吝赐教!
相信大多数前端开发者在日常工作中都碰过元素滚动时造成的一些非预期行为。
这篇文章就和大家来聊聊那些滚动中的非预期行为的出现原理和解决方案。
By default, mobile browsers tend to provide a "bounce" effect or even a page refresh when the top or bottom of a page (or other scroll area) is reached. You may also have noticed that 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.
上述是 MDN 中对于 overscroll-behavior 属性的描述,上述这段话恰恰描述了为什么会发生"滚动穿透"现象。
简单直译过来是说默认情况下,当到达页面的顶部或底部(或其他滚动区域)时,移动浏览器倾向于提供“弹跳”效果甚至页面刷新。您可能还注意到,当滚动内容页面顶部有一个包含滚动内容的对话框时,一旦到达对话框的滚动边界,底层页面就会开始滚动 - 这称为滚动链接。
直观上来说所谓的 Scroll Chaining(滚动链接)通常会在两种情况下被意外触发:
通常情况下,当我们对于某个不可滚动元素进行拖拽时往往会意外触发其父元素(背景元素)的滚动。
常见的业务场景比如在 Dialog、Mask 等存在全屏覆盖的内容中,当我们拖动不可滚动的弹出层元素内容时,背后的背景元素会被意外滚动。
比如上方图片中有两个元素,一个为红色边框存在滚动条的父元素,另一个则为蓝色边框黑色背景不存在滚动条的子元素。
当我们拖动不可滚动的子元素时,实际会意外造成父元素会跟随滚动。
还有另一种常见场景,我们在某个可滚动元素上进行拖动时,当该元素的滚动条已经到达顶部/底部。继续沿着相同方向进行拖动,此时浏览器会寻找当前元素最近的可滚动祖先元素从而意外触发祖先元素的滚动。
同样,动画中红色边框为拥有滚动区域的父元素,蓝色边框为父元素中同样拥有滚动区域的子元素。我们在子元素区域内进行拖拽时,当子元素滚动到底部(顶部)时,仍然继续往下(上)进行拖动。
上述两种情况相信大家也日常业务开发中碰到过不少次。这样的滚动意外行为用专业术语来说,被称为滚动链接(Scroll Chaining)。
那么,它是如何产生的呢?或者换句话说,浏览器哪条约束规定了这样的行为?
仔细查阅 w3c 上的 scroll-event 并没有明确的此项规定。
手册上仅仅明确了,滚动事件的 Target 可以是 Document 以及里边的 Element ,当 Target 为 Document 时事件会发生冒泡,而 Target 为 Element 时并不会发生冒泡,仅仅会 fire an event named scroll at target.
换句话说,也就是规范并没有对于 scroll chaining 这样的意外行为进行明确规定如何实现。
就比如,手册上规定了在 Element 以及 Document 中滚动必要的特性以及在代码层面应该如何处理这些特性,但是手册中并没有强制规定某些行为不可以被实现,就好比 scroll chaining 的行为。
不同的浏览器厂商私下里都遵从了 scroll chaining 的行为,而手册中并没有强制规定这种行为不应该被实现,自然这种行为也并不属于不被允许。
通过上边的描述我们已经了解了”滚动穿透“的原理:绝大多数浏览器厂商对于滚动,如果目标节点不能滚动则会尝试触发祖先节点的滚动,就比如上述第一种现象。而对于目标节点可以滚动时,当滚动到顶部/底部继续进行滚动时,同样会意外触发祖先节点的滚动。
在移动端,我们完全可以使用一种通用的解决方案来解决上述造成“滚动穿透”意外行为:
无论元素是否可以滚动时,每次元素的拖拽事件触发时我们只需要进行判断:
之所以寻找 event.target 元素至 event.currentTarget(包含)可滚动祖先元素,是因为我们需要判断本次滚动是否有效。
首先,我们先来看一个有关于移动端滚动的简单 Hook:
tsx复制代码import { useRef } from 'react'
const MIN_DISTANCE=10
type Direction='' | 'vertical' | 'horizontal'
function getDirection(x: number, y: number) {
if (x > y && x > MIN_DISTANCE) {
return 'horizontal'
}
if (y > x && y > MIN_DISTANCE) {
return 'vertical'
}
return ''
}
export function useTouch() {
const startX=useRef(0)
const startY=useRef(0)
const deltaX=useRef(0)
const deltaY=useRef(0)
const offsetX=useRef(0)
const offsetY=useRef(0)
const direction=useRef<Direction>('')
const isVertical=()=> direction.current==='vertical'
const isHorizontal=()=> direction.current==='horizontal'
const reset=()=> {
deltaX.current=0
deltaY.current=0
offsetX.current=0
offsetY.current=0
direction.current=''
}
const start=((event: TouchEvent)=> {
reset()
startX.current=event.touches[0].clientX
startY.current=event.touches[0].clientY
}) as EventListener
const move=((event: TouchEvent)=> {
const touch=event.touches[0]
// Fix: Safari back will set clientX to negative number
deltaX.current=touch.clientX < 0 ? 0 : touch.clientX - startX.current
deltaY.current=touch.clientY - startY.current
offsetX.current=Math.abs(deltaX.current)
offsetY.current=Math.abs(deltaY.current)
if (!direction.current) {
direction.current=getDirection(offsetX.current, offsetY.current)
}
}) as EventListener
return {
move,
start,
reset,
startX,
startY,
deltaX,
deltaY,
offsetX,
offsetY,
direction,
isVertical,
isHorizontal,
}
}
上述代码我相信大家一看便知,useTouch 这个 hook 定义了三个 start、move、reset 方法。
通过 useTouch 这个 hook 我们可以在移动端配合 touchstart、onTouchMove 轻松的计算出手指拖动时的方向和距离。
tsx复制代码// canUseDom 方法是对于是否可以使用 Dom 情况下的判断,主要为了甄别( Server Side Render )
import { canUseDom } from './can-use-dom'
type ScrollElement=HTMLElement | Window
const defaultRoot=canUseDom ? window : undefined
const overflowStylePatterns=['scroll', 'auto', 'overlay']
function isElement(node: Element) {
const ELEMENT_NODE_TYPE=1
return node.nodeType===ELEMENT_NODE_TYPE
}
export function getScrollParent(
el: Element,
root: ScrollElement | null | undefined=defaultRoot
): Window | Element | null | undefined {
let node=el
while (node && node !==root && isElement(node)) {
if (node===document.body) {
return root
}
const { overflowY }=window.getComputedStyle(node)
if (
overflowStylePatterns.includes(overflowY) &&
node.scrollHeight > node.clientHeight
) {
return node
}
node=node.parentNode as Element
}
return root
}
getScrollParent 方法本质上从 el(event.target) 到 root(event.currentTarget) 范围内寻找最近的滚动祖先元素。
代码同样也并不是特别难理解,在 while 循环中从传入的第一个参数 el 一层一层往上寻找。要么寻找到可滚动的元素,要么一直寻找到 node===root 直接返回 root。
比如这样的场景:
tsx复制代码import { useEffect, useRef } from 'react';
import './App.css';
import { getScrollParent } from './hooks/getScrollParent';
function App() {
const ref=useRef<HTMLDivElement>(null);
const onTouchMove=(event: TouchEvent)=> {
const el=getScrollParent(event.target as Element, ref.current);
console.log(el, 'el'); // child-1
};
useEffect(()=> {
document.addEventListener('touchmove', onTouchMove);
}, []);
return (
<>
<div ref={ref} className="parent">
<div
className="child-1"
style={{
height: '300px',
overflowY: 'auto',
}}
>
<div
style={{
height: '600px',
}}
>
This is child-2
</div>
</div>
</div>
</>
);
}
export default App;
我们在页面中拖拽滚动 This is child-2 内容时,此时控制台会打印 getScrollParent 从 event.target (也就是 This is child-2 元素开始)寻找到的类名为 .parent 区域内的最近滚动元素 .child-1 元素。
上边我们了解了一个基础的 useTouch 关于拖拽位置计算的 hook 以及 getScrollParent 获取区域内最近的可滚动祖先元素的方法,接下来我们就来看看在移动端中关于阻止 scroll chaining 意外滚动行为的通用 hook。
这里,我直接贴一段 ant-design-mobile 中的实现代码,(实际这是 ant-design-mobile 中从 vant 中搬运的代码):
tsx复制代码import { useTouch } from './use-touch'
import { useEffect, RefObject } from 'react'
import { getScrollParent } from './get-scroll-parent'
import { supportsPassive } from './supports-passive'
let totalLockCount=0
const BODY_LOCK_CLASS='adm-overflow-hidden'
function getScrollableElement(el: HTMLElement | null) {
let current=el?.parentElement
while (current) {
if (current.clientHeight < current.scrollHeight) {
return current
}
current=current.parentElement
}
return null
}
export function useLockScroll(
rootRef: RefObject<HTMLElement>,
shouldLock: boolean | 'strict'
) {
const touch=useTouch()
/**
* 当手指拖动时
* @param event
* @returns
*/
const onTouchMove=(event: TouchEvent)=> {
touch.move(event)
// 获取拖动方向
// 如果 deltaY 大于0,拖动的当前Y轴位置大于起始位置即从下往上拖动将 direction 变为 '10',否则则会 `01`
const direction=touch.deltaY.current > 0 ? '10' : '01'
// 我们在上边提到过,找到范围内可滚动的元素
const el=getScrollParent(
event.target as Element,
rootRef.current
) as HTMLElement
if (!el) return
// This has perf cost but we have to compatible with iOS 12
if (shouldLock==='strict') {
const scrollableParent=getScrollableElement(event.target as HTMLElement)
if (
scrollableParent===document.body ||
scrollableParent===document.documentElement
) {
event.preventDefault()
return
}
}
// 获取可滚动元素的位置属性
const { scrollHeight, clientHeight, offsetHeight, scrollTop }=el
// 定义初始 status
let status='11'
if (scrollTop===0) {
// 滚动条在顶部,表示还未滚动
// 滚动条在顶部时,需要判断是当前元素不可以滚动还是可以滚动但是未进行任何滚动
// 当 offsetHeight >=scrollHeight 表示当前元素不可滚动,此时将 status 变为 00,
// 否则表示当前元素可滚动但滚动条在顶部,将status变为 01
status=offsetHeight >=scrollHeight ? '00' : '01'
} else if (Math.abs(scrollHeight - clientHeight - scrollTop) < 1) {
// 滚动条已经到达底部(表示已经滚动到底),将 status 变为 '10'
status='10'
}
// 1. 完成上述的判断后,如果 status===11 表示当前元素可滚动并且滚动条不在顶部也不在底部(即在中间),表示 touchMove 事件不应该阻止元素滚动(当前滚动为正常现象)
// 2. 同时 touch.isVertical() 明确确保是垂直方向的拖动
// 3. parseInt(status, 2),当 status 不为 11 时,分为以下三种情况分别代表:
// 3.1 status 00 表示区域内未寻找到任何可滚动元素
// 3.2 status 01 表示寻找到可滚动元素,当前元素为滚动条在顶部
// 3.3 status 10 表示寻找到可滚动元素,当前元素滚动条在底部
// 自然 parseInt(status, 2) & parseInt(direction, 2) 这里使用了二进制的方式,
// 3.4 当 status 为 00 时, 0 & 任何数都是 0.自然 !(parseInt(status, 2) & parseInt(direction, 2)) 会变为 true (对应 3.1 情况),需要阻止意外的滚动行为。
// 3.5 当 status 为 01 时(对应 3.2 滚动条在顶部),此时当用户从下往上拖动时,需要阻止意外的滚动行为发生。否则,则不需要阻止正常滚动。 自然 status==='01' ,direction===10(从下往上拖动),!(parseInt(status, 2) & parseInt(direction, 2)) 为 true 需要进行阻止默认滚动行为。(进制上 1 & 1 为 1 ,1 & 2 为 0)
// 3.6 根据 3.5 的情况,当 status 为 10 (对应 3.3)滚动到达底部,自然对于从上往下拖动时 direction 为 01 时也应该阻止,所以 (2&1=0) 自然 !(parseInt(status, 2) & parseInt(direction, 2)) 为 true,同样会进入 if 语句阻止意外滚动。
if (
status !=='11' &&
touch.isVertical() &&
!(parseInt(status, 2) & parseInt(direction, 2))
) {
if (event.cancelable) {
event.preventDefault()
}
}
}
/**
* 锁定方法
* 1. 添加 touchstart 和 touchmove 事件监听
* 2. 根据 totalLockCount,当 hook 运行时为 body 添加 overflow hidden 的样式类名称
*/
const lock=()=> {
document.addEventListener('touchstart', touch.start)
document.addEventListener(
'touchmove',
onTouchMove,
supportsPassive ? { passive: false } : false
)
if (!totalLockCount) {
document.body.classList.add(BODY_LOCK_CLASS)
}
totalLockCount++
}
/**
* 组件销毁时移除事件监听方法,以及清空 body 上的 overflow hidden 的类名
*/
const unlock=()=> {
if (totalLockCount) {
document.removeEventListener('touchstart', touch.start)
document.removeEventListener('touchmove', onTouchMove)
totalLockCount--
if (!totalLockCount) {
document.body.classList.remove(BODY_LOCK_CLASS)
}
}
}
useEffect(()=> {
// 如果传入 shouldLock 表示需要防止意外滚动
if (shouldLock) {
lock()
return ()=> {
unlock()
}
}
}, [shouldLock])
}
我在上述代码片段中每一行都进行了详细的注释,认真看这段代码相信大家不难看懂。上述的代码仍然是按照我们在文章开头讲述的解决思路来解决移动端滚动链接的意外行为。
关于上边代码中有几个小 Tips ,这里和大家稍微赘述下:
文章到这里就和大家说声再见了,刚好前段时间在公司内编写移动端组件时遇到过这个问题所以拿出来和大家分享。
当然,如果大家对于文章中的内容有什么疑惑或者有更好的解决方案。你可以在评论区留下你的看法,我们可以一起进行讨论,谢谢大家。
作者:19组清风
链接:https://juejin.cn/post/7261493331188449341
*请认真填写需求信息,我们会在24小时内与您取得联系。