如何为不可动画化的事物制作动画

7,364 阅读10分钟

在CSS3过渡、@keyframe动画和奇妙的新技术(如即将推出的Web Animations API)之间,当涉及到在网络上构建动画时,我们从未有更多的控制。

不过,仍有一件事是这些技术都无法处理的:动画列表的重新排序。

Several list items being reorganized, with animation

识别问题

比方说,你有这个组件。

class ArticleList extends Component {
  render() {
    return (
      <div id="article-list">
        {this.props.articles.map(article => (
          <Article key={article.id} {...article} />
        ))}
      </div>
    );
  }
}

我们有一个父级的ArticleList 组件,它接受一个文章列表作为其道具。它按顺序映射它们,并渲染它们。

如果列表的顺序发生了变化(例如:用户切换了一个设置,改变了排序,一个项目得到了支持并改变了位置,新的数据从服务器上传来......),React会协调这两种状态,并更新DOM,创建新的节点,移动现有的节点,或销毁节点。

如果一个项目被从原来的位置移走,并在向下200px的新位置重新插入,它不知道这种更新对该元素在屏幕上的位置意味着什么。

因为该元素的CSS属性没有变化,所以没有办法使用CSS过渡来模拟这种变化。

我们怎样才能让浏览器表现得好像这些元素已经移动了?这个问题的解决方案将带领我们经历低级别的DOM操作、React生命周期方法和硬件加速的CSS实践。甚至还会有一些基本的数学知识。

解决方案

TL:DR - 我做了一个React模块来做这个。

为了解决这个问题,有几条信息是我们需要的,而且我们在一个非常具体的时间点上需要它们。让我们暂时放弃获取这些信息的复杂性,并根据以下假设进行操作。

  • 我们知道React刚刚重新渲染,DOM节点已经被重新排列。

  • 浏览器还没有绘制。即使DOM节点在新的位置上,屏幕上的元素还没有被更新。

  • 我们知道这些元素在屏幕上的位置。

  • 我们知道这些元素即将被重新涂抹的位置。

下面是我们可能遇到的情况。我们有一个3个项目的列表,它们刚刚被颠倒过来。我们知道他们的原始位置(左边),我们知道他们要移动到哪里(右边)。

Three colored rectangles that have been reordered请无视我的艺术能力不足

操作顺序

一个简短的旁白:你可能会惊讶地发现,在一个项目被画到屏幕上之前,存在一个我们可以知道它将在哪里的时刻。

当你想一想,这是有道理的;浏览器在知道新像素的确切位置之前,怎么能把它们画到屏幕上?

值得庆幸的是,这并不是一个黑匣子;浏览器以不同的步骤进行更新,而且有可能在计算布局和向屏幕绘制之间执行逻辑。

但我们如何访问计算出来的布局呢?

来救场!

DOM节点有一个非常有用的本地方法,getBoundingClientRect。它为我们提供了目标元素相对于视口的大小和位置。如果我们在计算新的布局之前,在顶部的蓝色矩形上调用这个方法,它可能会给我们带来什么。

blueItem.getBoundingClientRect();
// {
//   top: 0,
//   bottom: 600,
//   left: 0,
//   right: 500,
//   height: 60,
//   width: 400
// }

还有,在计算了新的布局之后。

blueItem.getBoundingClientRect();
// {
//   top: 136,
//   bottom: 464,
//   left: 0,
//   right: 500,
//   height: 60,
//   width: 400
// }

getBoundingClientRect很聪明,它可以计算出一个元素的新布局位置,同时考虑到它的高度、边距和其他会影响它在视口中位置的变量。

有了这两个数据,我们就可以计算出元素位置的变化;它的delta。

Δy = finalTop - initialTop = 132 - 0 = 132

所以,我们知道这个元素已经向下移动了132px。同样地,我们知道中间的元素根本没有移动(Δy = 0px),而最后一个元素向上移动了132px(Δy = -132px)。

