likes
comments
collection
share

【NavMenu 导航菜单】:难搞的展开/收缩动画

作者站长头像
站长
· 阅读数 19

前言

本篇文章讲述如何来实现一个导航菜单组件,导航菜单特别常见于后台管理系统这种较多页面层级的网页,往往通过点击某一项来控制页面跳转。它长这样【NavMenu 导航菜单】:难搞的展开/收缩动画

需求分析

导航菜单的功能不算复杂,总计出来就以下几点:

  • 可以通过点击菜单的某一项实现特定的操作,例如路由跳转、页面跳转、事件监听等
  • 可以嵌套子级菜单
  • 具有一定的交互过渡效果(这个很重要,不然就太丑了)
  • 外部可以获取到当前选中菜单项以及层级数据,用于更高级的需求场景

好了,具体需求我们先列出这么多,下面,就来看看如何实现一个导航菜单。

构思

导航菜单的基本结构

从交互上来看,导航菜单在展开后会露出包裹的子菜单,关闭时又会隐藏这些子菜单,所以应该是最外层有一个div容器,然后有一个ul列表包裹着所有子菜单项li,要实现显示或隐藏,通过控制这个ul的容器高度即可。

如何嵌套子菜单

有时候菜单的层级往往不止两层,而是有很多层,要如何实现嵌套呢?仔细想想,单独一层菜单的结构是 “容器 + 列表”;嵌套,则是把某个菜单项变成一个具有完整功能的菜单即可,所以,首先想到的就是** Tree 树结构**,可以通过递归来渲染出一个包含嵌套层级的导航菜单。

不过,这部分的实现没有那么简单,等我把 NavMenu 写出来之后,我发现它是花了我最多时间的一个部分。

如何实现伸缩动画效果

如何实现收缩/展开动画?很容易想到给ul菜单项容器简单的设置transition过渡效果,然后动态地改变ulheight值,它就会展示出过渡动画了

不过,动手实现的时候,我才发现这块好像也没那么简单,后面会详细解释。

单个展开模式

意思就是每次点击展开当前这个菜单时,其余的菜单自动收起,在 Element UI 中称为“手风琴”模式,不过我不会玩儿手风琴,所以完全没理解到这个名字内涵,所以我还是叫它“单个开关”模式吧。

要实现这个功能,就需要在点击展开某个菜单时,首先获取到上一个展开的菜单,关闭它,之后才展开当前点击的菜单。

开发

好了,前面分析了一大堆,下面应该上代码了。

首先,抽象一下组件层级以及它们的内部状态,最外部的容器组件是NavMenu,子菜单项是NavMenuItem,然后当需要嵌套子菜单时,还需要一个组件SubNavMenu,导航菜单的使用方式如下:

<NavMenu>
  {/* 单个菜单项 */}
  <NavMenuItem label="项目1"></NavMenuItem>

  {/* 嵌套菜单项 */}
  <SubNavMenu label="项目2">
    <NavMenuItem label="项目2-1"></NavMenuItem>

    {/* 继续嵌套 */}
    <SubNavMenu label="项目2-2">
      <NavMenuItem label="项目2-2-1"></NavMenuItem>
    </SubNavMenu>
  </SubNavMenu>
</NavMenu>

画了个图,方便理解【NavMenu 导航菜单】:难搞的展开/收缩动画

NavMenuItem 菜单项

菜单项需要展示一个标题以及响应点击事件,还需要注意的是:有时候菜单项可以是一个标题(静态文本),或者是一个可以点击的链接,还可以是 React Router 的Link组件,用来进行路由跳转;所以菜单项需要根据不同的输入,渲染出不同的元素。

下面是props定义:

interface propsNavMenuItem{
  to?: string; // 超链接
  icon?: any; // 标题前面的图标
  disabled?: boolean; // 是否禁用
  label?: string; // 菜单项标题
  children?: any; // 自定义的内容,可以是 <Link>
  className?: string
}

组件的基本结构如下,返回一个li元素:

