理解 react-spring

Avatar of Adam Rackis
Adam Rackis

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

动画是 React 中比较难处理的事情之一。 在这篇文章中,我将尝试提供我最初开始使用 react-spring 时希望拥有的介绍,然后深入探讨一些有趣的用例。 虽然 react-spring 不是 React 的唯一动画库,但它是更流行(也是更好)的库之一。

我将使用最新的 9.0 版本,截至撰写本文时,它处于候选发布状态。 如果您阅读本文时它尚未完全发布,请确保使用 react-spring@next 安装它。 从我所见和主要维护人员告诉我的来看,代码非常稳定。 我遇到的唯一问题是在并发模式下使用时出现的一个小错误,可以在 GitHub 存储库中追踪

react-spring redux

在我们开始探讨一些有趣的应用用例之前,让我们快速浏览一下介绍。 我们将涵盖弹簧、高度动画,然后是过渡。 我将在本节末尾提供一个可运行的演示,所以如果过程中有一些东西让您感到困惑,请不要担心。

弹簧

让我们考虑动画的典型“Hello world”:淡入淡出内容。 让我们停下来,想想我们如何在没有动画的情况下打开和关闭不透明度。 它看起来像这样

export default function App() {
  const [showing, setShowing] = useState(false);
  return (
    <div>
      <div style={{ opacity: showing ? 1 : 0 }}>
        This content will fade in and fade out
      </div>
      <button onClick={() => setShowing(val => !val)}>Toggle</button>
      <hr />
    </div>
  );
}

很简单,但很无聊。 我们如何动画更改的不透明度? 如果我们可以根据状态声明性地设置我们想要的不透明度,就像我们在上面所做的那样,但让这些值平滑地动画,那不是很好吗? 这就是 react-spring 的作用。 将 react-spring 视为我们的中间人,它洗涤我们不断变化的样式值,以便它可以生成我们想要在动画值之间进行的平滑过渡。 就像这样

const [showA, setShowA] = useState(false);


const fadeStyles = useSpring({
  config: { ...config.stiff },
  from: { opacity: 0 },
  to: {
    opacity: showA ? 1 : 0
  }
});

我们使用 from 指定初始样式值,并根据当前状态在 to 部分指定当前值。 返回值 fadeStyles 包含我们应用于内容的实际样式值。 只有一个我们需要完成的事情。

您可能会认为您可以这样做

<div style={fadeStyles}>

…然后就完成了。 但是,我们不是使用常规的 div,而是需要使用从 animated 导出创建的 react-spring div。 这可能听起来很令人困惑,但它实际上意味着

<animated.div style={fadeStyles}>

就这样。

动画高度

根据我们正在动画的内容,我们可能希望内容上下滑动,从零高度到其完整大小,以便周围的内容调整并平滑地流入到位。 您可能希望我们可以简单地复制上面的内容,并将高度从零改为自动,但遗憾的是,您无法动画到自动高度。 这既不适用于普通 CSS,也不适用于 react-spring。 相反,我们需要知道内容的实际高度,并在弹簧的 to 部分指定它。

我们需要动态获取任意内容的高度,以便可以将该值传递给 react-spring。 事实证明,Web 平台专门为此设计了一些东西:ResizeObserver。 并且支持实际上相当不错! 由于我们使用的是 React,因此我们当然会在钩子中包装此用法。 这是我的代码

export function useHeight({ on = true /* no value means on */ } = {} as any) {
  const ref = useRef<any>();
  const [height, set] = useState(0);
  const heightRef = useRef(height);
  const [ro] = useState(
    () =>
      new ResizeObserver(packet => {
        if (ref.current && heightRef.current !== ref.current.offsetHeight) {
          heightRef.current = ref.current.offsetHeight;
          set(ref.current.offsetHeight);
        }
      })
  );
  useLayoutEffect(() => {
    if (on && ref.current) {
      set(ref.current.offsetHeight);
      ro.observe(ref.current, {});
    }
    return () => ro.disconnect();
  }, [on, ref.current]);
  return [ref, height as any];
}

我们可以选择提供一个 on 值,该值打开和关闭测量(这将在以后派上用场)。 当 ontrue 时,我们告诉 ResizeObserver 观察我们的内容。 我们返回一个引用,该引用需要应用于我们想要测量的任何内容,以及当前高度。

让我们看看它在行动中的样子。

