使用 FLIP 技术为布局添加动画

Avatar of David Khourshid
David Khourshid

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

当用户界面直观易懂时,它们才能最有效。动画在其中起着重要作用 - 正如 Nick Babich 所说,动画让用户界面栩栩如生。但是,添加有意义的过渡和微交互通常是事后才考虑的事情,或者只有在时间允许的情况下才将其视为“锦上添花”。我们经常会遇到这样的 Web 应用程序,它们只是从一个视图“跳”到另一个视图,而没有给用户时间去处理当前上下文中刚刚发生的事情。

这会导致非直观的用户体验,但我们可以做得更好,避免在创建 UI 时出现“跳切”和“瞬移”。毕竟,有什么比现实生活更自然呢?现实生活中,没有什么是瞬移的(也许除了钥匙),而且你与之交互的任何东西都是以自然的运动移动的。

在本文中,我们将探索一种名为“FLIP”的技术,它可以用于以高性能的方式为任何 DOM 元素的定位和尺寸添加动画,而不管它们的布局是如何计算或渲染的(例如,高度、宽度、浮动、绝对定位、变换、Flexbox、网格等)。

为什么使用 FLIP 技术?

你有没有尝试过为 heightwidthtopleft 或除 transformopacity 之外的任何其他属性添加动画?你可能已经注意到,动画看起来有点“卡顿”,这是有原因的。当任何触发布局更改的属性(例如 height)发生变化时,浏览器必须递归地检查是否有任何其他元素的布局因此而发生了更改,这可能很昂贵。如果该计算花费的时间超过一帧动画(约 16.7 毫秒),则动画帧将被跳过,导致“卡顿”,因为该帧没有及时渲染。在 Paul Lewis 的文章 “像素很昂贵” 中,他更深入地解释了像素的渲染方式以及各种性能开销。

简而言之,我们的目标是尽可能快地完成操作 - 我们希望尽可能快地计算出所需的最小样式更改。关键是只为 transformopacity 添加动画,而 FLIP 解释了如何仅使用 transform 来模拟布局更改。

什么是 FLIP?

FLIP 是一个 由 Paul Lewis 首创 的助记词和技术,代表 **F**irst(首)、**L**ast(尾)、**I**nvert(反转)、**P**lay(播放)。他的文章对该技术进行了详细的解释,但我会在这里概述一下。

  • **首:**在任何事情发生之前,记录将要过渡的元素的当前(即首)位置和尺寸。你可以使用 element.getBoundingClientRect() 来完成此操作,如下所示。
  • **尾:**执行导致过渡立即发生的代码,并记录元素的最终(即尾)位置和尺寸。
  • **反转:**由于元素处于尾部位置,我们希望使用 transform 修改其位置和尺寸,从而营造出它处于首部位置的错觉。这需要一些数学运算,但并不太难。
  • **播放:**将元素反转(并假装处于首部位置)后,我们可以通过将其 transform 设置为 none,将其移回其尾部位置。

以下是使用 Web Animations API 实现这些步骤的方法。

const elm = document.querySelector('.some-element');

// First: get the current bounds
const first = elm.getBoundingClientRect();

// execute the script that causes layout change
doSomething();

// Last: get the final bounds
const last = elm.getBoundingClientRect();

// Invert: determine the delta between the 
// first and last bounds to invert the element
const deltaX = first.left - last.left;
const deltaY = first.top - last.top;
const deltaW = first.width / last.width;
const deltaH = first.height / last.height;

// Play: animate the final element from its first bounds
// to its last bounds (which is no transform)
elm.animate([{
  transformOrigin: 'top left',
  transform: `
    translate(${deltaX}px, ${deltaY}px)
    scale(${deltaW}, ${deltaH})
  `
}, {
  transformOrigin: 'top left',
  transform: 'none'
}], {
  duration: 300,
  easing: 'ease-in-out',
  fill: 'both'
});

**注意:**在撰写本文时,Web Animations API 尚未在所有浏览器中得到支持。但是,你可以 使用 polyfill

查看 David Khourshid (@davidkpiano) 在 CodePen 上创建的 Pen FLIP 技术的工作原理

有两点需要注意

  1. 如果元素的大小发生了变化,你可以变换 scale 以“调整大小”,而不会影响性能;但是,请确保将 transformOrigin 设置为 'top left',因为我们就是在此处基于我们的增量计算。
  2. 我们在这里使用 Web Animations API 来为元素添加动画,但你可以随意使用任何其他动画引擎,例如 GSAP、Anime、Velocity、Just-Animate、Mo.js 等等。

共享元素过渡

在元素之间过渡应用程序视图和状态的常见用例之一是,最终元素可能与初始元素不是同一个 DOM 元素。在 Android 中,这类似于 共享元素过渡,不同之处在于元素不会像在 Android 中那样从 DOM 的一个视图“回收”到另一个视图。
尽管如此,我们仍然可以通过一些“魔法”来实现 FLIP 过渡。