function NavMenuItem(props: propsNavMenuItem) {
  const {
    to,
    icon,
    disabled = false,
    children,
    className = '',
    label,
  } = props;

  // 根据接收到的 props 渲染不同的元素
  const renderNavItem = () => {
    if (to) {
      // 超链接
      return (
        <a
          className={'wdu-navMenuItem__label'}
          href={to}
          target='_blank'
          referrerPolicy='no-referrer'>
          {label}
        </a>
      );
    } else if (!to && children) {
      // <Link/> 
      return children;
    } else if (!to && !children && label) {
      // 纯文本标题
      return (
        <span className={'wdu-navMenuItem__label'}>
          {label.toString()}
        </span>
      );
    }
  };

  return (
    <li className={navItemClassList}>
      {icon && <span className={'wdu-navMenuItem__icon'}>{icon}</span>}

      {renderNavItem()}
    </li>
  );
}

NavMenu 菜单

作为顶层容器组件,它接受的props如下:

interface propsNavMenu {
    single?: boolean; // 是否每次展开时只展开一个子菜单(手风琴模式)
    children?: Array<JSX.Element>; // 子元素,只接受 NavMenuItem 和 SubNavMenu
    className?: string; // 自定义类名,方便用户自定义样式
}

接下来是组件的基本结构,它是一个ul元素,包含上面渲染的li元素

function NavMenu(props: propsNavMenu) {
    const { children, className = '', single = false } = props;

    return (
        <ul className={`wdu-navMenu ${className}`}>
          {/* NavMenu 子元素 */}
        </ul>
    );
}

SubNavMenu 子菜单

这个组件的作用是复用NavMenu的功能,包裹嵌套的子菜单,但是又跟NavMenu有一些区别,下面是它的结构定义:

// 跟 NavMenuItem 的 props 接口很相似
interface propsSubNavMenu {
  label: string; // 嵌套子菜单的标题
  icon?: any; // 标题的 icon
  disabled?: boolean;
  onClick?: (event: MouseEvent, navMenuItemData: any) => any;
  expand?: boolean; // 嵌套菜单是否展开
  children?: any; // 嵌套菜单的子元素,NavMenuItem 或 SubNavMenu
}

function SubNavMenu(props: propsSubNavMenu) {
  const {
    children,
    expand = false,
    label,
  } = props;

  // 是否展开
  const [menuExpand, setExpand] = useState(() => expand);

  // 容器的高度
  const [expandHeight, setExpandHeight] = useState<{
    height: string | number;
  }>({ height: 0 });

  // 开关子菜单容器,主要方法是控制容器高度
  useEffect(() => {
    let height: string | number;
    if (menuExpand) {
      height = 'auto'
    } else {
      height = 0;
    }
    setExpandHeight((prev) => ({ ...prev, height }));
  }, [menuExpand]);

  return (
    <ul className='wdu-subNavMenu'>
      {/* 作为标题 */}
      <NavMenuItem
        className='wdu-subNavMenu__title'
        expand={menuExpand}
        onClick={handleClick}
        label={label}
        subMenuItem></NavMenuItem>

      <div
        className='wdu-subNavMenu__items'
        style={expandHeight}
        >
        {childNodes}
      </div>
    </ul>
  );
}

上面是经过了精简后的SubNavMenu结构,后面会有更详细的介绍。

实现子菜单嵌套

这一部分将会是本文最不好理解的地方,文字比较多,我尽量清晰且有条理的去阐述这一部分的实现思路。

递归组件

首先来看一下组件渲染到页面以后的 DOM 结构【NavMenu 导航菜单】:难搞的展开/收缩动画

仔细观察,不难发现一个特点:每个导航菜单的构成都是ul之内嵌套着li以及ulli就是菜单项的标题,嵌套的ul就是一个完整的菜单结构,内部又是li和嵌套的ul,直到最底层。所以,这里使用了递归组件。

还有一个需要注意的地方:需要将SubNavMenu 容器设置为overflow: hidden,才能让菜单闭合后,嵌套的子菜单元素不可见。

下面有一段 NavMenu 的模板代码:

<NavMenu>
  <NavMenuItem label='菜单 1' />

  <SubNavMenu label='菜单 2'>
    <NavMenuItem label='菜单 2-1' />

    <SubNavMenu label='菜单 2-2'>
      <NavMenuItem label='菜单 2-2-1' />
    </SubNavMenu>
  </SubNavMenu>
</NavMenu>

这是一个三层嵌套的导航菜单,其中SubNavMenu组件中仍然可以继续使用SubNavMenu,这就是将组件递归地渲染。

下面是 SubNavMenu 组件的 JSX 模板部分代码,通过 React.Children 获取到子组件(NavMenuItem 或者 SubNavMenu),然后渲染到 SubNavMenu 组件中:


<ul className='wdu-subNavMenu'>
  <NavMenuItem
    className='wdu-subNavMenu__title'
    expand={menuExpand}
    onClick={handleClick}
    label={label}
    ></NavMenuItem>

  <div
    className='wdu-subNavMenu__items'
    style={expandHeight}
    ref={refSubNavContainer}>
    {childNodes}
  </div>
</ul>

SubNavMenu的结构是一个ul元素中包含了一个作为子菜单项标题的NavMenuItem组件(li元素),然后下面是用于包裹子菜单项的div,其子元素节点就是之前代码中演示过的NavMenuItem或者SubNavMenu

处理缩进

【NavMenu 导航菜单】:难搞的展开/收缩动画上图中可以明显看到,每个嵌套的子菜单左侧都会有一定距离的缩进,就像写作文时每个自然段的缩进。那么,到底如何实现呢?先来看看第一种方法:给 SubNavMenu 容器设置margin-left

<ul className='wdu-subNavMenu' style={{marginLeft: '40px'}}>
  <NavMenuItem
    className='wdu-subNavMenu__title'
    expand={menuExpand}
    onClick={handleClick}
    label={label}
    ></NavMenuItem>

  <div
    className='wdu-subNavMenu__items'
    style={expandHeight}
    ref={refSubNavContainer}>
    {childNodes}
  </div>
</ul>

渲染结果如下【NavMenu 导航菜单】:难搞的展开/收缩动画

可以看到,每个菜单项其实有鼠标悬浮效果的,简单的设置margin-left虽然可以实现缩进,但是也会使得NavMenuItem的宽度变小,这会让悬浮效果只能体现出一部分,而不是整个 100% 的父容器宽度,视觉效果上也就不满足要求。

鼠标悬浮效果是通过设置:hover伪类改变NavMenuItem的背景色来实现的,但是background-color的作用范围是不包括元素的margin的,而是padding + content。所以不能使用margin,而是要通过padding来实现缩进,这个知识点其实大部人都知道,但是我因为 css 功底没那么好,所以算是踩了个坑,这里单独拿出来说一说。

为了实现缩进,需要当前这个SubNavMenu 组件继承上一级的缩进距离,然后以此类推下去即可

// 缩进距离,常量
const NEST_ITEM_INDENT = 20;

// 通过自定义 SubNavMenu 子元素的 props,把缩进距离 indent 一级一级传下去
const childNodes = bindImplicitProps(React.Children.toArray(children), {
  indent: indent + NEST_ITEM_INDENT,
});

bindImplicitProps函数一个工具方法,用于将给定的props遍历绑定到每个子节点上

SubNavMenu 中,通过 indent 传递缩进:

<ul className='wdu-subNavMenu'>
  <NavMenuItem
    indent={indent}
    ></NavMenuItem>

  <div
    className='wdu-subNavMenu__items'
    style={expandHeight}
    >
    {childNodes}
  </div>
</ul>

NavMenuItem 中,接受 indent 然后设置到标题元素的style

<li
  className={navItemClassList}
  style={{ paddingLeft: `${indent}px` }}
  >
  {icon && <span className={'wdu-navMenuItem__icon'}>{icon}</span>}

  {renderNavItem()}

  {subMenuItem}
</li>

可以看到,此时的嵌套子菜单组件完全被 NavMenuItem 组件包裹了起来,只要给容器li元素应用上一级菜单传递下来的缩进indent就能实现整个SubNavMenu容器的缩进,也就是style={{ paddingLeft: ${indent}px }}的作用所在。

稍微总结一下:到目前为止,通过不断SubNavMenu中嵌套NavMenuItem以及SubNavMenu,实现了子菜单的嵌套;接着,又通过给每个SubNavMenu的容器设置padding-left实现了不影响鼠标悬浮效果的缩进,效果如下:【NavMenu 导航菜单】:难搞的展开/收缩动画

实现单层导航菜单的伸缩动画效果