const [heightRef, height] = useHeight();
const slideInStyles = useSpring({
  config: { ...config.stiff },
  from: { opacity: 0, height: 0 },
  to: {
    opacity: showB ? 1 : 0,
    height: showB ? height : 0
  }
});

useHeight 为我们提供了对我们正在测量的內容的引用和高度值,我们将其传递给我们的弹簧。 然后我们应用引用并应用高度样式。

<animated.div style={{ ...slideInStyles, overflow: "hidden" }}>
  <div ref={heightRef}>
    This content will fade in and fade out with sliding
  </div>
</animated.div>

哦,别忘了将 overflow: hidden 添加到容器中。 这使我们能够正确地容纳调整的高度值。

动画过渡

最后,让我们看看如何将动画项目添加和删除到 DOM 中,以及从 DOM 中添加和删除动画项目。 我们已经知道如何为 DOM 中存在且保持在 DOM 中的项目动画更改值,但要为添加或删除项目动画,我们需要一个新的钩子:useTransition。

如果您之前使用过 react-spring,那么这是 9.0 版本在 API 中发生一些重大更改的少数地方之一。 让我们来看看。

为了对像这样的项目列表进行动画处理

const [list, setList] = useState([]);

…我们将声明我们的过渡函数,如下所示

const listTransitions = useTransition(list, {
  config: config.gentle,
  from: { opacity: 0, transform: "translate3d(-25%, 0px, 0px)" },
  enter: { opacity: 1, transform: "translate3d(0%, 0px, 0px)" },
  leave: { opacity: 0, height: 0, transform: "translate3d(25%, 0px, 0px)" },
  keys: list.map((item, index) => index)
});

正如我之前提到的,返回值 listTransitions 是一个函数。 react-spring 正在跟踪列表数组,跟踪添加和删除的项目。 我们调用 listTransitions 函数,提供一个接受单个 styles 对象和单个项目的回调函数,react-spring 将为列表中的每个项目调用它,并提供正确的样式,具体取决于它是新添加的、新删除的还是只是位于列表中。

注意 keys 部分: 这使我们能够告诉 react-spring 如何识别列表中的对象。 在这种情况下,我决定告诉 react-spring,数组中项目的索引唯一地定义了该项目。 通常,这是一个糟糕的主意,但现在,它允许我们看到该功能在行动中的样子。 在下面的演示中,当单击“添加项目”按钮时,它将一个项目添加到列表的末尾,而“删除最后一个项目”按钮将删除从列表中添加的最后一个项目。 因此,如果您在输入框中键入内容,然后快速点击添加按钮,然后点击删除按钮,您将看到同一个项目平滑地开始进入,然后立即从其动画中的任何阶段开始离开。 相反,如果您添加一个项目,然后快速点击删除按钮和添加按钮,同一个项目将开始滑出,然后突然停留在原地,然后滑回原来的位置。

这是一个演示

哇,这真是很多文字! 这是一个可运行的演示,展示了我们刚刚介绍的所有内容在行动中的样子。

零碎

您注意到,当您在演示中向下滑动内容时,它会以某种方式弹回原位,就像…弹簧一样吗? 这就是名称的由来:react-spring 使用弹簧物理学来插值我们不断变化的值。 它不会简单地将值更改切割成 N 个相等的增量,并在 N 个相等的延迟内应用这些增量。 相反,它使用更复杂的算法来产生这种弹簧般的效果,这将显得更加自然。

弹簧算法是完全可配置的,并且它提供了一些现成的预设值 - 上面的演示使用 stiffgentle 预设值。 有关更多信息,请参阅 文档

还要注意我如何在 translate3d 值内动画处理值。 正如您所见,语法不是最简洁的,因此 react-spring 提供了一些快捷方式。 有关此方面的 文档,但在本文的其余部分,我将继续使用完整的非快捷方式语法,以确保尽可能清晰地说明问题。

我将在本节结尾提请注意一个事实,即当您向上滑动上面的演示中的内容时,您可能会看到它下面的内容在最后有点跳动。 这是由于这种弹簧效果造成的。 当内容向下弹回原位时,它看起来很清晰,但当我们向上滑动内容时,它看起来就不那么清晰了。 请继续关注,看看我们如何将其关闭。(剧透,它是 clamp 属性)。

关于这些沙盒的一些注意事项

