在 React 中构建复杂的 UI 动画,简单易懂

Avatar of Alex Holachek
Alex Holachek

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

让我们使用 Reactstyled-componentsreact-flip-toolkit 来创建我们自己的 Stripe 首页动画导航菜单版本。它是一个拥有流畅动画效果的令人印象深刻的菜单,而这三种工具的组合可以使重现它变得相对容易。

这是一篇中级水平的演练,假设您熟悉 React 和基本的动画概念。我们的 React 指南 是一个不错的起点。

以下是我们的目标

查看 CodePen 上 Alex (@aholachek) 的作品 React Stripe 菜单

查看仓库


分解动画

首先,让我们将动画分解成不同的部分,以便我们更容易地重现它。您可能希望以慢动作查看最终产品(使用切换按钮),以便捕捉所有细节。

  1. 白色下拉容器更新其大小和位置。
  2. 下拉容器下半部分的灰色背景过渡其高度。
  3. 随着下拉容器的移动,先前的内容淡出并稍微向相反的方向移动,就像下拉菜单将它们留在后面一样,而新内容则滑入视图。

在我们着手在 React 中重现此动画时,需要牢记一些有用的指导原则。在可能的情况下,让我们通过让浏览器管理布局来保持简单。我们将通过将元素保留在常规 DOM 流中而不是使用绝对定位和手动计算来实现这一点。与其拥有一个每次用户鼠标位置改变都需要重新定位的单个下拉组件,不如在相应的导航栏部分渲染一个下拉组件。

我们将使用 FLIP 技术 来创建三个独立的下拉组件实际上是一个正在移动的组件的错觉。

使用 styled-components 搭建 UI

首先,我们将构建一个未动画的导航栏组件,它只需接收一个标题和下拉组件的配置对象,并渲染一个导航栏菜单。该组件将在鼠标悬停时显示和隐藏相关下拉菜单。

我们将使用 styled-components 构建 UI 组件。它们不仅是构建模块化 UI 的便捷方式,而且还具有 添加可配置 CSS 关键帧动画的出色 API。事实证明,CSS 动画和 React 能够很好地协同工作,因此我们将在稍后使用 CSS 关键帧添加许多动画。

在组件组装完成且没有任何动画的情况下,我们创建了一些看起来像这样的东西

查看 CodePen 上 Alex (@aholachek) 的作品 动画之前的 React Stripe 菜单

请注意,菜单底部的灰色背景不见了。它是我们唯一需要从常规 DOM 流中取出并绝对定位的元素,因此我们现在先忽略它。

使用 FLIP 技术为我们的下拉菜单设置动画

我们将使用 react-flip-toolkit 库来帮助我们为下拉菜单的大小和位置设置动画。这是我为了使高级和复杂过渡更容易和可配置而创建的一个库。

它为我们提供了两个组件:一个顶级 <Flipper/> 组件,以及一个 <Flipped/> 组件来包装我们想要设置动画的任何子元素。

首先,让我们在 AnimatedNavbar 的渲染函数中设置 Flipper 包装组件

// currentIndex is the index of the hovered dropdown
<Flipper flipKey={currentIndex}>
  <Navbar>
    {navbarConfig.map((n, index) => {
      // render navbar items here
    })}
  </Navbar>
</Flipper>  

接下来,在我们的 DropdownContainer 组件中,我们将需要设置动画的元素包装在它们自己的 Flipped 组件中,确保为每个组件提供唯一的 flipdId 属性

<DropdownRoot>
  <Flipped flipId="dropdown-caret">
    <Caret />
  </Flipped>
  <Flipped flipId="dropdown">
    <DropdownBackground>
      {children}    
    </DropdownBackground>
  </Flipped>
</DropdownRoot>

我们分别为 <Caret/> 组件和 <DropdownBackground/> 组件设置动画,以便 <DropdownBackground/> 组件上的 overflow:hidden 样式不会干扰 <Caret/> 组件的渲染。

现在我们有了可用的 FLIP 动画,但仍然存在一个问题:下拉菜单的内容在动画过程中看起来奇怪地拉伸了

查看 CodePen 上 Alex (@aholachek) 的作品 React Stripe 菜单 - 错误 #1:没有缩放调整

这种不希望有的效果是由于缩放转换应用于子元素造成的。如果您对包含一些文本的 div 应用 scaleY(2),您也将放大文本并使其变形。

我们可以通过将子元素包装在一个带有 inverseFlipIdFlipped 组件中来解决此问题,该组件引用父组件的 flipId(在本例中为 "dropdown")以请求取消父级转换对子级的影响。因为我们仍然希望平移转换影响子元素,所以我们还传递了 scale 属性以将取消限制为仅缩放更改。

<DropdownRoot>
  <Flipped flipId="dropdown-caret">
    <Caret />
  </Flipped>
  <Flipped flipId="dropdown">
    <DropdownBackground>
      <Flipped inverseFlipId="dropdown" scale>
        {children}
      </Flipped>
    </DropdownBackground>
  </Flipped>
</DropdownRoot>

哇。所有这些工作,我们创建了一些看起来像这样的东西