假设有一段模版代码:

<NavMenu>
  <SubNavMenu label="菜单 1">
    <NavMenuItem label='菜单 1' />
    <NavMenuItem label='菜单 2' />
    <NavMenuItem label='菜单 3' />
  </SubNavMenu>>
</NavMenu>

渲染结果如下:

【NavMenu 导航菜单】:难搞的展开/收缩动画上图中,菜单的展开和闭合都有比较平滑的过渡动画,那么这是如何实现的呢?

首先,最容易想到的就是改变ul元素的height,菜单展开时,设置样式height: auto(设置为 auto 是因为此时并不能知道容器展开后的确切高度),收起时设置样式height: 0

// SubNavMenu 组件
function SubNavMenu(props: propsSubNavMenu) {
  // ......

  // 容器的高度
  const [expandHeight, setExpandHeight] = useState<{
    height: string | number;
  }>({ height: 0 });

  // 开关子菜单容器,主要方法是控制容器高度
  useEffect(() => {
    let height: string | number;
    if (menuExpand) {
      height = 'auto'
    } else {
      height = 0;
    }
    setExpandHeight((prev) => ({ ...prev, height }));
  }, [menuExpand]);

  return (
    <ul className='wdu-subNavMenu'>
      <NavMenuItem
        className='wdu-subNavMenu__title'
        expand={menuExpand}
        onClick={handleClick}
        label={label}
        subMenuItem></NavMenuItem>

      <div
        className='wdu-subNavMenu__items'
        style={expandHeight}
        >
        {childNodes}
      </div>
    </ul>
  );
}

渲染结果如下:【NavMenu 导航菜单】:难搞的展开/收缩动画仔细观察就能发现,展开和闭合时是没有过渡动画效果的,原因是transition动画只支持在明确的数值之间渲染过渡动画,很明显auto不是一个数值,无法参与计算。所以,就需要手动获取到SubNavMenu容器的高度,然后将其作为height的值,这样过渡动画就能生效了。

function SubNavMenu(props: propsSubNavMenu) {
  // .......

  // 每个 NavMenuItem 的固定高度
  const ITEM_HEIGHT = 50;

  // 初始情况,SubNavMenu 还未展开,所以无法获取到容器高度,但是可以通过子节点个数来计算展开后的容器高度
  const initialHeight = childNodes.length * ITEM_HEIGHT + 'px';

  useEffect(() => {
    let height: string | number;
    if (menuExpand) {
      height = initialHeight;
    } else {
      height = 0;
    }
    setExpandHeight((prev) => ({ ...prev, height }));
  }, [menuExpand]);

  return (
    <ul className='wdu-subNavMenu'>
      <NavMenuItem
        className='wdu-subNavMenu__title'
        expand={menuExpand}
        indent={indent}
        onClick={handleClick}
        label={label}
        subMenuItem></NavMenuItem>

      <div
        className='wdu-subNavMenu__items'
        style={expandHeight}
        ref={refSubNavContainer}
        >
        {childNodes}
      </div>
    </ul>
  );
}

渲染结果如下:【NavMenu 导航菜单】:难搞的展开/收缩动画

实现嵌套导航菜单的伸缩动画效果

再次回忆一下嵌套的菜单结构【NavMenu 导航菜单】:难搞的展开/收缩动画每次菜单的展开与闭合都是控制绿色选框div的高度,在之前的代码中已经实现了单个SubNavMenu的展开和闭合动画效果。

下面来看另一段模版代码,它多了一层嵌套:

<NavMenu>
  <SubNavMenu label="菜单 1">
    <NavMenuItem label='项目 1' />
    <NavMenuItem label='项目 2' />
    <NavMenuItem label='项目 3' />
    <SubNavMenu label="菜单 4">
      <NavMenuItem label='项目 4-1' />
      <NavMenuItem label='项目 4-2' />
      <NavMenuItem label='项目 4-3' />
    </SubNavMenu>
  </SubNavMenu>
</NavMenu>

