FLIP技术给Web布局带来的变化
用户界面是最直观的呈现给用户,而其中动画在这方面又扮演着重要的角色。正如@Nick Babich所说:动画将用户界面带入生活。而且,UI动画在用户体验方面也是重要的一环。特别是移动端开发的同学更清楚这一点。移动应用中的UI动画是体验的核心部分之一,而不是事后的想法。可是呢?事实上我们经常遇到的Web运用程序或App只是从一个视图跳到另一个视图,并没有给用户足够多的时间来处理当前环境中刚刚发生的事情。同时,很多同学都认为,动画通常在用户体验方面仅仅是锦上添花,并且被认为是不必要的、过渡的或太复杂的,无法实现的,也是因为这些原因,UI动画在很多Web应用中被忽略了。更糟糕的是,缺乏意义的动画会给自己产品体验方面带来损害。
通常,我们使用的Web应用大多数都只是简单地从一个视图切换到另一个视图,导致用户体验不直观,但是我们可以通过技术的手段把这方面的交互行为做得更得体:
在创建UI时,添加合理的UI过渡动效,避免跳转和瞬间移动。如果将生活中的一些自然运动用到UI动效中来,将会给你的用户带来眼前一亮的感觉。毕竟,所有与你互动的东西都源自于生活中自然的运动。
接下来,我们将一起探讨你可能熟悉的某一类有意义的增强用户体验的UI动效。这种技术有一个专业术语,即FLIP(First, Last, Invert, Play)。FLIP技术可以以一种高性能的方式来动态的改变DOM元素的位置和尺寸,而不需要管它的布局是如何计算或渲染的(比如,height
、width
、float
、绝对定位、Flexbox和Grid等)。在改变的过程中将赋予一定的动效,从而达到我们所需要的目的,让UI动效更为合理,相应增强用户的体验。
视图之间的过渡
在移动应用程序中从一个视图移动到另一个视图时,焦点元素通常会在视图之间从一个位置移动到另一个位置。比如下面的这些场景:
图片展开和收缩效果
项目删除和添加时填充空白区域的效果
网格项的重新排序
特别声明,以前录屏效果来源于@harrisfreddy分享的《F.L.I.P. LAYOUT ANIMATION》话题。其实类似于这种视图切换的效果还有很多,比如@Nick Babich的博客中列举的一些示例效果。
但是在实现这些动画时,有一个烦人的问题:如何在同一个项目中的两个视图或状态之间有一个流畅地动效?。引入FLIP技术,我们就可以解决这样的烦恼,在两种状态之间可以共享一个元素。
为什么是FLIP技术
你可以已经尝试过了改变元素的height
、width
、top
、left
或者除也transform
和opacity
之外的其他属性来实现一个动画效果。或许你已经在以往的经历(做的动效)中发现自己的实现的动效有点粗糙甚至卡顿,其实这是有一定原因的。任何触发布局变化的属性(比如height
),浏览器都会递归检查布局中的其他元素是否也因此改变,这样的一个过程花销是很贵的。如果这个计算所费的时间比一个动画帧(大约16.7ms
)更长,那么动画就会丢帧,从而导致动画迟滞,给用户的体验是,你的动画是卡顿的,这主要是因为动画丢的帧没有及时渲染。@Paul Lewis的博客《像素是昂贵的》一文中深入地阐述了如何渲染像素以及各种性能的开销。
@Paul Irish和@Paul Lewis在HTML5 Rocks写的另一篇文章中也解释了如何获得高性能的动画。简单地说,如何利用管道确保动画达到60fps
。也就是说,只有改变属性才能触发合成(Compositing)而不是布局(Layout)或绘制(Paint):
有关于这方面相关的知识不做过多的阐述,如果感兴趣的话,可以阅读下面几篇文章:
也就是说,如果要让动效更流畅,就要尽量的让样式计算的数量尽量的少,而且尽可能的快。关键是在动画中尽量的只使用transform
和opacity
。只不过,只使用transform
和opacity
是令人沮丧的。但幸运地是,FLIP可以让我们如何只使用transform
来模拟布局变化。
FLIP是什么
FLIP是一种记忆设备和技术,最早是由@Paul Lewis提出的,FLIP是First、Last、Invert和Play四个单词首字母的缩写。@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 Khourshid在Codepen上写的示例。
简单的说明一下其计算过程:
// 首先获取点被点击图片的位置和尺寸
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'
}
);
这里有几点需要注意:
- 父元素和子元素的计时选项(比如
duration
、easing
等)不一定需要与些技术匹配。可以自由发挥你的创造力 - 上面的示例中省略了更改父元素或子元素的尺寸(
width
和height
) - 可以把父元素和子元素组合在一起,成为一个共享元素,然后再使用FLIP,可以更灵活
flipping.js
FLIP技术原理,上面应该阐述地差不多了。如果你不想花更多的时间去了解怎么使用或者更深层次的相关技术,而是仅仅希望在项目中使用该技术,那么可以考虑@Davidkpiano写的一个库:flipping.js
。有关于这个库怎么使用,这里不做过多的阐述。如果感兴趣可以阅读Github仓库中提供的说明文档。下面这个示例就是使用flipping.js
实现的:
扩展阅读
- Animating Layouts with the FLIP Technique
- Animating the Unanimatable - Joshua Comeau
- FLIP your Animations - Paul Lewis
- Pixels are Expensive - Paul Lewis
- Improving User Flow Through Page Transitions - Luigi de Rosa
- Smart Transitions in User Experience Design - Adrian Zumbrunnen
- What Makes a Good Transition? - Nick Babich
- Motion Guidelines in Google's Material Design
- Shared Element Transition with React Native
- Time to FLIP - High Performance Animations
- FLIP Your 60 FPS Animations,FLIP ’Em Good
- Create animations with the FLIP technique
另外还有几份相关的PPT,推荐阅读: