一个Hooks解决移动端滚动穿透

454 阅读7分钟

前言


在开发移动端需求的过程中,我们常常会遇到一种现象,当蒙层滚动或者子元素滚动的时候,会意外触发父元素的滚动,我们把这种现象称之为滚动穿透。显然,在大部分情况下,我们都不想要滚动穿透现象的发生,所以我们该如何解决这个问题呢?

Overflow Hidden?


我们很容易想到,当我们展示子元素的时候,可以手动的将父元素添加一个overflow:hidden样式,在子元素消失的时候,再把这个样式移除。

// 蒙层展示的时候
const onShow = () => {
    document.body.style.overflow = "hidden";
    document.body.style.position = "fixed"; 
};

// 蒙层关闭的时候
const onClose = () => {
    document.body.style.overflow = "auto";
    document.body.style.position = "static";
};

这种解决方式显然是最简单直接的,但是同时也存在着一些局限性的问题,倘若控制子元素显示的按钮不在body的首屏的话,document.body.style.position = "fixed",会强制让body滚动到页面顶部,这对用户体验来说是一种伤害。另外,如果子元素是非全屏元素,同时用户需要保持子元素的滚动性和父元素的滚动性时,这种方式就不太使用了。

阻止默认行为


让我们回到问题本身,当蒙层滚动或者子元素滚动的时候,会意外触发父元素的滚动,我们把这种现象称之为滚动穿透。那究竟在什么情况下,子元素的滚动才会触发滚动穿透呢?

情况分为两种:

  • 子元素不可滚动时,滑动子元素会触发父元素的滚动穿透
  • 子元素可以滚动时,滑动子元素到顶部或者底部,继续同方向滑动,则会触发父元素的滚动穿透

那么,根据上述情况,我们总结出一个通用方案:

  • 当元素触发TouchMove事件时,我们根据触发事件的event.target,找出距离其最近的可滚动的祖先元素,如果没有找到可以滚动的元素,则可以直接阻止滚动的默认行为。
  • 如果寻找到了可滚动的祖先元素,则进一步判断该元素是否滚动到了顶部或者底部,如果是,则阻止滚动的默认行为。

实际上,这也是Vant和AntDesign在移动端组件上使用的通用方案,后续我会将他们的源码进行部分的精简,来一步一步的实现useLocks,也就是解决移动端滚动穿透的代码。

前置知识


offsetHeight

HTMLElement.offsetHeight 是一个只读属性,它返回该元素的像素高度,高度包含该元素的垂直内边距和边框,且是一个整数。通常,元素的 offsetHeight 是一种元素 CSS 高度的衡量标准,包括元素的边框、内边距和元素的水平滚动条。

clientHeight

只读属性 Element.clientHeight 对于没有定义 CSS 或者内联布局盒子的元素为 0;否则,它是元素内部的高度(以像素为单位),包含内边距,但不包括边框、外边距和水平滚动条(如果存在)。

scrollHeight

Element.scrollHeight 这个只读属性是一个元素内容高度的度量,包括由于溢出导致的视图中不可见内容。scrollHeight 的值等于该元素在不使用滚动条的情况下为了适应视口中所用内容所需的最小高度。没有垂直滚动条的情况下,scrollHeight 值与元素视图填充所有内容所需要的最小值clientHeight相同。包括元素的 padding,但不包括元素的 border 和 margin。

scrollTop

Element.scrollTop 属性可以获取或设置一个元素的内容垂直滚动的像素数。 一个元素的 scrollTop 值是这个元素的内容顶部(卷起来的)到它的视口可见内容(的顶部)的距离的度量。当一个元素的内容没有产生垂直方向的滚动条,那么它的 scrollTop 值为0

从上述定义中,我们可以得知

  • 如果元素的offsetHeight >= scrollHeight,那么此元素是不可滚动的,反正,如果scrollHeight > clientHeight的话,我们认为这个元素是存在可以滚动的条件的(具体能否滚动,还得根据overflow的属性来进一步判断)。
  • 如果元素是可滚动的,且clientHeight + scrollTop = scrollHeight时,我们可以认为该元素滚动到了底部。

具体实现


寻找最近的可滚动的祖先元素

当TouchMove事件发生时,我们需要根据触发事件的event.target,找出距离其最近的可滚动的祖先元素,这里我们可以使用循环的方法,逐级向上查找元素的父元素。另外,判断元素是否可以滚动,我们可以使用如果scrollHeight、clientHeight,以及检查overflow属性来得知。

const overflowPattern = ['scroll', 'auto', 'overlay']

const canElementScroll = (node: Element): boolean => {
  return node.scrollHeight > node.clientHeight 
  && overflowPattern.includes(window.getComputedStyle(node).overflowY)
}
/**
 * 
 * @param el 触发TouchMove事件的元素
 * @param root 向上寻找元素的范围
 * @returns 
 */
const getScrollParent = (el: Element, root: HTMLElement | Window = window): Window | Element | null | undefined => {
  let node = el;
  while(node && node !== root) {
    
    if(node === document.body) {
      return root;
    }

    if(canElementScroll(node)) {
      return node
    }

    node = node.parentNode as Element
  }
  return root
}