const firstElm = document.querySelector('.first-element');

// First: get the bounds and then hide the element (if necessary)
const first = firstElm.getBoundingClientRect();
firstElm.style.setProperty('visibility', 'hidden');

// execute the script that causes view change
doSomething();

// Last: get the bounds of the element that just appeared
const lastElm = document.querySelector('.last-element');
const last = lastElm.getBoundingClientRect();

// continue with the other steps, just as before.
// remember: you're animating the lastElm, not the firstElm.

以下是一个示例,展示了两个完全不同的元素如何使用共享元素过渡来显示为同一个元素。点击其中一张图片即可查看效果。

查看 David Khourshid (@davidkpiano) 在 CodePen 上创建的 Pen 使用 WAAPI 的 FLIP 示例

父级-子级过渡

在之前的实现中,元素边界基于 window。对于大多数用例来说,这很好,但请考虑以下场景。

  • 一个元素改变了位置,需要过渡。
  • 该元素包含一个子元素,该子元素本身也需要过渡到父元素内部的不同位置。

由于之前计算的边界相对于 window,因此我们对子元素的计算将不正确。为了解决这个问题,我们需要确保边界相对于父元素计算。

const parentElm = document.querySelector('.parent');
const childElm = document.querySelector('.parent > .child');

// First: parent and child
const parentFirst = parentElm.getBoundingClientRect();
const childFirst = childElm.getBoundingClientRect();

doSomething();

// Last: parent and child
const parentLast = parentElm.getBoundingClientRect();
const childLast = childElm.getBoundingClientRect();

// Invert: parent
const parentDeltaX = parentFirst.left - parentLast.left;
const parentDeltaY = parentFirst.top - parentLast.top;

// Invert: child relative to parent
const childDeltaX = (childFirst.left - parentFirst.left)
  - (childLast.left - parentLast.left);
const childDeltaY = (childFirst.top - parentFirst.top)
  - (childLast.top - parentLast.top);
  
// Play: using the WAAPI
parentElm.animate([
  { transform: `translate(${parentDeltaX}px, ${parentDeltaY}px)` },
  { transform: 'none' }
], { duration: 300, easing: 'ease-in-out' });

childElm.animate([
  { transform: `translate(${childDeltaX}px, ${childDeltaY}px)` },
  { transform: 'none' }
], { duration: 300, easing: 'ease-in-out' });

这里还有一些需要注意的事项。

  1. 父元素和子元素的计时选项(durationeasing 等)不必使用这种技术进行匹配。你可以随意发挥创意!
  2. 在这个例子中,我们有意省略了父级和/或子级中尺寸的改变(widthheight),因为这是一个高级且复杂的话题。我们将在另一篇教程中讲解。
  3. 你可以将共享元素和父级-子级技术结合起来,以获得更大的灵活性。

使用 Flipping.js 以获得完全的灵活性

上述技术可能看起来很简单,但一旦你必须跟踪多个过渡元素,编码就会变得非常繁琐。Android 通过以下方式减轻了这种负担。

  • 将共享元素过渡烘焙到核心 SDK 中。
  • 允许开发人员使用通用的 android:transitionName XML 属性来标识哪些元素是共享的。

我创建了一个名为 Flipping.js 的小型库,其理念相同。通过在 HTML 元素中添加 data-flip-key="..." 属性,可以以可预测且高效的方式跟踪可能会从一个状态到另一个状态改变位置和尺寸的元素。

例如,请考虑以下初始视图。

    <section class="gallery">

<div class="photo-1" data-flip-key="photo-1">
        <img src="/photo-1">
</div>


<div class="photo-2" data-flip-key="photo-2">
        <img src="/photo-2">
</div>


<div class="photo-3" data-flip-key="photo-3">
        <img src="/photo-3">
</div>

    </section>

以及以下独立的详细信息视图。

    <section class="details">

<div class="photo" data-flip-key="photo-1">
        <img src="/photo-1">
</div>

      
        Lorem ipsum dolor sit amet...
      
    
</section>

请注意,在上面的示例中,有两个元素具有相同的 data-flip-key="photo-1"。Flipping.js 通过选择满足以下条件的第一个元素来跟踪“活动”元素。

  • 元素存在于 DOM 中(即它没有被删除或分离)。
  • 元素没有被隐藏(提示:对于隐藏的元素,elm.getBoundingClientRect() 将具有 { width: 0, height: 0 })。
  • selectActive 选项中指定的任何自定义逻辑。

Flipping.js 入门

Flipping 有几个不同的包,具体取决于你的需求。

  • flipping.js:小巧且低级;只有在元素边界发生改变时才会发出事件。
  • flipping.web.js:使用 WAAPI 来为过渡添加动画。
  • flipping.gsap.js:使用 GSAP 来为过渡添加动画。
  • 更多适配器即将推出!

你可以直接从 unpkg 获取压缩后的代码。