渲染结果如下:【NavMenu 导航菜单】:难搞的展开/收缩动画可以看到,第一层的可以正常展开与闭合,但是嵌套的第二层菜单的展开却失效了,这是为什么?因为之前的代码中只是计算了当前这一层级的容器的展开高度,并没有考虑到容器之中还可以嵌套另一个容器,所以高度值不够,导致第二层菜单在视觉上好像高度一直为 0,但实际上它的height值是生效了的,但因为 SubNavMenu 容器的overflow: hidden所以隐藏的部分就不可见了。

解决这个问题的核心逻辑如下:在当前菜单展开后,先缓存容器的高度,然后再将容器的高度从数值设置为auto

function SubNavMenu(props: propsSubNavMenu) {
  // .......

  // 缓存上次菜单展开后的高度
  const [lastHeight, setLastHeight] = useState<number>();

  // 在菜单展开后,给容器设置样式 height: auto
  const keepChildItemExpandable = (e: TransitionEvent) => {
    if (e.propertyName !== 'height' && e.propertyName !== 'width') return;

    let height: string | number;
    if (menuExpand) {
      height = 'auto';
    } else {
      height = 0;
    }
    setExpandHeight((prev) => ({ ...prev, height }));
  };

  // 计算 tansition 的精确数值
  const setComputedHeightToContainer = () => {
    if (refSubNavContainer.current) {
      const { clientHeight } = refSubNavContainer.current;
      refSubNavContainer.current.style.height = clientHeight + 'px';
      return clientHeight;
    }
  };

  const handleClick = () => {
    // 菜单展开后,缓存当前容器高度
    if (menuExpand && refSubNavContainer.current) {
      const clientHeight = setComputedHeightToContainer();
      setLastHeight(clientHeight);
    }

    // 延迟 50 ms 是为了等待 DOM 重排和重绘结束
    setTimeout(() => setExpand(!menuExpand), 50);
  };

  return (
    <ul className='wdu-subNavMenu'>
      <NavMenuItem
        className='wdu-subNavMenu__title'
        expand={menuExpand}
        indent={indent}
        onClick={handleClick}
        label={label}
        ></NavMenuItem>

      <div
        className='wdu-subNavMenu__items'
        style={expandHeight}
        ref={refSubNavContainer}
        onTransitionEnd={keepChildItemExpandable}>
        {childNodes}
      </div>
    </ul>
  );
}

当父容器的高度为auto时,被嵌套的子菜单展开时就可以自然而然的撑开父容器的高度,从而将自己显示出来【NavMenu 导航菜单】:难搞的展开/收缩动画

实现单个开关模式

“单个开关”模式就是当前层级中,同一时间只能有一个菜单被展开,当前点击某个菜单展开后,其余的菜单要能自动闭合起来。

本文的实现思路很简单:在当前菜单展开后,关闭上一个展开的菜单。简要的步骤如下:

  1. 每一个SubNavMenu内部都生成一个唯一的组件 ID
  2. 父级菜单中维护一个变量,表示当前展开的菜单的 ID
  3. 当前SubNavMenu展开之前,先查询父级菜单中保存的 ID ,然后决定闭合还是展开

首先,SubNavMenu需要为自己生成一个 ID

// react 18 新增的 hooks
const menuId = useId();

useEffect(() => {
  if (menuExpand) {
    // 提交自己的 id 到父级
    submitExpandId(menuId);
  }
}, [menuExpand]);

单个开关模式的生效范围只限于同一层级,所以需要在每个SubNavMenu内部都创建一个变量来记录当前展开的子菜单项。不过,再仔细想一想,整个导航菜单组件每次只能展开一个子菜单,不论它所处的层级如何,所以其实没有必要在每个SubNavMenu内部都创建 ID 变量,而是把这个变量提升到最顶层的NavMenu中,这样无论嵌套了多少层子菜单,也不会有冗余的代码逻辑

const NavContext = React.createContext<NavMenuContext>({});
const NavProvider = NavContext.Provider;

function NavMenu(props: propsNavMenu) {
  const { children, className = '', single = false } = props;

  // 上次展开的子菜单 id
  const [lastExpandItemId, setLastExpandItemId] = useState<string>();

  const childNodes = bindImplicitProps(React.Children.toArray(children), {
    lastExpandItem: lastExpandItemId,
    submitExpandId: setLastExpandItemId,
  });

  const contextValue = {
    single,
    selectedItem,
  };

  return (
    <ul className={`wdu-navMenu ${className}`}>
      <NavProvider value={contextValue}>{childNodes}</NavProvider>
    </ul>
  );
}

