vue中pc移动滚动穿透问题及解决
作者:weixin_41655541
vue pc移动滚动穿透问题
上层无滚动(很简单直接@touchmove.prevent)
<div @touchmove.prevent> 我是里面的内容 </div>
上层有滚动
如果上层需要滚动的话,那么固定的时候先获取 body 的滑动距离,然后用 fixed 固定,用 top 模拟滚动距离;不固定的时候用获取 top 的值,然后让 body 滚动到之前的地方即可。
示例如下:
watch:{ statusShow(val){ if(val) { this.lockBody(); } else { this.resetBody(); } }, calendarShow(val){ if(val) { this.lockBody(); } else { this.resetBody(); } } }, methods: { lockBody() { const { body } = document; const scrollTop = document.body.scrollTop || document.documentElement.scrollTop; body.style.position = 'fixed'; body.style.width = '100%'; body.style.top = `-${scrollTop}px`; }, resetBody() { const { body } = document; const { top } = body.style; body.style.position = ''; body.style.width = ''; body.style.top = ''; document.body.scrollTop = -parseInt(top, 10); document.documentElement.scrollTop = -parseInt(top, 10); }, }
body是DOM对象里的body子节点,即 标签;
documentElement 是整个节点树的根节点root,即 标签;
不同浏览器中,有的能识别document.body.scrollTop,有的能识别document.documentElement.scrollTop,有兼容性问题需要解决。
滑动穿透终极解决方案
问题描述
滑动穿透:浮层上的触控会导致底层元素滑动。
问题探究
1、给body加overflow:hidden,pc端可以锁scroll,移动端无效
pc端可以直接overflow:hidden解决
2、给body加overflow:hidden及绝对定位,背景会定位到顶部,如果是单屏页面可以,长页面不适用
如果弹出浮层时背景本来就没有滚动距离,可以overflow:hidden加绝对定位解决
3、禁用touchmove事件,如@touchmove.prevent,对于弹层不需要的滑动的元素来说非常好用,因为scroll是touchmove触发的,直接禁用就不会滑动穿透了,其实是直接就没有系统滑动事件了。但是显然不适合弹层需要滑动的情况
如果弹层时不需要滚动的,可以直接禁用touchmove就可以了
4、专门解决滑动穿透的第三方,存在巨大的兼容性问题。比如tua-body-scroll-lock,android可以完美解决,ios整个屏幕都不能滑动了。高星的body-scroll-lock据说android全挂,就没有试了。
第三方有兼容性问题,可以自己判断ua选用
5、终极解决方案:vant的popup
合理完美的解决方案,不存在兼容问题,适用于任何情况的popup。如果你不想为了锁背景引入一个根本用不到的库,可以一起来研究下popup的实现原理。
原理探究
如果不想看源码想直接知道结论的话可以看这里:
因为常见会滑动穿透的场景都是:
- 子元素本来就不可滚动,在子元素上滑动引起背景滚动,
- 子元素可以滚动,但已经滚动到顶部或者底部,继续滑动的话就会滑动穿透
所以如果子元素本身不可滚动,或者子元素氪滚动,但已经滚动到顶部或者底部时直接对touchmove进行默认事件阻止就可以阻止滑动穿透了。因为scroll事件是通过touchmove触发的,禁止掉就不会触发系统的scroll事件了。这样就可以完美解决可滚动元素可以滚动但其背景在滑动时不为所动的效果了。
如果你想看看popup到底时如何做的可以来看看下面的源码:
源码分析:
src/popup/index.js文件中主要是参数及界面显示的处理。
// src/popup/index.js import { createNamespace, isDef } from '../utils'; import { PopupMixin } from '../mixins/popup'; import Icon from '../icon'; const [createComponent, bem] = createNamespace('popup'); export default createComponent({ // 穿透处理的代码在这里混入 mixins: [PopupMixin], props: { round: Boolean, duration: Number, closeable: Boolean, transition: String, safeAreaInsetBottom: Boolean, closeIcon: { type: String, default: 'cross' }, closeIconPosition: { type: String, default: 'top-right' }, position: { type: String, default: 'center' }, overlay: { type: Boolean, default: true }, closeOnClickOverlay: { type: Boolean, default: true } }, beforeCreate() { const createEmitter = eventName => event => this.$emit(eventName, event); this.onClick = createEmitter('click'); this.onOpened = createEmitter('opened'); this.onClosed = createEmitter('closed'); }, render() { if (!this.shouldRender) { return; } const { round, position, duration } = this; const transitionName = this.transition || (position === 'center' ? 'van-fade' : `van-popup-slide-${position}`); const style = {}; if (isDef(duration)) { style.transitionDuration = `${duration}s`; } return ( <transition name={transitionName} onAfterEnter={this.onOpened} onAfterLeave={this.onClosed} > <div vShow={this.value} style={style} class={bem({ round, [position]: position, 'safe-area-inset-bottom': this.safeAreaInsetBottom })} onClick={this.onClick} > {this.slots()} {this.closeable && ( <Icon role="button" tabindex="0" name={this.closeIcon} class={bem('close-icon', this.closeIconPosition)} onClick={this.close} /> )} </div> </transition> ); } });
根据mixins混入,可以看到核心部分应该在src/mixins/popup中,在这里针对lockscroll做出了两种处理,绑定touchmove及touchstart并绑定class:van-overflow-hidden
// src/mixins/popup/index.js import { context } from './context'; import { TouchMixin } from '../touch'; import { PortalMixin } from '../portal'; import { on, off, preventDefault } from '../../utils/dom/event'; import { openOverlay, closeOverlay, updateOverlay } from './overlay'; import { getScrollEventTarget } from '../../utils/dom/scroll'; export const PopupMixin = { mixins: [ TouchMixin, PortalMixin({ afterPortal() { if (this.overlay) { updateOverlay(); } } }) ], props: { // whether to show popup value: Boolean, // whether to show overlay overlay: Boolean, // overlay custom style overlayStyle: Object, // overlay custom class name overlayClass: String, // whether to close popup when click overlay closeOnClickOverlay: Boolean, // z-index zIndex: [Number, String], // prevent body scroll lockScroll: { type: Boolean, default: true }, // whether to lazy render lazyRender: { type: Boolean, default: true } }, data() { return { inited: this.value }; }, computed: { shouldRender() { return this.inited || !this.lazyRender; } }, watch: { value(val) { const type = val ? 'open' : 'close'; this.inited = this.inited || this.value; this[type](); this.$emit(type); }, overlay: 'renderOverlay' }, mounted() { if (this.value) { this.open(); } }, /* istanbul ignore next */ activated() { if (this.value) { this.open(); } }, beforeDestroy() { this.close(); if (this.getContainer && this.$parent && this.$parent.$el) { this.$parent.$el.appendChild(this.$el); } }, /* istanbul ignore next */ deactivated() { this.close(); }, methods: { open() { /* istanbul ignore next */ if (this.$isServer || this.opened) { return; } // cover default zIndex if (this.zIndex !== undefined) { context.zIndex = this.zIndex; } this.opened = true; this.renderOverlay(); // 穿透处理的核心部分 if (this.lockScroll) { // 给touchstart及touchmove上绑定代码 // 关于touchStart及ontouchmove的代码在TouchMixin的引入中 on(document, 'touchstart', this.touchStart); on(document, 'touchmove', this.onTouchMove); if (!context.lockCount) { document.body.classList.add('van-overflow-hidden'); } context.lockCount++; } }, close() { if (!this.opened) { return; } if (this.lockScroll) { context.lockCount--; off(document, 'touchstart', this.touchStart); off(document, 'touchmove', this.onTouchMove); if (!context.lockCount) { document.body.classList.remove('van-overflow-hidden'); } } this.opened = false; closeOverlay(this); this.$emit('input', false); }, onTouchMove(event) { // 这个方法是touch文件中引入得,一会会看到 // 主要计算滑动得方向及距离 this.touchMove(event); // 方向计算 const direction = this.deltaY > 0 ? '10' : '01'; // 获取滚动目标对象 const el = getScrollEventTarget(event.target, this.$el); // 滚动元素相关属性赋值 const { scrollHeight, offsetHeight, scrollTop } = el; let status = '11'; /* istanbul ignore next */ if (scrollTop === 0) { // 没有滚动的情况下,判定是否有滚动条 status = offsetHeight >= scrollHeight ? '00' : '01'; } else if (scrollTop + offsetHeight >= scrollHeight) { // 有滚动距离且滚动到底部 status = '10'; } /* istanbul ignore next */ if ( status !== '11' && this.direction === 'vertical' && !(parseInt(status, 2) & parseInt(direction, 2)) ) { // 有滚动条且有滚动距离且方向为垂直时,阻止默认事件,即阻止页面滚动 // 所以原理其实是在可能会引起背景滑动穿透时禁止掉scroll事件 // 因为常见会滑动穿透的场景都是子元素不滚动引起背景滚动,或者子元素已经滚动到顶部或者底部,继续滑动的话就会滑动穿透,如果发现已经滚动到顶部或者底部时直接禁止掉touchmove就可以阻止滑动穿透了 preventDefault(event, true); } }, renderOverlay() { if (this.$isServer || !this.value) { return; } this.$nextTick(() => { this.updateZIndex(this.overlay ? 1 : 0); if (this.overlay) { openOverlay(this, { zIndex: context.zIndex++, duration: this.duration, className: this.overlayClass, customStyle: this.overlayStyle }); } else { closeOverlay(this); } }); }, updateZIndex(value = 0) { this.$el.style.zIndex = ++context.zIndex + value; } } };
来看看touch的处理,可以看到给touchstart及touchmove绑定了滑动方向及距离得计算,touchmove这个方法会在ontouchmove中被调用,注意名称,不要混淆。
import Vue from 'vue'; const MIN_DISTANCE = 10; function getDirection(x: number, y: number) { if (x > y && x > MIN_DISTANCE) { return 'horizontal'; } if (y > x && y > MIN_DISTANCE) { return 'vertical'; } return ''; } type TouchMixinData = { startX: number; startY: number; deltaX: number; deltaY: number; offsetX: number; offsetY: number; direction: string; }; export const TouchMixin = Vue.extend({ data() { return { direction: '' } as TouchMixinData; }, methods: { // touchstart获取起始位置 touchStart(event: TouchEvent) { this.resetTouchStatus(); this.startX = event.touches[0].clientX; this.startY = event.touches[0].clientY; }, // touchmove算得移动后得位移差,用来计算方向和偏移量 touchMove(event: TouchEvent) { const touch = event.touches[0]; this.deltaX = touch.clientX - this.startX; this.deltaY = touch.clientY - this.startY; this.offsetX = Math.abs(this.deltaX); this.offsetY = Math.abs(this.deltaY); this.direction = this.direction || getDirection(this.offsetX, this.offsetY); }, resetTouchStatus() { this.direction = ''; this.deltaX = 0; this.deltaY = 0; this.offsetX = 0; this.offsetY = 0; } } });
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。