问题是,当我们知道所有这些事实的时候,DOM即将更新;在一眨眼的时间里,这些盒子就会立刻出现在它们的新位置上!这就是我们的下一个工具。

这就是我们武器库中的下一个工具的作用:requestAnimationFrame

这是窗口对象上的一个方法,它告诉浏览器:"嘿,在你对屏幕做任何改变之前,你能先运行这段代码吗?"。这是一种在元素被更新之前快速做出任何调整的方法。

如果在浏览器画图之前,我们应用反向的变化,会怎么样?想象一下这个CSS。

.blue-item {
  top: -132px;
}
.purple-item {
  top: 0;
}
.fuscia-item {
  top: 132px;
}

浏览器会涂抹这个更新,但涂抹后不会有任何变化;DOM节点已经改变了位置,但我们已经用CSS抵消了这个变化。

这是很棘手的事情,所以让我们对刚刚发生的事情做一个高层次的概述。

  • React渲染了我们的初始状态,蓝色项目在上面。我们使用getBoundingClientRect来确定项目的位置。

  • React收到了新的道具:项目被颠倒了!现在蓝色项目在底部。现在蓝色项目在底部。

  • 我们使用getBoundingClientRect来计算出项目现在的位置,并计算出位置的变化。

  • 我们使用requestAnimationFrame来告诉DOM应用一些CSS来撤销这个新的变化;如果元素的新位置低了100px,我们就应用CSS来使它高100px。

动画时间到了

好了,我们在这里肯定已经完成了一些事情;我们已经使DOM的变化对用户来说是完全不可见的。这可能是一个整洁的聚会技巧,但可能仍然不清楚这对我们有什么帮助。

问题是,我们已经使它处于一种常规的CSS转换可以再次工作的情况。为了将这些元素动画化到它们的新位置,我们可以添加一个过渡,并撤消人为的位置变化。

继续我们上面的例子。我们的蓝色项目实际上是最后一个项目,但它看起来是第一个项目。它的CSS看起来像这样。

.blue-item {
  top: -132px;
}

现在,让我们更新一下CSS,使它看起来像这样。

.blue-item {
  transition: top 500ms;
  top: 0;
}

蓝色项目现在将向下滑动,超过半秒,从顶部位置到底部位置。欢呼吧!我们已经做了一些动画。

这种技术是由谷歌的保罗-刘易斯(Paul Lewis)推广的,他把它称为FLIP技术。FLIP是First,Last,Inverse,Play的缩写。

Guy doing lots of backflips so athletic

  • 计算第一个位置。

  • 计算最后的位置。

  • 反转这些位置

  • 播放动画

我们的版本有点不同,但原理是一样的。

对DOM的简单探索

在学习这种技术和编写我的模块时,我学到了不少关于DOM渲染的知识。虽然我学到的大部分内容都不在本文的讨论范围内,但有一个花絮我们应该快速浏览一下:绘画和合成的区别,以及它对选择硬件加速的CSS属性的影响。

最初,浏览器用CPU做一切事情。近年来,一些非常聪明的人发现,某些任务可以委托给GPU,以获得巨大的性能提升;特别是当静态内容的 "纹理 "没有变化时。

其主要目的是为了加快滚动速度;当你向下滚动一个页面时,没有任何元素发生变化,它们只是向上滑动。浏览器的人很好,他们也允许某些CSS属性以同样的方式工作。

通过使用CSS属性的转换套件--平移、缩放、旋转、倾斜等--和不透明度,我们并没有改变一个元素的质地。如果纹理不改变,它就不必在每一帧上重新绘制;它可以由GPU进行合成。这就是实现60+fps动画的方法。

如果你想了解更多关于浏览器的渲染过程(你应该了解!它既迷人又实用),我在下面提供了一些链接。

不过,在我们的案例中,这意味着我们应该使用transform而不是top

.blue-item {
  transition: transform 500ms;
  transform: translateY(0px);
}

缺少的部分:React