Code Sandbox 使用热重载。 当您更改代码时,更改通常会立即反映出来。 这很酷,但会对动画造成破坏。 如果您开始修补,然后看到奇怪的、明显不正确的行为,请尝试刷新沙盒。

这篇文章中的其他沙盒将使用模态。 出于我还没有完全弄清楚的原因,当模态打开时,您将无法修改任何代码 - 模态拒绝放弃焦点。 因此,请确保在尝试任何更改之前关闭模态。

现在让我们构建一些真实的东西

这些是 react-spring 的基本构建块。让我们用它们来构建一些更有趣的东西。你可能认为,鉴于以上所有内容,react-spring 非常易于使用。不幸的是,在实践中,可能很难弄清楚你需要正确处理的一些微妙之处。本文的其余部分将深入探讨这些细节中的许多。

我之前写的一些博客文章与我的 booklist 侧边项目有关。这篇文章也不例外——这不是痴迷,只是那个项目碰巧有一个公开可用的 GraphQL 端点,以及大量可以利用的现有代码,使其成为一个明显的目标。

让我们构建一个允许你打开模态窗口并搜索书籍的 UI。当结果出现时,你可以将它们添加到显示在模态窗口下方的已选书籍的运行列表中。完成后,你可以关闭模态窗口并单击一个按钮以查找与选择相似的书籍。

我们将从一个功能性 UI 开始,然后逐步对各个部分进行动画处理,包括一路上的交互式演示。

如果你真的渴望看看最终结果的样子,或者你已经熟悉 react-spring 并想看看我是否涵盖了你不知道的任何内容,这里就是(我知道它不会赢得任何设计奖)。本文的其余部分将逐步介绍到达该最终状态的过程。

为我们的模态窗口添加动画

让我们从我们的模态窗口开始。在我们开始添加任何类型的数据之前,让我们让我们的模态窗口动画效果很好。 这是 一个基本的、没有动画的模态窗口 的样子。我使用的是 Ryan Florence 的 Reach UI(特别是 模态窗口 组件),但是无论你使用什么构建你的模态窗口,想法都是一样的。我们希望让我们的背景淡入,并且也让我们的模态窗口内容进行过渡。

由于模态窗口是根据某种“打开”属性有条件地渲染的,我们将使用 useTransition 钩子。我已经用我自己的模态窗口组件包装了 Reach UI 模态窗口,并根据 isOpen 属性渲染为空或实际的模态窗口。我们只需要通过过渡钩子来让它进行动画。

以下是过渡钩子的样子

const modalTransition = useTransition(!!isOpen, {
  config: isOpen ? { ...config.stiff } : { duration: 150 },
  from: { opacity: 0, transform: `translate3d(0px, -10px, 0px)` },
  enter: { opacity: 1, transform: `translate3d(0px, 0px, 0px)` },
  leave: { opacity: 0, transform: `translate3d(0px, 10px, 0px)` }
});

这里没有太多意外。我们希望淡入事物并根据模态窗口是否处于活动状态提供一个轻微的垂直过渡。奇怪的部分是这个

config: isOpen ? { ...config.stiff } : { duration: 150 },

我只想在模态窗口打开时使用弹簧物理。这样做的原因——至少根据我的经验——是当你关闭模态窗口时,背景消失的时间太长,导致底层 UI 在太长时间内无法交互。所以,当模态窗口打开时,它会用弹簧物理很好地弹入到位,当关闭时,它会在 150 毫秒内快速消失。

当然,我们将通过我们的钩子返回的过渡函数渲染我们的内容。请注意,我将不透明度样式从样式对象中取出以应用于背景,然后将所有动画样式应用于实际的模态窗口内容。

return modalTransition(
  (styles, isOpen) =>
    isOpen && (
      <AnimatedDialogOverlay
        allowPinchZoom={true}
        initialFocusRef={focusRef}
        onDismiss={onHide}
        isOpen={isOpen}
        style={{ opacity: styles.opacity }}
      >
      <AnimatedDialogContent
        style={{
          border: "4px solid hsla(0, 0%, 0%, 0.5)",
          borderRadius: 10,
          maxWidth: "400px",
          ...styles
        }}
      >
        <div>
          <div>
            <StandardModalHeader caption={headerCaption} onHide={onHide} />
            {children}
          </div>
        </div>
      </AnimatedDialogContent>
    </AnimatedDialogOverlay>
  )
);