查看 CodePen 上 Alex (@aholachek) 的作品 React Stripe 菜单 - 简单 FLIP

细节决定成败

越来越接近了,但我们仍然需要处理一些细节,使动画看起来很棒:下拉菜单出现和消失时的微妙旋转动画、先前和当前下拉菜单子元素的交叉淡入淡出,以及丝般顺滑的灰色背景高度动画。

使用 styled 组件进行可配置的 CSS 关键帧动画

我们一直在使用 styled-components 来构建此演示的 UI,它提供了一种非常方便的方式来创建 可配置的关键帧动画。 我们将此功能用于下拉菜单进入动画和内容的交叉淡入淡出。我们可以传入有关所需动画的一些基本信息——内容是淡入还是淡出,以及用户鼠标移动的方向——并自动应用相应的动画。例如,以下是 <FadeContents> 组件中交叉淡入淡出动画的代码

const getFadeContainerKeyFrame = ({ animatingOut, direction }) => {
  if (!direction) return;
  return keyframes`
  from {
    transform: translateX(${
      animatingOut ? 0 : direction === "left" ? 20 : -20
    }px);
  }
  to {
    transform: translateX(${
      !animatingOut ? 0 : direction === "left" ? -20 : 20
    }px);
    opacity: ${animatingOut ? 0 : 1};
  }
`;
};

const FadeContainer = styled.div`
  animation-name: ${getFadeContainerKeyFrame};
  animation-duration: ${props => props.duration * 0.5}ms;
  animation-fill-mode: forwards;
  position: ${props => (props.animatingOut ? "absolute" : "relative")};
  opacity: ${props => (props.direction && !props.animatingOut ? 0 : 1)};
  animation-timing-function: linear;
  top: 0;
  left: 0;
`;

每次用户悬停新项目时,我们不仅会将当前下拉菜单,还会将上一个下拉菜单作为子元素提供给 DropdownContainer 组件,以及有关用户鼠标移动方向的信息。然后,DropdownContainer 组件会将它的两个子元素包装在一个新的组件 FadeContents 中,该组件将使用上面的关键帧动画代码添加适当的过渡。

这是一个指向 FadeContents 组件完整代码 的链接。

下拉菜单的进入/退出动画 的工作方式非常相似。

最后的润色:流畅的背景动画

最后,让我们添加灰色背景动画。为了使此动画保持清晰,我们需要偏离我们之前保持正常 DOM 嵌套并让浏览器处理布局的策略,而是执行一些手动定位计算。我们还需要直接与 DOM 交互。简而言之,它会变得有点混乱。

这是一个我们基本方法的可视化表示

查看 CodePen 上 Alex (@aholachek) 的作品 React Stripe Menu — Animated Background

我们将一个灰色的 div 绝对定位在 DropdownContainer 的顶部。在 DropdownContainercomponentDidMount 生命周期函数中,我们将更新灰色背景的 translateY 变换。如果下拉容器组件只有一个子元素(这意味着用户到目前为止只悬停了一个下拉菜单),我们将把灰色 div 的 translateY 设置为第一个下拉菜单部分的高度。如果有两个子元素,包括之前的一个下拉菜单,我们将把初始 translateY 设置为之前下拉菜单的第一个部分的高度,然后将 translateY 动画化到当前下拉菜单的第一个部分的高度。以下是 componentDidMount 中调用的函数

const updateAltBackground = ({
  altBackground,
  prevDropdown,
  currentDropdown
}) => {
  const prevHeight = getFirstDropdownSectionHeight(prevDropdown)
  const currentHeight = getFirstDropdownSectionHeight(currentDropdown)
  
  // we'll use this function when we want a change 
  // to happen immediately, without CSS transitions
  const immediateSetTranslateY = (el, translateY) => {
    el.style.transform = `translateY(${translateY}px)`
    el.style.transition = "transform 0s"
    requestAnimationFrame(() => (el.style.transitionDuration = ""))
  }

  if (prevHeight) {
    // transition the grey ("alt") background from its previous height
    // to its current height
    immediateSetTranslateY(altBackground, prevHeight)
    requestAnimationFrame(() => {
      altBackground.style.transform = `translateY(${currentHeight}px)`
    })
  } else {
    // immediately set the background to the appropriate height
    // since we don't have a stored value
    immediateSetTranslateY(altBackground, currentHeight)
  }
}

这种方法需要 DropdownContainer 使用 ref 并进入其子元素以在 getFirstDropdownSectionHeight 函数中获取 DOM 尺寸,这感觉有点笨拙。如果您有任何替代实现的想法,请在评论中告诉我!

总结

希望本文能帮助您澄清一些技术,以便您下次在 React 中构建动画时可以使用。通常有多种方法可以实现任何效果,但通常最好从最简单的实现开始——基本组件与一些 CSS 过渡或关键帧动画——并在必要时从那里扩展复杂性。在我们的例子中,这意味着包含一个额外的库 react-flip-toolkit,这样我们就不必担心手动转换下拉组件在屏幕上的位置。为了完全重现动画,我们确实需要编写大量的代码。但是通过将此动画分解成单独的部分并逐一解决它们,我们设法在 React 中复制了一个非常酷的 UI 效果。