123.移动端滚动穿透完美解决方案加原理描述

引言:这是个令人头疼并且及其常见的体验问题。

所谓滚动穿透,指我们滑动顶层的弹窗,但效果上却滑动了底层的内容。


什么情况会有该问题?

出现该问题的大前提:

  • 整个webapp是设置为可以滚动的。

    例如:vue-cli中包裹的最外层html/body没有设置height:100%;overflow:hidden;

  • 在手机上打开页面。(chrome上观测不到!!!高维世界?)

    !!!Chrome的移动端调试模拟器是看不见任何问题的


本文提供的解决案例的框架为vue-cli,若您使用原生或者react也不要紧,原理是一模一样的。

具体原理分析如下:

  • 1、改变顶层:从穿透的思路考虑,如果顶层不会穿透过去,就解决了。
  • 2、改变底层:既然是顶层影响了底层,要是底层不会滚动,就解决了。

明白了以上的两种原理,其实就很好解决了。
明白了以上的两种原理,其实就很好解决了。
明白了以上的两种原理,其实就很好解决了。

有问题的原始代码和bug展示

代码如下:

<template>
  <div class="wrap">
    <div class="main">
      <button @click="showDialog">出现吧弹窗</button>
    </div>
    <div class="dialog-wrapper" v-if="visible" @click="closeDialog">
      <div class="dialog-content" @click.stop>
        <header @click="closeDialog">隐藏弹窗</header>
        <ul>
          <li>一个元素,不需要滚动</li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      visible: false,
    };
  },
  created() {},
  mounted() {},
  methods: {
    showDialog() {
      this.visible = true
    },
    closeDialog() {
      this.visible = false
    }
  }
};
</script>

<style scoped lang="less">
.wrap {
  width: 100%;
  height: 100%;
  background: #088a9e;
  overflow: scroll;
  border: 5px solid #089e8a;

  .main {
    width: 100%;
    height: 100%;
    background: #eee;
  }

  .dialog-wrapper {
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    right: 0;
    background: rgba(0, 0, 0, .5);
    .dialog-content {
      position: fixed;
      bottom: 0;
      left: 0;
      width: 100%;
      height: 300px;
      overflow: scroll;
      background: seagreen;

      li {
        margin-top: 10px;
        width: 100%;
        height: 150px;
        background: khaki;
      }
    }
  }
}
</style>

bug效果如下:

情况一、若顶层弹窗本身不需要滚动(这种情况较为简单)

如果弹窗本身不需要滑动,那是非常简单的。

方法A、从让顶部不穿透的考虑触发,我们可以这么作修改

直接修改顶层弹窗的div,设置 @touchmove.prevent 即可.

<div class="dialog-wrapper" @touchmove.prevent v-if="visible" @click="visible = false">

实现后的效果如下:


方法B、从让底层不能滚动的考虑触发,我们可以这么作修改

我们在弹窗出现的时候,临时不让底部可以滚动;在弹窗消失的时候,再把底部可以滚动的功能加回去。

这里我们使用添加类名,使得底层临时不能滑动解决。
类名里面我们利用设置了position:fixed;不会随屏幕滚动的原理。

css添加如下

.dialog-open {
  position: fixed;
}

vue中给滚动的元素加上ref便于获取,再加上两个method用于添加类名/删除类名。问题解决

这里还是放出完整代码。

<template>
  <div class="wrap" ref="scrollEl">
    <div class="main">
      <button @click="showDialog">出现吧弹窗</button>
    </div>
    <div class="dialog-wrapper" v-if="visible" @click="closeDialog">
      <div class="dialog-content" @click.stop>
        <header @click="closeDialog">隐藏弹窗</header>
        <ul>
          <li>一个元素,不需要滚动</li>
        </ul>
      </div>
    </div>

  </div>
</template>