基本设置

让我们从上面描述的用例开始。如果你正在使用演示进行操作,这里是 所有内容都在工作时的完整演示,但没有任何动画。打开模态窗口,搜索任何东西(随意在空文本框中按 Enter)。你应该会访问我的 GraphQL 端点,并从我自己的个人库中获得搜索结果。

本文的其余部分将重点介绍向 UI 添加动画,这将使我们有机会看到前后对比,并(希望)观察一些微妙的、放置得当的动画如何使 UI 更漂亮。

为模态窗口大小添加动画

让我们从模态窗口本身开始。打开它并搜索“jefferson”。注意模态窗口如何突然变大以适应新内容。我们可以让模态窗口动画过渡到更大(和更小)的大小吗?当然可以。让我们找出我们信赖的 useHeight 钩子,看看我们能做些什么。

不幸的是,我们不能简单地将高度 ref 添加到我们内容中的包装器上,然后将高度放入弹簧中。如果我们这样做,我们会看到模态窗口滑动到其初始大小。我们不想要这样;我们希望我们的完全成形的模态窗口以正确的大小出现,并且从那里进行调整大小。

我们想要做的是等待我们的模态窗口内容在 DOM 中渲染,然后设置我们的高度 ref,并打开我们的 useHeight 钩子,开始测量。哦,我们希望我们的初始高度立即设置,而不是动画过渡到到位。听起来很多,但没有那么糟糕。

让我们从这个开始

const [heightOn, setHeightOn] = useState(false);
const [sizingRef, contentHeight] = useHeight({ on: heightOn });
const uiReady = useRef(false);

我们有一些状态用于我们是否正在测量模态窗口的高度。当模态窗口在 DOM 中时,这将被设置为 true。然后,我们使用 on 属性调用我们的 useHeight 钩子,以确定我们是否处于活动状态。最后,一些状态来保存我们的 UI 是否已准备好,并且我们可以开始进行动画处理。

首先:我们如何知道我们的模态窗口何时真正渲染在 DOM 中?事实证明,我们可以使用一个 ref 来让我们知道。我们习惯在 React 中使用 <div ref={someRef},但你实际上可以传递一个函数,React 将在它渲染后使用 DOM 节点调用该函数。现在让我们定义该函数。

const activateRef = ref => {
  sizingRef.current = ref;
  if (!heightOn) {
    setHeightOn(true);
  }
};

这将设置我们的高度 ref 并打开我们的 useHeight 钩子。我们快完成了!

现在我们如何让初始动画不立即执行?useSpring 钩子有两个我们现在要查看的新属性。它有一个 immediate 属性,它告诉它使状态更改立即执行,而不是对其进行动画处理。它还有一个 onRest 回调函数,该函数会在状态更改完成后触发。

让我们利用它们。以下是最终钩子的样子

const heightStyles = useSpring({
  immediate: !uiReady.current,
  config: { ...config.stiff },
  from: { height: 0 },
  to: { height: contentHeight },
  onRest: () => (uiReady.current = true)
});

任何高度更改完成后,我们将 uiReady ref 设置为 true。只要它为 false,我们就会告诉 react-spring 进行立即更改。因此,当我们的模态窗口首次挂载时,contentHeight 为零(如果没有任何内容可测量,useHeight 将返回零),弹簧只是在闲置,什么也不做。当模态窗口切换为打开,并且实际内容已渲染时,我们的 activateRef ref 被调用,我们的 useHeight 将打开,我们将为我们的内容获得一个实际的高度值,我们的弹簧将“立即”设置它,最后,onRest 回调函数将触发,并且将对未来的更改进行动画处理。呼!

我应该指出,如果在某些备用用例中,我们在首次渲染时确实立即获得了正确的高度,我们将能够将上面的钩子简化为以下内容

const heightStyles = useSpring({
  to: {
    height: contentHeight
  },
  config: config.stiff,
})

…这实际上可以进一步简化为以下内容

const heightStyles = useSpring({
  height: contentHeight,
  config: config.stiff,
})

我们的钩子将使用正确的高度进行初始渲染,并且对该值的任何更改都将进行动画处理。但由于我们的模态窗口在实际显示之前就渲染了,因此我们无法使用这种简化。