判断元素的滚动行为

当用户滑动元素的时候,我们需要去知道用户当前滑动的方向,以及幅度,以便我们后续利用这些信息去做进一步的扩展。这里我们定义一个useTouch的hook,去监听指定元素的touchstart和touchmove事件,以及相对应的滑动信息。

import { useRef } from 'react';

export const minDistance = 10;
export type Direction = '' | 'vertical' | 'horizontal'

export const getDirection = (x: number, y: number) => {
  if(x - y > 0 && x >= minDistance) {
    return 'horizontal'
  }
  if(y - x > 0 && y >= minDistance) {
    return 'vertical'
  }
}

const 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 reset = () => {
    deltaX.current = 0;
    deltaY.current = 0;
    offsetX.current = 0
    offsetY.current = 0
    direction.current = ''
  }
  
  const isVertical = () => direction.current === 'vertical'

  const isHorizontal = () => direction.current === 'horizontal'

  const start = (event: TouchEvent) => {
    reset()
    startX.current = event.touches[0].clientX;
    startY.current = event.touches[0].clientY;
  }

  const move = (event: TouchEvent) => {
    const touch = event.touches[0]
    // 获取移动的X Y轴的距离
    deltaX.current = touch.clientX - startX.current
    deltaY.current = touch.clientY - startY.current

    // 获取移动的绝对值
    offsetX.current = Math.abs(deltaX.current)
    offsetY.current = Math.abs(deltaY.current)
    direction.current = getDirection(offsetX.current, offsetY.current)
  }

  return {
    reset,
    start,
    move,
    startX,
    startY,
    deltaX,
    deltaY,
    offsetX,
    offsetY,
    direction,
    isVertical,
    isHorizontal
  }
}

export default useTouch;

实现useLocks

Vant在实现这段代码的时候,巧妙的将各个滚动的状态记为2进制的数字,这样在后续判断的时候就可以通过简单的二进制运算符 & 来做快速的判断。 例如,在判断滑动的方向时,当手指向下滑时,也就是滚动条往上滚,记为10,否则记为01。

    const direction =  touch.deltaY.current > 0 ? '10' : '01';

对于父元素的滚动状态,记录一个初始状态为11,然后根据进一步根据不同的状态,分别赋值为00,01或者10。 到了最后判断的时候,就可以:

  • status是11时,代表元素在中间滚动,此时不会触发穿透事件,也就不需要阻止默认事件
  • 如果不是垂直滚动时,也不需要阻止默认事件
  • 剩下的情况就会比较复杂一些,如果 direction是10,也就是手指往下滑,滚动条往上滚,这时候如果父元素的滚动条是在顶部的话,则会发生滚动穿透事件。也就是 10 和 01 ,这两个做位运算的话,就是parseInt('10', 2) & parseInt('01', 2), 最终是 2 & 1, 0010 & 0001, 结果是0000。
  • 如果是 direction是01, 也就是手指往上滑,滚动条往下滚,这时候如果父元素的滚动条是在底部的话,则会发生滚动穿透事件。也就是 01 和 10, 这两个做位运算的话,就是parseInt('01', 2) & parseInt('10', 2),最终是 1 & 2, 结果也是0000。
    if(status !== '11' && touch.isVertical() && !(parseInt(status, 2) & parseInt(direction, 2))) {
      if(event.cancelable) {
        event.preventDefault()
      }
    }

完整代码

const useLock = (rootRef: RefObject<HTMLElement>, shouldLock?: boolean) => {
  const touch = useTouch()

  const onTouchMove = (event: TouchEvent) => {
    
    touch.move(event);
    /**
     * deltaY > 0 时,页面往下滑,滚动条往上滚,记为10
     * 否则页面是往上滑,滚动条是往下滚,记为01
     */
    const direction =  touch.deltaY.current > 0 ? '10' : '01';

    const el = getScrollParent(event.target as Element, rootRef.current) as HTMLElement


    const { scrollTop, clientHeight, scrollHeight, offsetHeight } = el


    let status = '11'

    /**
     * 当滚动条在顶部的时候,判断当前是未滚动还是属于不能滚动
     * 当滚动条在底部的时候,将状态赋值为10
     * 当都没有命中的时候,代表滚动条在中间, 当滚动条在中间的时候,我们并不需要做任何事情,因为此时不会发生滚动穿透事件
     */
    if(scrollTop === 0 ) {

      status = offsetHeight >= scrollHeight ? '00' : '01';

    } else if (scrollHeight - clientHeight === scrollTop){

      status = '10'
    }

    if(status !== '11' && touch.isVertical() && !(parseInt(status, 2) & parseInt(direction, 2))) {
      if(event.cancelable) {
        event.preventDefault()
      }
    }


  }


  const lock = () => {
    document.addEventListener('touchstart', touch.start,)
    document.addEventListener('touchmove', onTouchMove)
  }

  const unlock = () => {
    document.removeEventListener('touchstart', touch.start)
    document.removeEventListener('touchmove', onTouchMove)
  }

  useEffect(() => {
    lock()
    return () => {
      unlock()
    }
  }, [])
  
}