<script>
export default {
  data() {
    return {
      visible: false,
    };
  },
  created() {},
  mounted() {},
  methods: {
    showDialog() {
      this.visible = true
      this.afterDialogOpen()
    },
    closeDialog() {
      this.visible = false
      this.afterDialogClose()
    },
    afterDialogOpen() {
      this.$refs.scrollEl.classList.add('dialog-open')
    },
    afterDialogClose() {
      this.$refs.scrollEl.classList.remove('dialog-open')
    }
  }
};
</script>

<style scoped lang="less">
.wrap {
  width: 100%;
  height: 100%;
  background: #088a9e;
  overflow: scroll;
  border: 5px solid #089e8a;

  .main {
    width: 100%;
    height: 100%;
    background: #eee;
  }

  .dialog-wrapper {
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    right: 0;
    background: rgba(0, 0, 0, .5);
    .dialog-content {
      position: fixed;
      bottom: 0;
      left: 0;
      width: 100%;
      height: 300px;
      overflow: scroll;
      background: seagreen;

      li {
        margin-top: 10px;
        width: 100%;
        height: 150px;
        background: khaki;
      }
    }
  }
}
.dialog-open {
  position: fixed;
}
</style>

效果其实基本一样。

情况二、若弹窗本身需要滚动

我们修改本文最上方的(未解决穿透时)的原始代码结构,仅添加多个li

<ul>
  <li>很多元素,需要滚动</li>
  <li>很多元素,需要滚动</li>
  <li>很多元素,需要滚动</li>
  <li>很多元素,需要滚动</li>
  <li>很多元素,需要滚动</li>
</ul>

重现了穿透问题。
并且这里直接对父级采用 touchmove.prevent 是不可行的,因为弹窗本身需要滚动,若使用了,本身也滚不了了。

bug效果如下:


显然这里我们不能完全照抄情况一的方法A。否则整块元素都划不动了。

父级设置touchmove.prevent,其内的元素也是会受到影响的。

但解决原理是一样的。
但解决原理是一样的。
但解决原理是一样的。

既然父级会影响,那我搞个同级不就好了吗!
如下:我们多添加一层元素设为touchmove.prevent,同级的元素是不会影响的,利用z-index区分开来。

方法C:还是从改变顶层元素不让穿透的思想解决

方法A的优化升级版

下面是我们的部分修改方案(期间我们还会遇见一个问题,关于 touchomove 和 click 的问题)

<template>
  <div class="wrap">
    <div class="main">
      <button @click="showDialog">出现吧弹窗</button>
    </div>
    <div class="dialog-wrapper" v-if="visible" @click="closeDialog">
      <div class="dialog-no-touch-area" @touchmove.prevent.stop>
      </div>
      <div class="dialog-content" touchmove.stop @click.stop>
        <header @click="closeDialog">隐藏弹窗</header>
        <ul>
          <li>很多元素,需要滚动</li>
          <li>很多元素,需要滚动</li>
          <li>很多元素,需要滚动</li>
          <li>很多元素,需要滚动</li>
          <li>很多元素,需要滚动</li>
        </ul>
      </div>
    </div>

  </div>
</template>

<script>
export default {
  data() {
    return {
      visible: false,
    };
  },
  created() {},
  mounted() {},
  methods: {
    showDialog() {
      this.visible = true
    },
    closeDialog() {
      this.visible = false
    }
  }
};
</script>

<style scoped lang="less">
.wrap {
  width: 100%;
  height: 100%;
  background: #088a9e;
  overflow: scroll;
  border: 5px solid #089e8a;

  .main {
    width: 100%;
    height: 100%;
    background: #eee;
  }

  .dialog-wrapper {
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    right: 0;
    background: rgba(0, 0, 0, .5);
    .dialog-no-touch-area {
      z-index: 100;
      position: fixed;
      width: 100%;
      height: 100%;
    }
    .dialog-content {
      z-index: 200;
      position: fixed;
      bottom: 0;
      left: 0;
      width: 100%;
      height: 300px;
      overflow: scroll;
      background: seagreen;

      li {
        margin-top: 10px;
        width: 100%;
        height: 150px;
        background: khaki;
      }
    }
  }
}
</style>

