小程序中实现元素拖拽功能

265 阅读2分钟

NutUI组件库的Drag组件,使用时组件内部报错。于是自己写了个,这个在模拟器和电脑端小程序环境下拖拽正常,非常丝滑,但在真机环境下就有一个很奇怪的现象:
当拖拽的起始方向是从上往下时,拖拽很卡顿,拖拽元素不跟手,而起始方向是从下往上,则很丝滑。百思不得其解。不知道是小程序中需要特殊处理还是怎么回事,因为从代码逻辑分析,完全找不到造成这个问题的原因。代码如下:

import { useRef, PropsWithChildren, useEffect, CSSProperties } from 'react';
import { View } from '@tarojs/components';
import Taro from '@tarojs/taro';
import useScreenInfo from '@/hooks/useScreenInfo'; // 这里的代码请往后翻
import { throttle } from '@/hooks/utils'; // 这里的代码请往后翻

interface Props extends PropsWithChildren {
  children: React.ReactNode;
  childrenSelector?: string;
  dragY?: boolean;
  dragX?: boolean;
  style?: CSSProperties;
  className?: string;
};

let isMove = false;
let diffX = 0;
let diffY = 0;

const Draggable = ({ children, childrenSelector, dragY = true, dragX = false, style = {}, className = ''}: Props) => {
  const draggableRef = useRef(null);
  const elementSize = useRef({ width: 0, height: 0, left: 0, top: 0 });
  const { current: draggableId } = useRef('id_' + new Date().getTime());
  const { screenWidth, screenHeight, navBarHeight } = useScreenInfo();

  // 仅在组件初始化时获取元素尺寸
  useEffect(() => {
    Taro.createSelectorQuery()
    .select(childrenSelector ?? `#${draggableId}`)
    .boundingClientRect()
    .exec((res) => {
      if (res[0]) {
        elementSize.current = {
          width: res[0].width,
          height: res[0].height,
          left: res[0].left,
          top: res[0].top,
        };
        if (childrenSelector) {
          // @ts-ignore
          draggableRef.current.style.width = `${res[0].width}px`;
          // @ts-ignore
          draggableRef.current.style.height = `${res[0].height}px`;
        };
        // @ts-ignore
        draggableRef.current.style.opacity = 1;
      }
    });
  }, []);

  const handleTouchStart = (e) => {
    if (!dragX && !dragY) return;
    Taro.createSelectorQuery()
    .select(childrenSelector ?? `#${draggableId}`)
    .boundingClientRect()
    .exec((res) => {
      if (res[0]) {
        elementSize.current = {
          width: res[0].width,
          height: res[0].height,
          left: res[0].left,
          top: res[0].top,
        };
        isMove = true;
        diffX = e.touches[0].clientX - res[0].left;
        diffY = e.touches[0].clientY - res[0].top;
      }
    });
  };

  const handleTouchMove = (e) => {
    if (!isMove) return
    let { width, height, top, left } = elementSize.current;
    const touchX = e.touches[0].clientX;
    const touchY = e.touches[0].clientY;

    // 补偿拖拽时元素的位置,使鼠标能处于按下时相对元素的位置不变
    let x = dragX ? touchX - diffX : left;
    let y = dragY ? touchY - diffY : top;

    if (dragX) {
      // 屏幕左边缘
      if ((touchX - diffX) <= 0) x = 0;
      // 屏幕右边缘
      if ((touchX + (width - diffX)) >= screenWidth) x = screenWidth - width;
    };

    if (dragY) {
      // 屏幕底部
      if ((touchY + (height - diffY)) >= screenHeight) y = screenHeight - height;
      // 屏幕顶部
      if ((touchY - diffY) <= navBarHeight) y = navBarHeight;
    };

    updateDraggablePosition(x, y)
  };

  const updateDraggablePosition = (x, y) => {
    // @ts-ignore
    draggableRef.current.style.left = `${x}px`;
    // @ts-ignore
    draggableRef.current.style.top = `${y}px`;
    // @ts-ignore
    draggableRef.current.style.bottom = `unset`;
    // @ts-ignore
    draggableRef.current.style.right = `unset`;
  };

  return (
    <View
      id={draggableId}
      className={`dd-draggable ${className}`}
      ref={draggableRef}
      onTouchMove={throttle(handleTouchMove, 10)}
      onTouchStart={handleTouchStart}
      onTouchEnd={() => {
        isMove = false;
        diffX = 0;
        diffY = 0;
      }}
      style={style}
    >
      {children}
    </View>
  );
};

export default Draggable;

// useScreenInfo代码:

import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';

const useScreenInfo = (callback?: (data: {screenWidth: number, screenHeight: number, navBarHeight: number}) => void) => {
  const [screenInfo, setScreenInfo] = useState({
    screenWidth: 0,
    screenHeight: 0,
    navBarHeight: 0,
  });

  useEffect(() => {
    const { windowWidth, windowHeight, statusBarHeight = 0 } = Taro.getWindowInfo();
    const menuButtonRect = Taro.getMenuButtonBoundingClientRect();

    let navBarHeight =
      menuButtonRect.height + (menuButtonRect.top - statusBarHeight) * 2 + statusBarHeight;

    const platform = Taro.getDeviceInfo()?.platform;
    if (['windows', 'mac'].includes(platform)) navBarHeight = menuButtonRect.height * 1.3;

    const data = {
      screenWidth: windowWidth,
      screenHeight: windowHeight,
      navBarHeight,
    };

    setScreenInfo(data);

    if (typeof callback === 'function') callback(data);

  }, []);

  return screenInfo;
};

export default useScreenInfo;
// throttle

type ThrottleOptions = {
  leading?: boolean;   // 是否在开始时立即调用
  trailing?: boolean;  // 是否在延迟结束后调用一次
};

export function throttle<T extends (...args: any[]) => any>(
  func: T,
  wait: number,
  options: ThrottleOptions = { leading: true, trailing: true }
): (...args: Parameters<T>) => void {
  let timeout: NodeJS.Timeout | null = null;
  let lastArgs: Parameters<T> | null = null;
  let lastInvokeTime = 0;

  const { leading = true, trailing = true } = options;

  const invokeFunc = () => {
    if (lastArgs) {
      func(...lastArgs); // 这里确保 lastArgs 不为 null
      lastInvokeTime = Date.now();
      lastArgs = null;
    }
  };

  const throttled = (...args: Parameters<T>) => {
    const now = Date.now();

    if (!lastInvokeTime && !leading) {
      lastInvokeTime = now;
    }

    const remaining = wait - (now - lastInvokeTime);

    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      lastArgs = args;
      invokeFunc();
    } else if (trailing && !timeout) {
      lastArgs = args;
      timeout = setTimeout(() => {
        timeout = null;
        if (trailing) {
          invokeFunc();
        }
      }, remaining);
    }
  };

  throttled.cancel = () => {
    if (timeout) {
      clearTimeout(timeout);
      timeout = null;
    }
    lastArgs = null;
    lastInvokeTime = 0;
  };

  return throttled;
}