[译] 动画无法动画化的元素

原文 Animating the Unanimatable

在 CSS3 transitions、@keyframe动画和即将推出的 Web Animations API 等新技术的帮助下,我们在构建Web动画方面拥有了更多的控制力。

然而,仍有一件事情是这些技术不能直接处理的;即列表重新排序的动画效果。

假设你有这样一个组件:

几个列表项正在重组,带有动画

1
2
3
4
5
6
7
8
9
10
11
class ArticleList extends Component {
render() {
return (
<div id="article-list">
{this.props.articles.map(article => (
<Article key={article.id} {...article} />
))}
</div>
);
}
}

我们有一个父级 ArticleList 组件,它以文章列表为props。 它按顺序映射它们并呈现它们。

如果列表顺序发生更改(例如:用户切换更改排序的设置,项目被点赞并更改位置,从服务器接收新数据…),React会协调两个状态,并更新 DOM,创建新节点,移动现有节点或销毁节点。

如果项目从其原始位置移除并重新插入到下方200px处,它对于元素的屏幕位置没有意识。

由于元素的CSS属性没有更改,因此无法使用CSS转换来动画显示此更改。

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

解决方案

TL:DR — 我制作了一个 React 模块来执行此操作。 来源| 演示

为了解决这个问题,我们需要一些特定时刻的信息。暂且不考虑获取这些信息的复杂性,我们假设:

  • 我们知道 React 只是重新渲染,DOM 节点已经重新排列。
  • 浏览器还没有绘制。尽管 DOM 节点处于新位置,但屏幕上的元素尚未更新。
  • 我们知道元素在屏幕上的位置。
  • 我们知道元素将在哪里重新绘制。

我们的情况可能是这样的:我们有一个包含 3 个项目的列表,它们只是被颠倒了。我们知道他们原来的位置(左侧),我们知道他们要移动到哪里(右侧)。

已重新排序的三个彩色矩形
请忽略我缺乏艺术能力

操作顺序

一个小插曲:你可能会惊讶地发现存在一个时间点,我们可以在某个时间点将项目绘制到屏幕上之前判断它的位置。

仔细想想,这是有道理的;浏览器如何在不知道确切绘制位置的情况下将新的像素绘制到屏幕上呢?

幸运的是,这不是黑匣子;浏览器以不同的步骤更新,可以在计算布局和绘制到屏幕之间执行逻辑。

但是,我们如何访问计算后的布局?

DOMRects 来拯救!

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

1
2
3
4
5
6
7
8
9
blueItem.getBoundingClientRect();
// {
// top: 0,
// bottom: 600,
// left: 0,
// right: 500,
// height: 60,
// width: 400
// }

并且,在计算新布局之后:

1
2
3
4
5
6
7
8
9
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

这是 window 对象上的一个方法,它告诉浏览器“嘿,在你对屏幕进行任何更改之前,你能先运行这段代码吗?”。这是一种在更新元素之前快速进行任何所需调整的方法。

如果在浏览器绘制之前,我们应用更改的函数怎么办?想象一下这个 CSS:

1
2
3
4
5
6
7
8
9
10
11
.blue-item {
top: -132px;
}

.purple-item {
top: 0;
}

.fuscia-item {
top: 132px;
}

浏览器会绘制此更新,但绘制不会改变任何东西;DOM 节点的位置发生了变化,但我们已经用 CSS 抵消了这种变化。

这是个有技巧的操作,让我们简要回顾一下刚才发生了什么:

  • React 渲染我们的初始状态,蓝色项目在顶部。我们使用 getBoundingClientRect 来确定项目的位置。
  • React 收到 props:项目已被撤销!现在蓝色项目在底部。
  • 我们使用 getBoundingClientRect 来确定项目现在的位置,并计算位置的变化。
  • 我们使用 requestAnimationFrame 告诉 DOM 应用一些 CSS 来撤销这个新的变化;如果元素的新位置低 100px,我们应用 CSS 使其高 100px。

到了动画时间了

好的,我们确实完成了某些事情;我们使得 DOM 的变化对用户完全不可见。这可能是一个很有趣的花招,但现在可能还不清楚它如何帮助我们。

问题是,我们现在处于一个情况下,普通的 CSS 过渡效果可以再次使用了。为了将这些元素动画到它们的新位置,我们可以添加一个过渡效果并撤消这些人为的位置更改。

