react版模拟亚马逊人机交互菜单

2,144 阅读5分钟

「这是我参与2022首次更文挑战的第10天,活动详情查看:2022首次更文挑战

Hope is a good thing, maybe the best of things. And no good thing ever dies—— 《The Shawshank Redemption》

前言

前段时间接了一个需求,实现一个模仿亚马逊和京东的菜单交互效果,这种效果被称为模拟人机交互。在网上搜了一下,目前没有见到有 reactVue的版本,然后就自己参考了一下现有的方式,实现了一个react版本。

需求介绍

本文都是在web端的需求

参考亚马逊和京东商城的首页左侧菜单效果,实现一个react版本的组件,以供业务使用。

我们先看下亚马逊和京东商城的效果:

亚马逊商城

亚马逊.gif

京东商城

jd.gif

从上面的效果得出我们的菜单效果需求点:

  1. 当我们的鼠标悬浮在左侧菜单的时候,右侧会对应展示它对应的子菜单项,
  2. 当我们的鼠标在左侧菜单上下移动时,左侧可以快速切换为对应的子菜单
  3. 当我们的鼠标移动以一定的倾斜角度移动到右侧的时候,鼠标虽然会经过其它的左侧菜单,但是不会执行切换。

到目前为止,我们就搞情况了我们的需求。接下来就要去实现我们的方案了。

实现方案

要实现我们的需求,复杂点主要是在如何实现上述的需求3。需求1和需求2 的基本切换效果我就不再说了,直接进入需求3

实现需求3

如果要实现这个需求,我们需要记录鼠标从左往右(从左侧菜单区域移动到右侧菜单区域)的移动轨迹,然后根据它的移动轨迹去判断它是否是在一个三角形的区域之内,如果在的话,就不让它切换菜单。

我们先看一张图:

  • P1:鼠标的起始位置
  • P2:左侧菜单的固定点1,鼠标在左侧区域的最大位移点
  • P3:左侧菜单的固定点2,鼠标在左侧区域的最大位移点
  • M:鼠标在左侧菜单移动的结束位置

WechatIMG102.jpeg

从上图我们可以得出:

如果鼠标的起始点是在 P1 的话,当鼠标移动到右侧区域,鼠标可能经过的三角形区域就是 P1-P2-P3所在的三角形,M点是鼠标的结束位置。所以我们判断鼠标的运动轨迹是否在三角形中就可以了。

部分逻辑代码

