「React」SwipeAction 滑动操作

2,051 阅读3分钟

壹 概念须知

要完成这个组件,首先要理解几个事件及其参数

touchstart:
当手指触摸屏幕时候触发,即使已经有一个手指放在屏幕上也会触发。

touchmove:
当手指在屏幕上滑动的时候连续地触发。在这个事件发生期间,调用preventDefault()事件可以阻止滚动。

touchend:
当手指从屏幕上离开的时候触发。

我们可以在这三个事件的回调参数e中通过e.touches[0](单指)获取需要的数据

  • pageX 触摸点在页面中的横坐标
  • pageY 触摸点在页面中的纵坐标
  • clientX 触摸点在浏览器窗口中的横坐标
  • clientY 触摸点在浏览器窗口中的纵坐标
  • screenX 触摸点在屏幕中的横坐标
  • screenY 触摸点在屏幕中的纵坐标
  • force 触摸点压力大小
  • identifier 触摸点唯一标识(ID)
  • radiusX 触摸点椭圆的水平半径
  • radiusY 触摸点椭圆的垂直半径
  • rotationAngle 旋转角度

贰 要点梳理

如何准确判断用户滑动方向

按道理说,只需要比较判断touchmovetouchstart的坐标即可,但存在例外情况,比如用户向左滑动了一大段距离,又突然向右滑动一小段距离,此时touchmove的坐标依然在touchstart坐标的左边,无法准确判断滑动方向。

A :这时就需要一个变量记录上一次滑动的位移量preTouchS

preTouchS = touchmove坐标 - touchstart 坐标

这个变量的初始值设为0, 滑动时,只要比较preTouchS和当前的touchS即可判断实时的滑动方向

2.如何防止过度的滑动

  // 估算位移 = 起始left + touch偏移
  let s = touchStartLeft + touchS

  // 校正位移 防止滑动幅度过大
  if (s < 0) {
    // 左滑
    s = Math.abs(s) > maxLeftS ? -maxLeftS : s
  } else {
    // 右滑
    s = s > 5 ? 5 : s
  }

其中, touchStartLeft为每次touchstart记录下的当前偏移left值, maxLeftS是允许左滑的最大距离,在滑动删除功能中,可以设置为右边按钮组的宽度,5是右滑的缓冲值

叁 完整代码

import React, { useCallback, useEffect, useRef } from 'react'

type baseFnType = (...args: any) => void | unknown

type RightOptionsType = {
  text: string
  onPress: baseFnType
  style: {
    backgroundColor: string
    color: string
  }
}

export interface SwipeActionProps {
  index: number
  right?: RightOptionsType[]
  onOpen?: baseFnType
  onClose?: baseFnType
  disabled?: boolean
  children: React.ReactNode
  autoClose: boolean
}