敏锐的读者可能会想知道当你关闭模态窗口时会发生什么。嗯,内容将取消渲染,高度钩子将保留最后报告的高度,但仍然“观察”一个不再在 DOM 中的 DOM 节点。如果你担心这个问题,随时比我在这里做得更好,也许使用以下内容

useLayoutEffect(() => {
  if (!isOpen) {
    setHeightOn(false);
  }
}, [isOpen]);

这将取消该 DOM 节点的 ResizeObserver 并修复内存泄漏。

为结果添加动画

接下来,让我们看看如何为模态窗口中结果的更改添加动画。如果你运行一些搜索,你应该会看到结果立即交换进出。

查看 searchBooks.js 文件中的 SearchBooksContent 组件。现在,我们有 const booksObj = data?.allBooks;,它从 GraphQL 响应中取出适当的结果集,然后在后面渲染它们。

{booksObj.Books.map(book => (
  <SearchResult
    key={book._id}
    book={book}
    selected={selectedBooksMap[book._id]}
    selectBook={selectBook}
    dispatch={props.dispatch}
  />
))}

当来自我们的 GraphQL 端点的最新结果返回时,此对象将发生更改,所以为什么不利用这个事实,并将其传递给之前的 useTransition 钩子,并获得一些定义的过渡动画。

const resultsTransition = useTransition(booksObj, {
  config: { ...config.default },
  from: {
    opacity: 0,
    position: "static",
    transform: "translate3d(0%, 0px, 0px)"
  },
  enter: {
    opacity: 1,
    position: "static",
    transform: "translate3d(0%, 0px, 0px)"
  },
  leave: {
    opacity: 0,
    position: "absolute",
    transform: "translate3d(90%, 0px, 0px)"
  }
});

注意从 position: staticposition: absolute 的更改。具有绝对定位的传出结果集不会影响其父级的 height,这是我们想要的。我们的父级将调整到内容的大小,当然,我们的模态窗口将根据我们上面所做的工作很好地动画到新大小。

像之前一样,我们将使用我们的过渡函数来渲染我们的内容

<div className="overlay-holder">
  {resultsTransition((styles, booksObj) =>
    booksObj?.Books?.length ? (
      <animated.div style={styles}>
        {booksObj.Books.map(book => (
          <SearchResult
            key={book._id}
            book={book}
            selected={selectedBooksMap[book._id]}
            selectBook={selectBook}
            dispatch={props.dispatch}
          />
        ))}
      </animated.div>
    ) : null
  )}

现在,新的结果集将淡入,而传出的结果集将淡出(并略微滑动)以向用户提供额外的提示,表明事情已更改。

当然,我们还希望为任何消息进行动画处理,例如没有结果时,或者用户选择了结果集中的所有内容时。这段代码与这里的所有其他代码非常重复,并且由于这篇文章已经很长了,我会将代码留在演示中

选中书籍的动画(出)

现在,选择一本书会立即并突然地将其从列表中消失。让我们在将它滑出到右侧的同时,应用我们通常的淡出效果。当项目向右滑动(通过变换)时,我们可能希望它的高度动画设置为零,以便列表可以平滑地调整到退出项目,而不是让它滑动出去,留下一个空框,然后立即消失。

到目前为止,你可能认为这很容易。你期待的是这样的

