FLIP技术给Web布局带来的变化

发布于 大漠

用户界面是最直观的呈现给用户,而其中动画在这方面又扮演着重要的角色。正如@Nick Babich所说:动画将用户界面带入生活。而且,UI动画在用户体验方面也是重要的一环。特别是移动端开发的同学更清楚这一点。移动应用中的UI动画是体验的核心部分之一,而不是事后的想法。可是呢?事实上我们经常遇到的Web运用程序或App只是从一个视图跳到另一个视图,并没有给用户足够多的时间来处理当前环境中刚刚发生的事情。同时,很多同学都认为,动画通常在用户体验方面仅仅是锦上添花,并且被认为是不必要的、过渡的或太复杂的,无法实现的,也是因为这些原因,UI动画在很多Web应用中被忽略了。更糟糕的是,缺乏意义的动画会给自己产品体验方面带来损害。

通常,我们使用的Web应用大多数都只是简单地从一个视图切换到另一个视图,导致用户体验不直观,但是我们可以通过技术的手段把这方面的交互行为做得更得体:

在创建UI时,添加合理的UI过渡动效,避免跳转瞬间移动。如果将生活中的一些自然运动用到UI动效中来,将会给你的用户带来眼前一亮的感觉。毕竟,所有与你互动的东西都源自于生活中自然的运动。

接下来,我们将一起探讨你可能熟悉的某一类有意义的增强用户体验的UI动效。这种技术有一个专业术语,即FLIP(First, Last, Invert, Play)。FLIP技术可以以一种高性能的方式来动态的改变DOM元素的位置和尺寸,而不需要管它的布局是如何计算或渲染的(比如,heightwidthfloat、绝对定位、Flexbox和Grid等)。在改变的过程中将赋予一定的动效,从而达到我们所需要的目的,让UI动效更为合理,相应增强用户的体验。

视图之间的过渡

在移动应用程序中从一个视图移动到另一个视图时,焦点元素通常会在视图之间从一个位置移动到另一个位置。比如下面的这些场景:

图片展开和收缩效果

项目删除和添加时填充空白区域的效果

网格项的重新排序

特别声明,以前录屏效果来源于@harrisfreddy分享的《F.L.I.P. LAYOUT ANIMATION》话题。其实类似于这种视图切换的效果还有很多,比如@Nick Babich博客中列举的一些示例效果

但是在实现这些动画时,有一个烦人的问题:如何在同一个项目中的两个视图或状态之间有一个流畅地动效?。引入FLIP技术,我们就可以解决这样的烦恼,在两种状态之间可以共享一个元素。

为什么是FLIP技术

你可以已经尝试过了改变元素的heightwidthtopleft或者除也transformopacity之外的其他属性来实现一个动画效果。或许你已经在以往的经历(做的动效)中发现自己的实现的动效有点粗糙甚至卡顿,其实这是有一定原因的。任何触发布局变化的属性(比如height),浏览器都会递归检查布局中的其他元素是否也因此改变,这样的一个过程花销是很贵的。如果这个计算所费的时间比一个动画帧(大约16.7ms)更长,那么动画就会丢帧,从而导致动画迟滞,给用户的体验是,你的动画是卡顿的,这主要是因为动画丢的帧没有及时渲染。@Paul Lewis的博客《像素是昂贵的》一文中深入地阐述了如何渲染像素以及各种性能的开销。

@Paul Irish和@Paul Lewis在HTML5 Rocks写的另一篇文章中也解释了如何获得高性能的动画。简单地说,如何利用管道确保动画达到60fps。也就是说,只有改变属性才能触发合成(Compositing)而不是布局(Layout)或绘制(Paint):

有关于这方面相关的知识不做过多的阐述,如果感兴趣的话,可以阅读下面几篇文章:

也就是说,如果要让动效更流畅,就要尽量的让样式计算的数量尽量的少,而且尽可能的快。关键是在动画中尽量的只使用transformopacity。只不过,只使用transformopacity是令人沮丧的。但幸运地是,FLIP可以让我们如何只使用transform来模拟布局变化。

FLIP是什么

FLIP是一种记忆设备和技术,最早是由@Paul Lewis提出的,FLIP是FirstLastInvertPlay四个单词首字母的缩写。@Paul Lewis的文章对这项技术做了很好的解释,这里简单的陈述一下:

First,指的是在任何事情发生之前(过渡之前),记录当前元素的位置和尺寸。可以使用getBoundingClientRect()这个API来处理,比如:

// 获取当前元素的边界
const first = el.getBoundingClientRect() 

getBoundingClientRect()将会返回一个DOMRect对象,如下图所示:

Last:执行一段代码,让元素发生相应的变化,并记录元素在最后状态的位置和尺寸,比如:

// 通过给元素添加一个类名,设置元素最后状态的位置和大小 (在.totes-at-the-end中添加相应的样式规则)
// 布局发生了变化
el.classList.add('totes-at-the-end')

// 记录元素最后状态的位置和尺寸大小
const last = el.getBoundingClientRect()

Invert:计算元素第一个位置(first)和最后一个位置(last)之间的位置变化(如果需要,还可以计算两个状态之间的尺寸大小的变化),然后使用这些数字做一定的计算,让元素进行移动(通过transform来改变元素的位置和尺寸),从而创建它位于第一个位置(初始位置)的一个错觉:

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:将元素反转(假装在first位置),我们可以把transform设置为none,将其移动到last位置,让元素有动画效果:

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'
    }
);