const SwipeAction: React.FC<SwipeActionProps> = (props) => {
  const {
    right: btnOptions,
    index,
    onOpen,
    onClose,
    disabled = false,
    children,
    autoClose = false,
  } = props

  const getLeft = (dom: HTMLElement): number =>
    parseInt(dom.style.left || '0px', 10)

  const contentDomRef = useRef<HTMLDivElement>(null)
  const btnDomRef = useRef<HTMLDivElement>(null)
  const touchStartX = useRef<number>(0) // 保存点击时的初始点击坐标
  const touchStartLeft = useRef<number>(0) // 保存点击时的初始位置
  const directionRef = useRef<string>('') // 保存滑动方向,touchmove和touchend里都要用到

  const preTouchS = useRef<number>(0)

  /**
   * @description 滑动过程
   * @param e
   */
  const touchmove = useCallback(
    (e) => {
      if (disabled) return
      const contentDom = contentDomRef.current
      const startX = touchStartX.current // 触碰开始
      const currentX = e.touches[0].pageX //  实时位置
      const btnWidth = btnDomRef.current.offsetWidth // 按钮宽度

      const maxLeftS = btnWidth + 15 // 左滑最大距离

      const touchS = currentX - startX

      if (touchS - preTouchS.current < 0) {
        directionRef.current = 'left'
      } else {
        directionRef.current = 'right'
      }

      if (
        (directionRef.current === 'right' && getLeft(contentDom) > 5) ||
        (directionRef.current === 'left' && Math.abs(touchS) >= maxLeftS)
      ) {
        return
      }

      // 估算位移 = 起始left + touch偏移
      let s = touchStartLeft.current + touchS

      // 校正位移 防止滑动幅度过大
      if (s < 0) {
        s = Math.abs(s) > maxLeftS ? -maxLeftS : s
      } else {
        s = s > 5 ? 5 : s
      }

      // window.requestAnimationFrame(() => {
      contentDom.style.left = `${s}px`
      // })

      preTouchS.current = touchS
    },
    [disabled]
  )

  /**
   * @description 滑动开始 记录开始的位置
   * @param e
   */
  const touchstart = useCallback((e) => {
    touchStartLeft.current = getLeft(contentDomRef.current)
    touchStartX.current = e.touches[0].pageX
  }, [])

  /**
   * @description 滑动结束 还原位置
   * @param e
   */
  const touchend = useCallback(() => {
    const contentDom = contentDomRef.current
    const direction = directionRef.current // 往哪滑的
    const btnWidth = btnDomRef.current.offsetWidth // 按钮宽度 即滑动的最大距离
    if (direction === 'left') {
      contentDom.style.left = `${-btnWidth}px`
      typeof onOpen === 'function' && onOpen(index)
    } else if (direction === 'right') {
      contentDom.style.left = `${0}px`
      typeof onClose === 'function' && onClose(index)
    }
  }, [index, onClose, onOpen])

  /**
   * 还原
   */
  const handleReset = () => {
    const contentDom = contentDomRef.current
    contentDom.style.left = '0px'
  }

  /**
   *  @description 组件挂载 监听/移除事件
   */
  useEffect(() => {
    const contentDom = contentDomRef.current
    contentDom.style.transition = '0.1s all'
    contentDom.addEventListener('touchstart', touchstart)
    contentDom.addEventListener('touchmove', touchmove)
    contentDom.addEventListener('touchend', touchend)
    return () => {
      contentDom.removeEventListener('touchstart', touchstart)
      contentDom.removeEventListener('touchmove', touchmove)
      contentDom.removeEventListener('touchend', touchend)
    }
  }, [touchend, touchmove, touchstart])

  return (
    <div className="slider-wrap">
      <div ref={contentDomRef} className="slider-content">
        {children}
      </div>
      <div ref={btnDomRef} className="slider-btn">
        {btnOptions.map((item) => (
          <button
            type="button"
            key={item.text}
            onClick={() => {
              autoClose && handleReset()
              item.onPress(index)
            }}
            style={{
              background: item.style.backgroundColor,
              color: item.style.color,
            }}
          >
            {item.text}
          </button>
        ))}
      </div>
    </div>
  )
}

export default SwipeAction

scss

$swipeActionPrefixCls: 'slider-wrap';
$listItemPrefixCls: 'au-list-item';
.slider-wrap {
  position: relative;
  display: inline-block;
  overflow-x: hidden; // 左边滑动部分裁掉
}

.slider-content {
  display: inline-block;
  width: 100%;
  height: 100%;
  position: relative;
  z-index: 20;
  vertical-align: bottom; // 不设置这个的话,wrap会比content高一点,不知道是为什么x
}

.slider-btn {
  position: absolute;
  right: 0;
  // width: 100px;
  height: 100%;

  display: inline-flex;
  flex-direction: row;

  transform: scale(0.98);

  button {
    flex: 1;
    padding: 0;
    border: none;
  }
}

.#{$swipeActionPrefixCls} {
  border-top: 1px solid $gray-400;
  &:last-child {
    border-bottom: 1px solid $gray-400;
  }
  .#{$listItemPrefixCls} {
    border: none !important;
    &:active {
      opacity: 0.8;
    }
  }
}