const SearchResult = props => {
  let { book, selectBook, selected } = props;


  const initiallySelected = useRef(selected);
  const [sizingRef, currentHeight] = useHeight();


  const heightStyles = useSpring({
    config: { ...config.stiff, clamp: true },
    from: {
      opacity: initiallySelected.current ? 0 : 1,
      height: initiallySelected.current ? 0 : currentHeight,
      transform: "translate3d(0%, 0px, 0px)"
    },
    to: {
      opacity: selected ? 0 : 1,
      height: selected ? 0 : currentHeight,
      transform: `translate3d(${selected ? "25%" : "0%"},0px,0px)`
    }
  }); 

这使用我们信赖的 useHeight 钩子来测量我们的内容,使用 selected 值来为离开的项目进行动画处理。我们正在跟踪 selected 属性,并且如果它已经被选中,则将高度动画设置为 0 或从 0 开始,而不是简单地删除项目并使用过渡。这允许不同的结果集,即使它们有相同的书籍,如果书籍被选中,也会正确地拒绝显示它。

这段代码确实有效。尝试一下 在这个演示中

但是有一个问题。如果你在结果集中选择大部分书籍,当继续选择时,会有一种弹跳动画链。书籍开始从列表中动画出去,然后模态本身的高度开始落后。

在我看来,这看起来很愚蠢,所以让我们看看我们能做些什么。

我们已经看到如何使用 immediate 属性关闭所有弹簧动画。我们还看到 onRest 回调在动画完成时触发,我相信你不会感到惊讶,有一个 onStart 回调,它执行你所期望的操作。让我们使用这些部分来允许模态内部的内容在内容本身正在动画高度时“关闭”模态高度的动画。

首先,我们将向模态添加一些状态,用于切换动画的开关。

const animatModalSizing = useRef(true);
const modalSizingPacket = useMemo(() => {
  return {
    disable() {
      animatModalSizing.current = false;
    },
    enable() {
      animatModalSizing.current = true;
    }
  };
}, []);

现在,让我们将它绑定到我们之前的过渡。

const heightStyles = useSpring({
  immediate: !uiReady.current || !animatModalSizing.current,
  config: { ...config.stiff },
  from: { height: 0 },
  to: { height: contentHeight },
  onRest: () => (uiReady.current = true)
});

很好。现在我们如何将 modalSizingPacket 传递到我们的内容,以便无论我们在渲染什么,都可以在需要时实际关闭模态的动画?当然是用上下文!让我们创建一个上下文。

export const ModalSizingContext = createContext(null);

然后,我们将用它来包装模态的所有内容

<ModalSizingContext.Provider value={modalSizingPacket}>

现在,我们的 SearchResult 组件可以获取它

const { enable: enableModalSizing, disable: disableModalSizing } = useContext(
  ModalSizingContext
);

…并将它绑定到它的弹簧

const heightStyles = useSpring({
  config: { ...config.stiff, clamp: true },
  from: {
    opacity: initiallySelected.current ? 0 : 1,
    height: initiallySelected.current ? 0 : currentHeight,
    transform: "translate3d(0%, 0px, 0px)"
  },
  to: {
    opacity: selected ? 0 : 1,
    height: selected ? 0 : currentHeight,
    transform: `translate3d(${selected ? "25%" : "0%"},0px,0px)`
  },
  onStart() {
    if (uiReady.current) {
      disableModalSizing();
    }
  },
  onRest() {
    uiReady.current = true;
    setTimeout(() => {
      enableModalSizing();
    });
  }
});

注意最后的 setTimeout。我发现,为了确保模态的动画真正关闭,直到一切都稳定下来,这是必要的。

我知道代码很多。如果我速度太快,请务必查看 演示,以查看所有这些的实际效果。

选中书籍的动画(入)

让我们通过为出现在主屏幕上模态下方已选中的书籍进行动画处理来结束这篇文章。让我们让新选中的书籍在被选中时从左侧滑动并淡入,然后在被移除时向右滑动,同时高度缩小到零。

我们将使用过渡,但似乎已经出现了一个问题,因为我们需要考虑到每本已选中的书籍都需要有它自己的单独高度。以前,当我们使用 useTransition 时,我们有一个单独的 fromto 对象,它们应用于进入和退出项目。

这里,我们将使用另一种形式,允许我们为 to 对象提供一个函数。它将与实际的动画项目(在本例中为书籍对象)一起调用,我们返回包含动画值的 to 对象。此外,我们将跟踪一个简单的查找对象,它将每本书的 ID 映射到它的高度,然后将其绑定到我们的过渡。

首先,让我们创建高度值的映射

const [displaySizes, setDisplaySizes] = useState({});
const setDisplaySize = useCallback(
  (_id, height) => {
    setDisplaySizes(displaySizes => ({ ...displaySizes, [_id]: height }));
  },
  [setDisplaySizes]
);

我们将 setDisplaySizes 更新函数传递给 SelectedBook 组件,并将其与 useHeight 一起使用,以报告每本书的实际高度。

const SelectedBook = props => {
  let { book, removeBook, styles, setDisplaySize } = props;
  const [ref, height] = useHeight();
  useLayoutEffect(() => {
    height && setDisplaySize(book._id, height);
  }, [height]);

请注意,我们在调用它之前如何检查高度值是否已使用实际值更新。这样,在我们设置正确的高度之前,我们不会过早地将值设置为零,这会导致我们的内容向下动画,而不是完全成形地滑入。相反,最初不会设置任何高度,因此我们的内容将默认为 height: auto。当我们的钩子触发时,实际的高度将被设置。当一个项目被移除时,高度将动画设置为零,因为它会淡出并滑动出去。

这是过渡钩子

const selectedBookTransitions = useTransition(selectedBooks, {
  config: book => ({
    ...config.stiff,
    clamp: !selectedBooksMap[book._id]
  }),
  from: { opacity: 0, transform: "translate3d(-25%, 0px, 0px)" },
  enter: book => ({
    opacity: 1,
    height: displaySizes[book._id],
    transform: "translate3d(0%, 0px, 0px)"
  }),
  update: book => ({ height: displaySizes[book._id] }),
  leave: { opacity: 0, height: 0, transform: "translate3d(25%, 0px, 0px)" }
});

注意 update 回调。如果任何高度发生变化,它将调整我们的内容。(你可以在演示中通过在选择大量书籍后调整结果窗格的大小来强制执行此操作。)

为了给我们的蛋糕增添一点糖霜,请注意我们如何有条件地设置钩子配置的 clamp 属性。当内容动画进入时,我们关闭了 clamp,这会产生一个很好的(至少在我看来)弹跳效果。但当离开时,它会向下动画,但会保持消失状态,没有任何之前我们在关闭钳制时看到的抖动。

奖励:简化模态高度动画,同时修复一个 bug

在完成这篇文章后,我在模态实现中发现了一个 bug,如果模态高度在显示时发生变化,你会在下一次打开模态时看到旧的、现在不正确的高度,然后模态会动画到正确的高度。要了解我的意思,请查看演示的这个更新。你会注意到新的按钮可以清除或强制在模态不可见时将结果添加到模态中。打开模态,然后关闭它,单击按钮添加结果,然后重新打开它,你应该会看到它尴尬地动画到新的、正确的高度。

修复此问题也使我们能够简化之前高度动画的代码。问题在于我们的模态目前仍在 React 组件树中渲染,即使它没有显示。高度钩子仍然在“运行”,只是在下次模态显示时才更新,并渲染子级。如果我们将模态的子级移到它自己的专用组件中,并将高度钩子带到那里会怎么样?这样,钩子和动画弹簧只会在模态显示时渲染,并且可以从正确的值开始。它不像看起来那样复杂。现在我们的模态组件是这样的

<animated.div style={{ overflow: "hidden", ...heightStyles }}>
  <div style={{ padding: "10px" }} ref={activateRef}>
    <StandardModalHeader
      caption={headerCaption}
      onHide={onHide}
    />
    {children}
  </div>
</animated.div>

让我们创建一个新的组件来渲染这个标记,包括所需的钩子和引用

const ModalContents = ({ header, contents, onHide, animatModalSizing }) => {
  const [sizingRef, contentHeight] = useHeight();
  const uiReady = useRef(false);

  const heightStyles = useSpring({
    immediate: !uiReady.current || !animatModalSizing.current,
    config: { ...config.stiff },
    from: { height: 0 },
    to: { height: contentHeight },
    onRest: () => (uiReady.current = true)
  });

  return (
    <animated.div style={{ overflow: "hidden", ...heightStyles }}>
      <div style={{ padding: "10px" }} ref={sizingRef}>
        <StandardModalHeader caption={header} onHide={onHide} />
        {contents}
      </div>
    </animated.div>
  );
};

与我们之前的代码相比,这显著降低了复杂性。我们不再有 activateRef 函数,也不再有在 activateRef 中设置的 heightOn 状态。这个组件只有在模态显示时才会被模态渲染,这意味着我们保证有内容,因此我们可以只向我们的 div 添加一个常规引用。不幸的是,我们仍然需要 uiReady 状态,因为即使现在我们也没有在第一次渲染时获得高度;它只有在第一次渲染完成后立即触发的 useHeight 布局效果才会可用。

当然,这也解决了之前的 bug。无论在模态关闭时发生了什么,当它重新打开时,这个组件将重新渲染,我们的弹簧将从 uiReady 的新值开始。

结束语

如果你一直坚持到最后,谢谢你!我知道这篇文章很长,但我希望你从中找到了一些价值。

react-spring 是一个用于用 React 创建强大动画的不可思议的工具。它有时可能很底层,这使得它对于非平凡的用例来说很难理解。但正是这种底层性质使它如此灵活。