問題描述
滑動穿透:浮層上的觸控會導致底層元素滑動。
問題探究:
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;
}
}
});