注意:这篇文章最初是在很久以前写的,特别是这一部分的代码已经不是很成熟了。所用的生命周期方法已经被废弃,ReactDOM.findDOMNode也被极力劝阻了。本节中的想法是可靠的,但请不要试图重复使用所提供的代码

React是如何融入这一切的?令人高兴的是,事实证明,React与这种技术配合得非常好。

每个孩子都需要两个重要的东西来实现这个功能。

  • 每个孩子都需要一个独特的 "key "属性。这就是我们用来区分它们的东西。

  • 每个孩子都需要一个ref,这样我们就能查找DOM节点并计算其边界框。

获取第一位置

每当组件收到新的道具时,我们需要检查是否有必要做动画。最早的机会是在componentWillReceiveProps生命周期方法中进行。

class ArticleList extends Component {
  componentWillReceiveProps() {
    this.props.children.forEach(child => {
      // Find the ref for this specific child.
      const ref = this.refs[child.key];
      // Look up the DOM node
      const domNode = ReactDOM.findDOMNode(ref);
      // Calculate the bounding box
      const boundingBox = domNode.getBoundingClientRect();
      // Store that box in the state, by its key.
      this.setState({
        [child.key]: boundingBox,
      });
    });
  }
}

在这个生命周期方法结束时,我们的状态将充满DOMRect对象,准确地勾勒出页面上每个孩子的位置。

获得最后的位置

下一个任务是弄清楚事物的位置。

这里要做的非常重要的区别是,**React的渲染方法并不立即画到屏幕上。**我对低级别的细节有点模糊,但这个过程看起来有点像这样。

  • render返回一个它希望DOM是什么样子的表示。

  • React将这个表示与DOM的实际状态相协调,并应用其中的差异。

  • 浏览器注意到有些东西已经改变,并计算新的布局。

  • React的componentDidUpdate生命周期方法启动了。

  • 浏览器将这些变化显示在屏幕上。

这个过程的美妙之处在于,我们有机会在DOM的布局被计算出来之后,但屏幕被更新之前,勾住DOM的状态。

下面是它的样子。

componentDidUpdate(previousProps) {
  previousProps.children.forEach(child => {
    let domNode  = ReactDOM.findDOMNode(this.refs[child.key]);
    const newBox = domNode.getBoundingClientRect();
    // ...more to come
  });
}

倒立

我们现在知道了第一个和最后一个位置,而且没有一毫秒的空闲!DOM就要更新了!"。DOM就要更新了!

我们将使用requestAnimationFrame来确保我们的变化在那一帧之前完成。

让我们继续编写componentDidUpdate方法。

componentDidUpdate(previousProps) {
  previousProps.children.forEach(child => {
    let domNode = ReactDOM.findDOMNode(this.refs[child.key]);
    const newBox = domNode.getBoundingClientRect();
    const oldBox = this.state[key];
    const deltaX = oldBox.left - newBox.left;
    const deltaY = oldBox.top  - newBox.top;
    requestAnimationFrame(() => {
      // Before the DOM paints, Invert it to its old position
      domNode.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
      // Ensure it inverts it immediately
      domNode.style.transition = 'transform 0s';
    });
  });
}

此时,在这个方法运行后,我们的DOM节点将被重新排列,但它们在屏幕上的位置将保持不变。爽啊!只剩下一个步骤了...

播放

componentDidUpdate(previousProps) {
  previousProps.children.forEach(child => {
    let domNode = ReactDOM.findDOMNode(this.refs[child.key]);
    const newBox = domNode.getBoundingClientRect();
    const oldBox = this.state[key];
    const deltaX = oldBox.left - newBox.left;
    const deltaY = oldBox.top  - newBox.top;
    requestAnimationFrame(() => {
      domNode.style.transform  = `translate(${deltaX}px, ${deltaY}px)`;
      domNode.style.transition = 'transform 0s';

哈!我们已经做到了;我们已经使非动画化了。

High five betwen Conan O'Brian and his assistant guy