继续使用我们上面的例子:我们的蓝色项实际上是最后一项,但它看起来像是第一项。它的 CSS 如下:

1
2
3
.blue-item {
top: -132px;
}

现在,让我们更新 CSS,使其看起来像这样:

1
2
3
4
.blue-item {
transition: top 500ms;
top: 0;
}

蓝色项目现在将在半秒内从顶部位置向下滑动到底部位置。万岁!我们已经动画化了一些东西。

这种技术由 Google 的 Paul Lewis 推广,他称之为 FLIP 技术。FLIP 是FirstLastI nverse、P lay 的首字母缩写词。

做很多后空翻的家伙如此运动

  • 计算第一个位置。
  • 计算最后位置。
  • 反转位置
  • 播放动画

我们的版本有些不同,但原理是相同的。

对DOM的简要探究

在学习这项技术和编写我的模块时,我学到了很多关于 DOM 渲染的知识。虽然我学到的大部分内容超出了本文的范围,但我们应该快速了解一下:绘画和合成之间的区别,以及它对选择硬件加速 CSS 属性的影响。

最初,浏览器通过 CPU 完成所有工作。近年来,一些非常聪明的人发现可以将某些任务委托给 GPU 以大幅提高性能;具体来说,当静态内容的“纹理”没有改变时。

主要目标是加快滚动速度;当您向下滚动页面时,没有任何元素发生变化,它们只是向上滑动。浏览器的人很友善,也允许某些 css 属性以相同的方式工作。

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

如果您想了解有关浏览器渲染过程的更多信息(您应该这样做!既有趣又实用),我在下面提供了一些链接。

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

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

缺失的部分:React

注意:这篇文章最初是很久以前写的,特别是这一部分的代码还没有很好地老化。使用的生命周期方法已被弃用,并且强烈建议不要使用 ReactDOM.findDOMNode。本节中的思路是可靠,但但请不要尝试重用提供的代码!

React 如何与这一技术结合?令人高兴的是,React 与这一技术完美契合。

每个子元素都需要两个重要的属性才能使其正常运作::

  • 每个子元素都需要一个唯一的”key”属性,这是我们用来区分它们的。
  • 每个子元素都需要一个 ref,这样我们就可以查找 DOM 节点并计算其边界框。

获取第一个位置

每当组件接收到新的 props,我们需要检查是否需要动画。最早的机会是在 componentWillReceiveProps 生命周期方法中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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,
});
});
}
}

在这个过程的结尾,我们的 state 将充满 DOMRect 对象,准确地描述每个子元素在页面上的位置。

获得最后位置

下一个任务是弄清楚事物的去向。

这里要做出的非常重要的区别是 React 的 *render* 方法不会立即绘制到屏幕上。我对底层细节有点模糊,但这个过程看起来有点像这样:

  • render返回它希望 DOM 成为什么的表示,
  • React 将这种表示与 DOM 的实际状态相协调,并应用差异,
  • 浏览器注意到有些东西发生了变化,并计算出新的布局,
  • React 的 componentDidUpdate 生命周期方法触发,
  • 浏览器将更改绘制到屏幕上。

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

下面是代码:

1
2
3
4
5
6
7
8
componentDidUpdate(previousProps) {
previousProps.children.forEach(child => {
let domNode = ReactDOM.findDOMNode(this.refs[child.key]);
const newBox = domNode.getBoundingClientRect();

// ...more to come
});
}

反转

我们现在知道第一个和最后一个位置,没有一毫秒的空闲时间!DOM 即将更新!

我们将使用 requestAnimationFrame 来确保我们的更改在该帧之前完成。

我们继续编写 componentDidUpdate 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 节点将重新排列,但它们在屏幕上的位置将保持不变。太棒了!只剩下最后一步了…

播放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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';

requestAnimationFrame(() => {
// In order to get the animation to play, we'll need to wait for
// the 'invert' animation frame to finish, so that its inverted
// position has propagated to the DOM.
//
// Then, we remove the transform, reverting it to its natural
// state, and apply a transition so it does so smoothly.
domNode.style.transform = '';
domNode.style.transition = 'transform 500ms';
});
});
});
}

哈!我们已经完成了,我们已经为不可动画的元素添加了动画效果。