或者,你也可以使用 npm install flipping --save 并将其导入到你的项目中。

// import not necessary when including the unpkg scripts in a <script src="..."> tag
import Flipping from 'flipping/adapters/web';

const flipping = new Flipping();

// First: let Flipping read all initial bounds
flipping.read();

// execute the change that causes any elements to change bounds
doSomething();

// Last, Invert, Play: the flip() method does it all
flipping.flip();

由于对函数调用产生的 FLIP 过渡进行处理是一种非常常见的模式,因此 .wrap(fn) 方法通过首先调用 .read(),然后获取函数的返回值,然后调用 .flip(),最后返回返回值,来透明地包装(或“装饰”)给定的函数。这将减少很多代码。

const flipping = new Flipping();

const flippingDoSomething = flipping.wrap(doSomething);

// anytime this is called, FLIP will animate changed elements
flippingDoSomething();

以下是如何使用flipping.wrap()轻松实现字母平移效果的示例。点击任何地方查看效果。

查看 CodePen:Flipping Birthstones #Codevember,由 David Khourshid (@davidkpiano) 在 CodePen 上创建。

将 Flipping.js 添加到现有项目

另一篇文章 中,我们使用有限状态机创建了一个简单的 React 画廊应用程序。它按预期工作,但用户界面可以使用一些状态之间的平滑过渡来防止“跳跃”并改善用户体验。让我们将 Flipping.js 添加到我们的 React 应用程序中来实现这一点。(请记住,Flipping.js 是与框架无关的。)

步骤 1:初始化 Flipping.js

Flipping 实例将驻留在 React 组件本身,以便它隔离到仅发生在该组件内的更改。通过在componentDidMount 生命周期钩子中设置它来初始化 Flipping.js。

  componentDidMount() {
    const { node } = this;
    if (!node) return;
    
    this.flipping = new Flipping({
      parentElement: node
    });
    
    // initialize flipping with the initial bounds
    this.flipping.read();
  }

通过指定parentElement: node,我们告诉 Flipping 只在渲染的App中查找具有data-flip-key 的元素,而不是整个文档。
然后,用data-flip-key 属性(类似于 React 的key 属性)修改 HTML 元素以识别唯一和“共享”元素。

  renderGallery(state) {
    return (
      <section className="ui-items" data-state={state}>
        {this.state.items.map((item, i) =>
          <img
            src={item.media.m}
            className="ui-item"
            style={{'--i': i}}
            key={item.link}
            onClick={() => this.transition({
              type: 'SELECT_PHOTO', item
            })}
            data-flip-key={item.link}
          />
        )}
      </section>
    );
  }
  renderPhoto(state) {
    if (state !== 'photo') return;
    
    return (
      <section
        className="ui-photo-detail"
        onClick={() => this.transition({ type: 'EXIT_PHOTO' })}>
        <img
          src={this.state.photo.media.m}
          className="ui-photo"
          data-flip-key={this.state.photo.link}
        />
      </section>
    )
  }

请注意img.ui-itemimg.ui-photo 如何分别由data-flip-key={item.link}data-flip-key={this.state.photo.link} 表示:当用户点击img.ui-item 时,该item 会被设置为this.state.photo,因此.link 值将相等。

并且由于它们相等,Flipping 将从img.ui-item 缩略图平滑过渡到较大的img.ui-photo

现在我们还需要做两件事。

  1. 当组件将要更新时,调用this.flipping.read()
  2. 当组件更新时,调用this.flipping.flip()

你们中的一些人可能已经猜到这些方法调用将发生在哪里:分别在componentWillUpdate 和 componentDidUpdate 中。

  componentWillUpdate() {
    this.flipping.read();
  }
  
  componentDidUpdate() {
    this.flipping.flip();
  }

就这样,如果您使用的是 Flipping 适配器(例如flipping.web.jsflipping.gsap.js),Flipping 将跟踪所有具有[data-flip-key] 的元素,并在它们发生变化时平滑地将它们过渡到新边界。以下是最终结果。

查看 CodePen:FLIPping Gallery App,由 David Khourshid (@davidkpiano) 在 CodePen 上创建。

如果您希望自己实现自定义动画,可以使用flipping.js 作为简单的事件发射器。 阅读文档 以了解更高级的用例。

Flipping.js 及其适配器默认处理共享元素和父子过渡,以及

  • 中断的过渡(在适配器中)
  • 进入/移动/离开状态
  • 对插件的支持,例如mirror,它允许新进入的元素“镜像”另一个元素的移动
  • 以及将来计划的更多功能!

资源

类似的库包括

  • FlipJS,由 Paul Lewis 本人创建,它处理简单的单个元素 FLIP 过渡
  • React-Flip-Move,一个由 Josh Comeau 创建的有用的 React 库
  • BarbaJS,不一定是 FLIP 库,但它允许您在不同 URL 之间添加平滑过渡,而不会出现页面跳转。

更多资源