在 react 中,说到跨层级组件通信,最先想到的一定是 context;这里用React.createContext来保存当前展开的子菜单 ID ,这样所有的SubNavMenu都可以访问到lastExpandItemId,而且还可以很方便的提交自己的 ID


function SubNavMenu(props: propsSubNavMenu) {
  const {
    children,
    expand = false,
    label,
    indent = NEST_ITEM_INDENT,
    lastExpandItem,
    submitExpandId,
  } = props;

  useEffect(() => {
    if (menuExpand) {
      // 展开时,提交自己的 id 到顶层 context 中
      submitExpandId(menuId);
    }
  }, [menuExpand]);

  useEffect(() => {
    if (!single) return;

    // 根据 lastExpandItem 判断自身是否需要闭合
    if (lastExpandItem !== undefined && lastExpandItem !== menuId) {
      setComputedHeightToContainer();
      setExpand(false);
    }
  }, [lastExpandItem]);

  // 将相同的 props 传递给子组件
  const [lastExpandItemIndex, setLastExpandItemIndex] = useState<
    number | undefined
    >();
  const childNodes = bindImplicitProps(React.Children.toArray(children), {
    indent: indent + NEST_ITEM_INDENT,
    lastExpandItem: lastExpandItemIndex,
    submitExpandId: setLastExpandItemIndex,
  });

  return (
    <ul className='wdu-subNavMenu'>
      // 菜单标题
      <NavMenuItem
        className='wdu-subNavMenu__title'
        expand={menuExpand}
        indent={indent}
        onClick={handleClick}
        subMenuItem></NavMenuItem>

      <div
        className='wdu-subNavMenu__items'
        style={expandHeight}
        ref={refSubNavContainer}
        onTransitionEnd={keepChildItemExpandable}>
        {childNodes}
      </div>
    </ul>
  );
}

菜单项目的选中样式

接下来别忘了还有NavMenuItem组件,它既可以作为菜单项也可以作为菜单标题,每次点击菜单展开的时候,实际上就是点击了NavMenuItem,为了显示出当前选中的是哪个菜单项,需要在点击后给当前菜单项加上一个特定的样式【NavMenu 导航菜单】:难搞的展开/收缩动画上图中,选中当前菜单项后背景会变蓝,同时之前选中的菜单项背景会被移除。

这个需求的实现原理还是跟之前的“单个开关”模式一样;首先,还是在顶层的NavMenu组件中注册一个context value,然后分发到每个NavMenuItem中:

// 当前点击的菜单项的 ID
const [selectedItem, setSelectedItem] = useState<string>();

const contextValue = {
  submitSelectedItem: setSelectedItem,
};

NavMenuItem中,单击事件触发后,就提交当前 ID ,然后通过对比上一个被点击的菜单项的 ID 来动态设置当前菜单项的背景样式:

function NavMenuItem(props: propsNavMenuItem) {
  const {
    // .....
    expand = false,
    subMenuItem = false, // 当前组件是否是 SubNavMenu 标题
  } = props;

  const { selectedItem, submitSelectedItem } = useContext(NavContext);

  const menuId = useId();

  const handleClick = (event: MouseEvent) => {
    if (disabled) return;

    //  忽略 SubNavMenu 的标题 NavMenuItem
    if (!subMenuItem) submitSelectedItem(menuId);
  };

  return (
    <li
      className={navItemClassList}
      style={{ paddingLeft: `${indent}px` }}
      onClick={handleClick}>
      {icon && <span className={'wdu-navMenuItem__icon'}>{icon}</span>}

      {renderNavItem()}

      {subMenuItem && <Arrow style={expand ? 'bottom' : 'right'} />}
    </li>
  );
}

总结

本文介绍了如何用 React 实现一个导航菜单组件,通过递归组件的方式实现了菜单的嵌套;通过监听TransitionEnd事件来帮助实现了未知高度容器的过渡动画;通过React.context分发标识 ID 的办法实现了“单个开关”模式以及菜单项选中样式,最终得到了一个支持无限嵌套的、拥有过渡动画的导航菜单组件。

源码地址:github预览地址:Wood UI