const [active, setActive] = useState(null) // 选中的菜单
  const [showSub, setShowSub] = useState(false) // 是否显示子菜单
  let timeout = useRef(null) //  设置延迟定时器,防止鼠标移到tab内容经过菜单时的切换
  let mouseLocs = useRef([]) // 记录鼠标移动时的坐标数组
  let firstSlope = useRef(null) // 菜单栏的固定点1, 根据菜单栏和内容的位置而改变
  let secondSlope = useRef(null) // 菜单栏的固定点2, 根据菜单栏和内容的位置而改变
  const refNavigation = useRef(null)
  const refNav = useRef(null)
  const refSubnav = useRef(null)


  /**
   * 根据内容栏相对于菜单栏的位置,判断移动过程中的点是否在三角形内
   * @param {Object} p1 开始位置
   * @param {Object} p2 菜单栏固定点1
   * @param {Object} p3 菜单栏固定点2
   * @param {Object} m 结束位置
   * @return {*}
   */
  function proPosInTriangle(p1, p2, p3, m) {
    // 结束时鼠标坐标位置
    let x = m.x,
      y = m.y,
      // 开始鼠标坐标位置
      x1 = p1.x,
      y1 = p1.y,
      // 菜单栏包裹层右上角坐标
      x2 = p2.x,
      y2 = p2.y,
      // 右下角坐标
      x3 = p3.x,
      y3 = p3.y,
      // (y2 - y1) / (x2 - x1)为两坐标连成直线的斜率
      // 因为直线的公式为y=kx+b;当斜率相同时,只要比较
      // b1和b2的差值就可以知道该点是在
      // (x1,y1),(x2,y2)的直线的哪个方向
      // 当r1大于0,说明该点在直线右侧,其它以此类推
      r1 = y - y1 - ((y2 - y1) / (x2 - x1)) * (x - x1),
      r2 = y - y2 - ((y3 - y2) / (x3 - x2)) * (x - x2),
      r3 = y - y3 - ((y1 - y3) / (x1 - x3)) * (x - x3),
      compare

    compare = r1 * r2 * r3 < 0 && r1 > 0
    // 返回是否在三角形内的结果
    return compare
  }

  /**
   * 获取元素相对于整个网页左上角的位置
   * @param element
   * @return {{x: Number, y: Number}}
   * @constructor
   */
  function LocFromdoc(element) {
    /**
     * getBoundingClientRect, https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect
     * 则可以将当前的滚动位置(可通过 window.scrollX 和 window.scrollY 获得)添加到 y / top 和 x / left 属性上
     */
    const { x, y } = element.getBoundingClientRect()
    return { x: x + window.scrollX, y: y + window.scrollY };
  }
  /**
   * 记录元素的位置信息
   * @param element
   * @return {{top: *, topAndHeight: number, left: *, leftAndWidth: number}}
   */
  function getInfo(element) {
    const location = LocFromdoc(element)
    return {
      top: location.y,
      topAndHeight: location.y + element.offsetHeight, // offsetHeight 元素的像素高度, 高度包含该元素的垂直内边距和边框,且是一个整数
      left: location.x,
      leftAndWidth: location.x + element.offsetWidth,
    }
  }
  /**
   * 根据内容栏相对于菜单栏的位置, 返回菜单栏的固定点1,和固定点2,保存在firstSlope和secondSlope对象里
   * 即 左侧菜单栏的右上角和右下角的位置
   */
  function ensureTriangleDots() {
    // 获取菜单栏的位置
    const info = getInfo(refNav.current)
    const x1 = info.leftAndWidth
    const y1 = info.top
    const x2 = x1
    const y2 = info.topAndHeight

    firstSlope.current = {
      x: x1,
      y: y1,
    }
    secondSlope.current = {
      x: x2,
      y: y2,
    }
  }

  const onMouseOver = useCallback(
    obj => {
      let diff
      try {
        // 是否在指定三角形内
        diff = proPosInTriangle(
          mouseLocs.current[0],
          firstSlope.current,
          secondSlope.current,
          mouseLocs.current[2]
        )
      } catch (ex) {}
      // 是就启动延迟显示,
      // 否则不延迟
      if (diff) {
        timeout.current = setTimeout(() => {
          setActive(obj.key)
          setShowSub(true)
        }, 300)
      } else {
        setActive(obj.key)
        setShowSub(true)
      }
    },
    [mouseLocs, timeout]
  )

  const onMouseEnter = () => {
    // 计算位置
    if (refNav.current) {
      ensureTriangleDots()
    }
    setShowSub(true)
  }

  // 移出菜单所在区域
  const onMouseLeave = () => {
    if (refSubnav.current) {
      setActive(null)
      setShowSub(false)
    }
  }

  // 记录鼠标在菜单栏中移动的最后三个坐标位置,相对于整个网页左上角的位置
  const onMousemove = event => {
        /**
         * pageX/Y 兼容性:除IE6/7/8不支持外,其余浏览器均支持
         * clientX/Y 兼容性:所有浏览器均支持。获取的是相对于当前屏幕的坐标,忽略页面滚动因素。但是我们需要考虑页面滚动,也就是相对于文档(body元素)的坐标,加上滚动的位移就可以了
         */
    const e = event || window.event;
    const scrollX = document.documentElement.scrollLeft || document.body.scrollLeft;
    const scrollY = document.documentElement.scrollTop || document.body.scrollTop;
    const x = e.pageX || e.clientX + scrollX;
    const y = e.pageY || e.clientY + scrollY;

    mouseLocs.push({ x, y });

    if (mouseLocs.current.length > 3) {
      // 移除超过三项的数据
      mouseLocs.current.shift()
    }
  }
  // 鼠标移出的时候,清除延时器
  const onMouseout = () => {
    if (timeout.current) {
      clearTimeout(timeout.current)
    }
  }

实现效果

结果.gif

如果需要完整的代码,请评论留言!!!

结语

如果这篇文章帮到了你,欢迎点赞👍和关注⭐️。

文章如有错误之处,希望在评论区指正🙏🙏

欢迎关注我的微信公众号,一起交流技术,微信搜索 🔍 :「 五十年以后

参考: javascript 模拟亚马逊左侧导航算法的tab选项卡,支持四个方向,支持tab键切换,兼容各浏览器..