但是你最后会发现一个 bug:在我们滑动灰色遮罩的部分的时候,我们发现触发了click事件,但是我们想要区分touchmove连携的click正常的click

究其原因是:

在移动端,手指点击一个元素,会经过:touchstart --> touchmove -> touchend --> click

解决方案关键在于区分 click 事件和 touchmove 事件。

这里提供我的方法(借鉴于某个阅读器项目的代码),网上的方法没有找到合适的。

代码如下:

<template>
  <div class="wrap">
    <div class="main">
      <button @click="showDialog">出现吧弹窗</button>
    </div>
    <div class="dialog-wrapper" v-if="visible" @click="closeDialog" @tap="closeDialog">
      <div class="dialog-no-touch-area"
        @touchstart="touchStart"
        @touchend="touchEnd"
        @touchmove.prevent.stop>
      </div>
      <div class="dialog-content" touchmove.stop @click.stop>
        <header @click="closeDialog">隐藏弹窗</header>
        <ul>
          <li>很多元素,需要滚动</li>
          <li>很多元素,需要滚动</li>
          <li>很多元素,需要滚动</li>
          <li>很多元素,需要滚动</li>
          <li>很多元素,需要滚动</li>
        </ul>
      </div>
    </div>

  </div>
</template>

<script>
export default {
  data() {
    return {
      visible: false,
      // 用于检测是否是移动事件,通过间隔时间、间隔距离进行判断
      touchStartX: 0,
      touchStartTime: 0,
    };
  },
  created() {},
  mounted() {},
  methods: {
    showDialog() {
      this.visible = true
    },
    closeDialog() {
      console.log('click or tap')
      this.visible = false
    },
    touchStart($event) {
      this.touchStartX = $event.changedTouches[0].clientX
      // this.touchStartTime = $event.timeStamp
    },
    touchEnd($event) {
      const offsetX = $event.changedTouches[0].clientX - this.touchStartX
      // const diffTime = $event.timeStamp - this.touchStartTime
      // alert('差距时间' + diffTime)
      // 判断什么情况下是touchMove,什么情况是click
      if (Math.abs(offsetX) >= 20) {
        $event.preventDefault()
        $event.stopPropagation()
      }
    }
  }
};
</script>

<style scoped lang="less">
.wrap {
  width: 100%;
  height: 100%;
  background: #088a9e;
  overflow: scroll;
  border: 5px solid #089e8a;

  .main {
    width: 100%;
    height: 100%;
    background: #eee;
  }

  .dialog-wrapper {
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    right: 0;
    background: rgba(0, 0, 0, .5);
    .dialog-no-touch-area {
      z-index: 100;
      position: fixed;
      width: 100%;
      height: 100%;
    }
    .dialog-content {
      z-index: 200;
      position: fixed;
      bottom: 0;
      left: 0;
      width: 100%;
      height: 300px;
      overflow: scroll;
      background: seagreen;

      li {
        margin-top: 10px;
        width: 100%;
        height: 150px;
        background: khaki;
      }
    }
  }
}
</style>

完美解决。

方法D:按照改变底层元素的思想解决

我本以为方法B的代码,是可以通用在两种情况的,但经过几次测试发现并不行。

需要小小的改动一下。

其中会有一个问题,感觉就是聚焦的问题,当滑动了遮罩的部分,浏览器就聚焦在遮罩层了。
这个时候需要再聚焦回来才能流畅地滑动。

那么怎么解决呢!!!请看下方鄙人表演一个四两拨千斤

.dialog-wrapper {
  touch-action: none;
}

给它的遮罩结构的类添加一个禁用浏览器所有平移、缩放手势的属性。并且查看MDN文档后,发现它不会被继承————即是说不会影响到我们需要滑动的子级。

这样就不存在上方说的什么聚焦(只是我的说法)了,它压根没法被触碰。

其他代码同方法B。仅多一句css。

完美解决。

参考文案

HTML DOM addEventListener() 方法
(MDN解释touch-action)[https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action]


compete.

posted @ 2019-11-11 19:02  海客无心x  阅读(647)  评论(0编辑  收藏  举报