注意:在Play中使用了Web Animations API。如果从未接触过相关的API,建议花点时间点击这里进行了解

把所有代码合在一起,如下:

// 获取当前元素的边界
const first = el.getBoundingClientRect() 

// 通过给元素添加一个类名,设置元素最后状态的位置和大小 (在.totes-at-the-end中添加相应的样式规则)
// 布局发生了变化
el.classList.add('totes-at-the-end')

// 记录元素最后状态的位置和尺寸大小
const last = el.getBoundingClientRect()

const deltaX = first.left - last.left
const deltaY = first.top - last.top
const deltaW = first.width / last.width
const deltaH = first.height / last.height

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'
    }
);

为了便于大家更好的理解FLIP技术制作的动效原理,借用下图向大家展示,或许更易于理解:

加上最后一个过程Play,实现的动画效果如下图所示:

特别声明:上面两张图来自于@davidkpiano在2017年的CSS Dev Conf分享的《FLIP ping out about animated layouts》话题中。

再给大家展示一个列表重排的效果吧:

共享元素的过渡

在App应用中,视图的过渡和元素不同状态的过渡效果是一个非常常见的效果,其最基本的原则就是元素初始状态和最终状态不同。在Android中,这类似于共享元素转换,只不过元素在DOM中不会像在Android中那样从一个视图“回收”到另一个视图。

接下来我们来看一个FLIP的过渡效果:

特别声明,上面的示例来自于@David KhourshidCodepen上写的示例

简单的说明一下其计算过程:

// 首先获取点被点击图片的位置和尺寸
let firstRect = itemImage.getBoundingClientRect();

// 然后获取详情的位置和尺寸
let lastRect = detailItem.getBoundingClientRect();

// 详情到图片的动效
detailItem.animate(
    [
        {
            transform: `
            translateX(${firstRect.left - lastRect.left}px)
            translateY(${firstRect.top - lastRect.top}px)
            scale(${firstRect.width / lastRect.width})
            `
        },
        {
            transform: `
            translateX(0)
            translateY(0)
            scale(1)
            `
        }
    ], 
    {
        duration: 600,
        easing: 'cubic-bezier(0.2, 0, 0.2, 1)'
    }
);

// 详情的位置和尺寸
let itemImageRect = itemImage.getBoundingClientRect();

// 原图片的位置和尺寸
let detailItemRect = detailItem.getBoundingClientRect();

// 详情回到图片的动效
itemImage.animate(
    [
        {
            zIndex: 2,
            transform: `
            translateX(${detailItemRect.left - itemImageRect.left}px)
            translateY(${detailItemRect.top - itemImageRect.top}px)
    scale(${detailItemRect.width / itemImageRect.width})
            `
        },
        {
            zIndex: 2,
            transform: `
            translateX(0)
            translateY(0)
            scale(1)
            `
        }
    ], 
    {
        duration: 600,
        easing: 'cubic-bezier(0.2, 0, 0.2, 1)'
    }
);

整个计算过程用下图来阐述,将更会清晰一些:

父子元素的过渡

上面我们看到的示例,元素的边界是基于window的。对于大多数用例,这是可以的,但考虑一下这样的场景:

  • 元素改变位置,需要一个过渡效果
  • 该元素包含一个子元素,该子元素本身需要过渡到父元素中的不同位置

由于之前计算的边界是相对于window的,我们的子元素的计算将会关闭。为了解决这个问题,我们需要确保这些边界是相对于父元素计算的:

// 获取父元素
const parentElm = document.querySelector('.parent');

// 获取子元素
const childElm = document.querySelector('.parent > .child');

// First: 父元素和子元素的初始位置和尺寸
const parentFirst = parentElm.getBoundingClientRect();
const childFirst = childElm.getBoundingClientRect();

doSomething();

// Last: 父元素和子元素最后位置和尺寸
const parentLast = parentElm.getBoundingClientRect();
const childLast = childElm.getBoundingClientRect();

// Invert: 父元素开始和结束位置
const parentDeltaX = parentFirst.left - parentLast.left;
const parentDeltaY = parentFirst.top - parentLast.top;

// Invert: 子元素相对于父元素位置 
const childDeltaX = (childFirst.left - parentFirst.left) - (childLast.left - parentLast.left);
const childDeltaY = (childFirst.top - parentFirst.top) - (childLast.top - parentLast.top);

// Play: 使用 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' 
    }
);

这里有几点需要注意:

  • 父元素和子元素的计时选项(比如durationeasing等)不一定需要与些技术匹配。可以自由发挥你的创造力
  • 上面的示例中省略了更改父元素或子元素的尺寸(widthheight
  • 可以把父元素和子元素组合在一起,成为一个共享元素,然后再使用FLIP,可以更灵活

flipping.js

FLIP技术原理,上面应该阐述地差不多了。如果你不想花更多的时间去了解怎么使用或者更深层次的相关技术,而是仅仅希望在项目中使用该技术,那么可以考虑@Davidkpiano写的一个库:flipping.js。有关于这个库怎么使用,这里不做过多的阐述。如果感兴趣可以阅读Github仓库中提供的说明文档。下面这个示例就是使用flipping.js实现的:

扩展阅读

另外还有几份相关的PPT,推荐阅读: