# 移动端弹出层滚动穿透处理

作者: 东润

# 背景

在移动端中,如果使用了一个固定定位的遮罩层,而被遮罩层盖住的dom元素如果可以被滚动的话,当用户在遮罩层上滑过时,有可能会出现被盖住的元素还是能滚动的情况,遮罩层貌似失效。这种情况称之为滚动穿透

# 方案

通过尝试前辈整理网络上的方案(https://www.cnblogs.com/padding1015/p/10568070.html)后,最终结合了方案三方案四变种来解决。

下面是前辈整理的方案,如果想直接看最终解决方案,可以跳转到解决

# 方案一:body无滚动 + 弹层无滚动[css-超出隐藏]

适用场景需满足以下条件:

1、body最好是一屏、无滚动

2、虽然body内容超出一屏需滚动,但触发弹层出现的按钮在第一屏中

3、弹层不用滚动效果

解决方案:

弹层出现时,用css给body设置固定定位和超出隐藏。

关键代码:

btn.onclick = function () {
  // 弹层出现
  layer.style.display = 'block';
  document.body.style.overflow = 'hidden';
  document.body.style.position = 'fixed';// 果然是因为加了fixed,就会自动回滚到顶部
};
var closeBtn = document.getElementById('close');
closeBtn.onclick = function () {
  // 弹层关闭
  layer.style.display = 'none';
  document.body.style.overflow = 'auto';
  document.body.style.position = 'static';
};

ps:我偷懒直接js控制了行间样式,但标准写法应该是给body添加类名来控制

局限问题: body滚动后再触发弹层,会使body页面回滚到顶部。

赘述:

这个方案是简单粗暴的给body设置:

body {
  overflow: hidden;
  position: fixed;
}

起初,我只给body一个overflow隐藏,弹窗出现后上下滑动,底部的body也不会滑动,瞬间感觉世界很美好。

但是晴天霹雳来的太快,在模拟器是起作用的,但是到了真机上,body还是会滚动。所以必须添加上fixed固定定位,才能在弹窗出现后,body不能被拖动。

但是,也因为加了position: fixed;出现了新问题:

它会导致触发弹层后,body回滚、定位到顶部。假如用户向下翻页了几屏后,再触发弹层,整个页面就会回滚到最初的顶部,这对用户体验来说是非常不好的。

因此,这种方案的适用环境也就非常局限,只能适用触发弹层出现的按钮位于第一屏中的情况。需要我们能确保用户在不发生上滑页面滚动屏幕的情况下就能触发弹层出现,就不会出现我上边说的问题。

或者干脆我们就是一个swiper项目,每一页都是一屏,body不能滚动,那么在项目中用这个方法,还是性价比很高的。

# 方案二:body无滚动 + 弹层内部滚动[css-弹框超出滚动|真机有bug]

适用场景需满足以下条件:

1、body最好是一屏、无滚动

2、虽然body内容超出一屏需滚动,但触发弹层出现的按钮在第一屏中

解决方案:

弹层出现时,用css给body设置固定定位和超出隐藏。

至于弹层内部的滚动,设置一个overflow: scroll;即可。

不过为了流畅体验,可以加上-webkit-overflow-scrolling: touch,以解决在IOS上滚动惯性失效的问题,提高滚动的流畅度。

关键代码:

JS控制弹窗的交互、body的禁止滚动

btn.onclick = function () {
  // 弹层出现
  layer.style.display = 'block';
  document.body.style.overflow = 'hidden';
  document.body.style.position = 'fixed';// 果然是因为加了fixed,就会自动回滚到顶部
};
var closeBtn = document.getElementById('close');
closeBtn.onclick = function () {
  // 弹层关闭
  layer.style.display = 'none';
  document.body.style.overflow = 'auto';
  document.body.style.position = 'static';
};

css添加弹层的超出滚动效果

overflow-y: scroll;
-webkit-overflow-scrolling: touch;/* 解决在IOS上滚动惯性失效的问题 */

局限问题:

弹层中内容滚动到顶部或底部后,还会连带页面body一起滚动。也就是还会发生穿透效果。

赘述:

方案一,我们只是在弹窗打开的时候,简单的禁止了body的滚动效果。但是限制条件是,我们的弹窗也不能滚动。这次,我们优化一下 -- 允许弹窗内部滚动。

在前边代码的基础上,通过css单纯的设置一下纵轴的超出滚动。

overflow-y: scroll;

只有这一句滚动效果不太好,没有原生滚动流畅。加一个属性

-webkit-overflow-scrolling: touch;/* 解决在IOS上滚动惯性失效的问题 */

但是这只是简单地解决了一个问题:实现了滑动弹窗其他地方(蒙层背景),底部body页面确实未跟随滚动。

真正的问题是当我们滑动弹窗可滚动区域,把可滚动区域的内容上滑到底部或下拉到顶部后,再触发弹窗可滚动区域准备滑动,此时的背景页面就会跟随滚动。真是恐怖。

因此还需要我们对弹层的可滚动区域的滑动事件做监听:

第一种情况,若向上滑动时,到达底部;或者第二种情况,若向下滑动时,已到顶部。

这两种情况任意一种发生时,就阻止滑动事件。

这段逻辑代码如下:

var targetY = null;
layerBox.addEventListener('touchstart', function (e) {
  // clientY-客户区坐标Y 、pageY-页面坐标Y
  targetY = Math.floor(e.targetTouches[0].clientY);
});
layerBox.addEventListener('touchmove', function (e) {
  // 检测可滚动区域的滚动事件,如果滑到了顶部或底部,阻止默认事件
  var NewTargetY = Math.floor(e.targetTouches[0].clientY); var // 本次移动时鼠标的位置,用于计算
    sTop = layerBox.scrollTop; var // 当前滚动的距离
    sH = layerBox.scrollHeight; var // 可滚动区域的高度
    lyBoxH = layerBox.clientHeight;// 可视区域的高度
  if (sTop <= 0 && NewTargetY - targetY > 0 && '鼠标方向向下-到顶') {
    // console.log('条件1成立:下拉页面到顶');
    e.preventDefault();
  } else if (sTop >= sH - lyBoxH && NewTargetY - targetY < 0 &&
    '鼠标方向向上-到底') {
    // console.log('条件2成立:上翻页面到底');
    e.preventDefault();
  }
}, false);

# 方案三:body滚动 + 弹层无滚动[js-阻止弹层中touchmove的默认行为]

适用场景:

1、(适用)body可滚动

2、(适用)触发弹层出现的按钮可以在任意位置

需满足以下条件:

1、(需满足)弹层内容不需要滚动

解决方案:

当弹层出现的时候不需要再禁掉body的滚动效果了,我们可以从弹层方面入手,阻止弹框的touchmove事件的默认行为。就能阻止滚动穿透。

关键代码:

js控制弹窗的交互、弹窗的禁止滚动

btn.onclick = function () {
  layer.style.display = 'block';
  layer.addEventListener('touchmove', function (e) {
    e.preventDefault();
  }, false);
};
var closeBtn = document.getElementById('close');
closeBtn.onclick = function () {
  layer.style.display = 'none';
  // 弹窗关闭后,可解除所有禁止 - 懒人就不写了
};

局限问题:

因为touchmove被禁掉了,就会造成弹窗内部所有位置都不能响应touchmove事件,效果上就是弹窗内部不能再滚动了。

赘述:

在弹层不需要超出滚动的情况下,才可以使用这个。也就是禁止整个弹窗的touchmove的默认事件,以阻止滚动穿透。

同样,如果弹层中需要滚动效果,则不能解决了。那么这时,就引来我们的主题难点,可以有以下几种思路解决:

# 方案四:body滚动 + 弹层内部滚动[js-检测touchmove的target]

简单粗暴,一针见血:谁能动谁动,谁不能动就禁止touchmove事件的preventEvent默认行为。

适用以下场景:

1、body可滚动

2、触发弹层出现的按钮可以在任意位置

3、弹层可以滚动

简单来说,就是适用任何场景

解决方案:

检测touchmove事件,如果touch的目标是弹窗不可滚动区域(背景蒙层)就禁掉默认事件,反之就不做控制。

但是同样的问题是,需要判断滚动到顶部和滚动到底部的时候禁止滚动。否则,就和第二条一样,触碰到上下两端,弹窗可滚动区域的滚动条到了顶部或者底部,依旧穿透到body,使得body跟随弹窗滚动。

所以依旧需要同样的代码,对可滚动区域的touchmove做监听:若到顶或到底,同样阻止默认事件。

需要做的事情有:

1、预存一个全局变量targetY

2、监听可滚动区域的touchstart事件,记录下第一次按下时的

e.targetTouches[0].clientY值,赋值给targetY

3、后期touchmove里边获取每次的e.targetTouches[0].clientY与第一次的进行比较,可以得出用户是上滑还是下滑手势。

4、如果手势是向上滑,且页面现在滚动的位置刚好是整个可滚动高度——弹窗内容可视区域高度的值,说明上滑到底,阻止默认事件。

同理,如果手势是向下滑,并且当前滚动高度为0说明当前展示的已经在可滚动内容的顶部了,此时再次阻止默认事件即可。

两个判断条件可以写到一个if中,用 || (或)表示即可。我这里为了代码可读性,分开写了:

if (sTop <= 0 && NewTargetY - targetY > 0 && '鼠标方向向下-到顶') {
  // console.log('条件1成立:下拉页面到顶');
  e.preventDefault();
} else if (sTop >= sH - lyBoxH && NewTargetY - targetY < 0 &&
  '鼠标方向向上-到底') {
  // console.log('条件2成立:上翻页面到底');
  e.preventDefault();
}

完整代码:

出现弹窗时:

btn.onclick = function () {
  layer.style.display = 'block';
  layer.addEventListener('touchmove', function (e) {
    e.stopPropagation();
    if (e.target == layer) {
      // 让不可以滚动的区域不要滚动
      console.log(e.target);
      e.preventDefault();
    }
  }, false);
  var targetY = null;
  layerBox.addEventListener('touchstart', function (e) {
    // clientY-客户区坐标Y 、pageY-页面坐标Y
    targetY = Math.floor(e.targetTouches[0].clientY);
  });
  layerBox.addEventListener('touchmove', function (e) {
    // 检测可滚动区域的滚动事件,如果滑到了顶部或底部,阻止默认事件
    var NewTargetY = Math.floor(e.targetTouches[0].clientY); var // 本次移动时鼠标的位置,用于计算
      sTop = layerBox.scrollTop; var // 当前滚动的距离
      sH = layerBox.scrollHeight; var // 可滚动区域的高度
      lyBoxH = layerBox.clientHeight;// 可视区域的高度
    if (sTop <= 0 && NewTargetY - targetY > 0 && '鼠标方向向下-到顶') {
      // console.log('条件1成立:下拉页面到顶');
      e.preventDefault();
    } else if (sTop >= sH - lyBoxH && NewTargetY - targetY < 0 &&
      '鼠标方向向上-到底') {
      // console.log('条件2成立:上翻页面到底');
      e.preventDefault();
    }
  }, false);
};

隐藏弹窗时:

var closeBtn = document.getElementById('close');
closeBtn.onclick = function () {
  layer.style.display = 'none';
  // 弹窗关闭后,可解除所有禁止
};

# 方案五:body滚动 + 弹层内部滚动[js-代码模拟上下滑动手势效果]

我想,既然我们监控弹层、监控touchY那么辛苦了已经,还差再辛苦一点,自己写一个模拟手势滚动效果嘛!

这次依旧从弹层上入手,不让弹层用css自动的超出滚动,而是超出隐藏,然后简单粗暴地利用JS的touchstart、touchmove、touchend等事件,手动写一个自定义滚动效果。

适用场景:

一切,这种做法应用到项目中过,经得起测试的考验。

解决方案与思路:

具体制作思路写在js注释上。

1、交互代码

/* 交互代码 */
btn.onclick = function () {
  layer.style.display = 'block';
  // 为了我的css能统一使用,这里偷个懒,加个行间样式,
  // 把之前做demo用的overflow滚动给禁掉,然后改了点别的样式
  layerBox.style.overflow = 'hidden';
  layerBox.style.paddingTop = 0;
  layerList.style.paddingTop = 0;
  layerList.style.paddingBottom = 0;
};
var closeBtn = document.getElementById('close');
closeBtn.onclick = function () {
  console.log('?/*  */');
  layer.style.display = 'none';
  // 弹窗关闭后,可解除所有禁止 - 懒人就不写了
};

2、禁掉弹窗的touchmove 的默认事件

/* 禁掉所有的touchmove事件 */
layer.addEventListener('touchmove', function (e) {
  e.preventDefault();
}, false);

3、重写手势滑动效果

/* 重新写touchmove效果 */
var targetY = null;
var transH = 0;
var lastY = 0;
layerBox.addEventListener('touchstart', function (e) {
  // 这里简单的把整个layerBox的默认事件给禁止了,所以close的click事件就不起作用了。
  // 可以把结构再改改把close挪出来。或者js把close绕开:
  if (e.target != closeBtn) {
    e.preventDefault();
  }
  // clientY-客户区坐标Y 、pageY-页面坐标Y
  lastY = targetY = Math.floor(e.targetTouches[0].clientY);
});
layerBox.addEventListener('touchmove', function (e) {
  // 为了写这个,还得改动一下结构
  var NewTargetY = Math.floor(e.targetTouches[0].clientY); // 本次移动时鼠标的位置,用于计算
  var sTop = layerBox.scrollTop; // 当前滚动的距离
  var sH = layerBox.scrollHeight; // 可滚动区域的高度
  var lyBoxH = layerBox.clientHeight; // 可视区域的高度
  if (NewTargetY - targetY > 0 && '鼠标方向向下滑-上翻效果') {
    transH += NewTargetY - lastY;// 先把这次鼠标滑动的距离计算出来,叠加给transH
    transH = transH >= 0 ? 0 : transH;// 原本transH是负值,如果一直向上翻,就需要一直+正值,一旦正负相加抵消到>=0,说明翻到顶了,就直接赋值为顶,不再上翻。
  } else if (NewTargetY - targetY < 0 && '鼠标方向向上滑动-下拉效果') {
    transH -= lastY - NewTargetY;// 先把这次鼠标滑动的距离计算出来,叠减给transH
    transH = Math.abs(transH) > sH - lyBoxH ? -(sH - lyBoxH) : transH;// 如果transH的绝对值大于可滚动的距离了,说明翻到底,则把可滚动区域翻到底的值赋给他。否则就一直下滚鼠标移动的距离
  }
  layerList.style.transform = `translateY(${transH}px)`;
  lastY = NewTargetY;
}, false);

大致思路关键点就在touchmove里边: 1、在touchstart的时候,监听用户手势按下,记录初次按下的坐标点y的值y1。

2、touchmove手势移动的时候,再次获取最新的坐标点y的值y2,(其实记录可滚动区域的可滚动高度、当前滚动距离等可以在一开始就记录,我这里写到了touchmove里,还可以再优化)。

3、然后通过计算y1和y2 的差值判断出用户是朝哪个方向移动的手势。

4、进而根据不同的手势方向给弹层可滚动内容的transform添加位移translate效果(或者基础用position: absolute,再根据手势移动的距离,动态设置top的值。代码不止一种)。思路就是把手势移动的长度添加到弹层上下移动的距离上。

5、可能需要多考虑的一点是,当用户一直上翻到底或者一直下拉到顶时,做一下极值的判断和限制。

6、最后把本次移动到的点y2替换给y1,根据手势移动实时更新当前手势的地址。

7、另外这里还可以在touchend事件里,把touchstart和touchmove包括自身touchend的事件都解绑掉。我偷懒就不写了。

问题局限:

不好的点就是没有原生滚动条那种效果,一点也不灵动,只能鼠标移动多少、可滚动区域挪动多少。

# 方案六:body滚动 + 弹层内部滚动[css+js-记录滚动位置]

换个脑子,回到最初 寻找新的思路。

不从弹层上入手,也就是不禁掉弹层的touchmove默认事件。

而是继续给body一个overflow: hidden;和position: fixed;就会有页面跳转到顶部的现象。

这时,我们可以通过记录用户打开弹窗前所滚动页面的位置,在弹层展开的时候赋给body在css中的top值,等关闭弹层的时候,再把这个值赋值给body在js中的scrollTop值,还原body的滚动位置。

这种原理简单,理解方便。并且各方面都能实现。比如说:

body可以继续滚动、弹层出来后他的top值限制他不会跳到顶部、

弹层中不管短还是长,需不需要滚动,都不care,自由活动、

然后关闭弹层后,body还可以继续滚动,丝毫不受影响、

兼容性虽然都写了,但是我也没测试~

这个神不知鬼不觉的人工介入方案也是各位前辈写烂的一个点。很是巧妙,很是经典。

代码:

1、事先准备一个工具:

function getScrollOffset () {
  if (window.pageXOffset) {
    return {
      x: window.pageXOffset,
      y: window.pageYOffset,
    };
  } else {
    return {
      x: document.body.scrollLeft || document.documentElement.scrollLeft,
      y: document.body.scrollTop || document.documentElement.scrollTop,
    };
  }
};

2、获取页面的滚动距离:

/* 动态获取当前页面的滚动位置 */
var scrollT = null;
var LastScrollT = 0;
window.onscroll = function (e) {
  scrollT = getScrollOffset().y;// 滚动条距离
};

3、弹层出现/消失的主流程

btn.onclick = function () {
  layer.style.display = 'block';
  // 在这里获取滚动的距离,赋值给body,好让他不要跳上去。
  document.body.style.overflow = 'hidden';
  document.body.style.position = 'fixed';
  document.body.style.top = -scrollT + 'px';// 改变css中top的值,配合fixed使用
  // 然后找个变量存一下刚才的scrolltop,要不然一会重新赋值,真正的scrollT会变0
  LastScrollT = scrollT;
};
var closeBtn = document.getElementById('close');
closeBtn.onclick = function () {
  console.log(LastScrollT);
  layer.style.display = 'none';
  document.body.style.overflow = 'auto';
  document.body.style.position = 'static';

  // 关闭close弹层的时候,改变js中的scrollTop值为上次保存的LastScrollT的值。并根据兼容性赋给对应的值。
  if (window.pageXOffset) {
    window.pageYOffset = LastScrollT;
  } else {
    document.body.scrollTop = LastScrollT;
    document.documentElement.scrollTop = LastScrollT;
  }
};

局限问题:

这个方法我在真机上测试时发现一个问题,是IOS的:

大家应该都知道IOS的页面顶部继续下拉或者底部继续上拉,都会出现页面后边的背景,这个在手机上很常见。但是到了这个解决方法里边,如果用户在弹窗黑屏上继续下拉漏出了底部背景,那弹层的滚动效果就都没了。

# 解决

通过对组件库的梳理,弹出层的基本结构为遮罩展示内容两个部分。

遮罩是不需要滚动,因此直接使用方案三禁止遮罩的滚动。

由于使用弹出层的情景无法确定,因此排除了所有通过css来控制的方案。综合考虑后,使用了方案四来解决。

但是方案四需要确定滚动的元素是哪一个,因此需要解决在弹出层复杂使用情景下,找到展示内容中滚动元素。

通过以下代码,通过判定元素的滚动高度是否比元素高度长,否则就找其父元素,递归调用直到找到那个实际滚动的元素或到展示内容包裹元素停止

getScrollTarget (target) {
  if (!target || target === this.$content) {
    return target;
  }
  const scrollHeight = target.scrollHeight; // 可滚动区域的高度
  const clientHeight = target.clientHeight; // 可视区域的高度
  return scrollHeight > clientHeight ? target : this.getScrollTarget(target.parentNode);
},

然后将正常不会变动的值直接在touchstart阶段获取并处理,减少touchmove阶段的计算,提高性能

contentTouchStart (e) {
  this.touchStart = Math.floor(e.targetTouches[0].clientY);
  if (this.$touchTarget !== e.target) {
    this.$target = this.getScrollTarget(e.target);
    this.$touchTarget = e.target;
    this.exceedHeight = this.$target.scrollHeight - this.$target.clientHeight;
  }
},
contentTouchMove (e) {
  // 检测可滚动区域的滚动事件,如果滑到了顶部或底部,阻止默认事件
  if (this.exceedHeight === 0) {
    e.preventDefault();
    return false;
  }
  const touchEnd = Math.floor(e.targetTouches[0].clientY);
  const scrollTop = this.$target.scrollTop; // 当前滚动的距离
  if (scrollTop <= 0 && touchEnd > this.touchStart) {
    e.preventDefault();
    return false;
  } else if (scrollTop >= this.exceedHeight && touchEnd < this.touchStart) {
    e.preventDefault();
    return false;
  }
  return true;
},

通过以上方案解决了thinking-ui的弹出层滚动穿透问题。