一直很好奇坊间的一些
vue/react ui库的按钮弹窗的那些动画是怎么做到从按钮的方向弹射出来的,效果很让人惊叹,但是一直没去深究。后来无意中看到一种动画效果——叫FLIP,发现能完美实现前面的那些高大上的效果,遂去翻书,这个东西一开始看看得一头雾水,不知所云。后面自己小写了一些demo,渐渐地体会到其中的奥义。于是记录下来。

原理
FLIP 是一套动画思想和执行的流程规则,它表达的含义是:First,Last,Invert,Play。
- first——元素即将开始过渡动画之前的初始状态,即位置、尺寸信息
- last——元素的最终状态
- invert—— 计算出初始状态和最终状态的变化量,像宽度,高度,透明度这些。然后把这些状态量通通反转,并使用
transform的对应属性应用到元素上 - play——开启动画,把动画的结束状态设置为移除掉我们在
invert中设置了的transform的属性,和还原opacity属性。
看不懂文字?没关系,demo已经在路上了...
原生js使用FLIP
以下demo实现了按钮弹窗这么个小功能。演示地址(使用chrome)
<!--html-->
<div class="dialog-btn"></div>
<div class="dialog-wrapper">
<div class="dialog -large">
<h1>hello world!</h1>
</div>
</div>
// js
const wrapper = document.querySelector('.dialog-wrapper')
const dialog = wrapper.querySelector('.dialog')
const dialogBtn = document.querySelector('.dialog-btn')
dialogBtn.addEventListener('click', () => {
// first——获取运动元素的初始状态
const first = dialogBtn.getBoundingClientRect()
// last——触发运动元素到达最终状态
wrapper.style.display = 'block'
const last = dialog.getBoundingClientRect()
// invert——计算运动元素的变化量
const dx = first.x - last.x // x轴位移变化量
const dy = first.y - last.y // y轴位移变化量
const dw = first.width / last.width // 宽度变化量
const dh = first.height / last.height // 高度变化量
// play——触发运动元素开始运动
dialog.animate(
[
{
transform: `
translate(${dx}px, ${dy}px)
scale(${dw}, ${dh})
`,
opacity: 0.6
},
{
transform: `
translate(0, 0)
scale(1, 1)
`,
opacity: 1
}
],
{
duration: 6000,
easing: 'cubic-bezier(0.2, 0, 0.2, 1)'
}
)
}, false)
实现效果:

把上面的代码总结一下:
- 运动元素——模态框的初始尺寸获取的是按钮的尺寸,不一定是弹窗原本的css尺寸。可以看得出
flip真的是可以为所欲为,设置了什么初始状态,动画的初始状态就是什么。 - 获取last状态,就是直接通过css把模态框显示出来,通过
getBoundingClientRect来获取位置尺寸信息。 - invert阶段,因为在第二步中,元素当前已经是最终状态,而我们做动画的需求原本是让元素从初始状态动画过渡到最终状态,所以我们期望的是在第三步中把元素还原回初始状态,再在第下一步中触发动画。用 first - last ,得出差值,把差值应用到元素上,来让元素回到初始状态。举个🌰,初始状态
left为20px,最终状态left为100px。x轴位移变化量为20-100=-80px,此时我们设置transform: translateX(-80px)就能够让元素回到初始状态了。 - 触发补间动画,设置终态为
transform: translateX(0),由css3管理动画执行。
提出疑问
-
一套
F-L-I-P流程做动画带来什么优势?答: 说白了就是css3本身的优势。一是使用了浏览器本身的功能,只需要确定动画的开始和结束节点的位置尺寸信息,由渲染引擎自动完成补间动画。二是
transform和opacity属性本身就能触发gpu渲染,动画性能相当赞。如果自己操作js+dom,控制动画的工作量会相当大,而且一般还需要引入第三方库。 -
invert反转的意图是什么?答:用
flip方案做动画时,会加入一些transform属性,这些是额外加入的属性,会影响到我们原本的css布局,通过反转操作,最后把transform置为none,那么transform相关属性只会在过渡动画的生命周期里存在,动画结束时不再影响原来的dom的css布局。
如何在MVVM框架上使用FLIP
大家都知道
MVVM框架是数据驱动,对dom的操作一般没jQuery这种方便。而2020年了,MVVM地位举足轻重,如何在MVVM框架上集成也是一个大的课题,下面会以react为案例来探讨一下
确定 F-L-I-P
- F —— 如何获取 first 的位置?dom更新前的位置尺寸信息一般可以在
componentWillReceiveProps、componentWillUpdate这些生命周期函数里获取 - L —— 如何获取 last 的位置?对应的是dom更新后的钩子函数里,即
componentDidMount、componentDidUpdate或setStat的回调里 - I —— 通过 F 和 L 来计算差值。dom多次变化时,上一次的
last状态就是下一次的first状态了。所以需要记录每一次的dom变动,需要维护一个状态管理器。 - P —— 调用css3补间动画,参数来源于 I 步骤所得
实现按钮弹窗
不妨以上一个例子——按钮弹窗场景来小试牛刀 在线预览。从复用的角度来看,我们希望把动画的逻辑封装成单独的一个Component,这里我们定义为了Flipper,相关的flip逻辑将会放在这里面去。
根据flip法则:
- first —— 按钮的位置
firstRect,由父元素传递给Flipperclass App extends Component { state = { showDialog: false, firstRect: {}, } render () { return ( <div> <button ref={el => this.btnRef = el} onClick={this.onClick} >open dialog</button> { this.state.showDialog ? ( <div className="dialog-wrapper"> <Flipper duration={1000} firstRect={firstRect} > <Dialog key="dialog" close={this.close.bind(this)} /> </Flipper> </div> ) : null } </div> ) } componentDidMount () { <!--获取按钮的尺寸信息--> this.setState({ firstRect: this.btnRef.getBoundingClientRect() }) } } - last / invert / play —— 在这个例子中,做的是一个元素由无到有的进入的动画行为,在componentDidMount里,模态框插入到了document,此时就是模态框的last状态。在React Component中获取实际的dom结构,需要借用到一些React提供的顶层API。
class Flipper extends Component { state = { showDialog: false, firstRect: {}, } render () { return ( <> <!--这里需要为节点添加ref信息,以供在js里获取模态框dom服务。所以使用React.cloneElement加工this.props.children--> <!--{ this.props.children }--> { React.Children.map(this.props.children, node => { return React.cloneElement(node, { ref: node.key }); }) } </> ) } componentDidMount () { <!--开始f-l-i-p--> this.doFlip() } doFlip () { <!--first信息--> let first = this.props.firstRect if (!first) return; <!--last信息,this.props.children 代表<Dialog />组件,通过ReactDOM.findDOMNode获取dom节点 --> const dom = ReactDOM.findDOMNode(this.refs[this.props.children.key]) const last = dom.getBoundingClientRect() <!--inver信息--> const diffX = first.x - last.x const diffY = first.y - last.y if (!diffX && !diffY) return; <!--触发play--> const task = dom.animate( [ { opacity: 0, transform: `translate(${diffX}px, ${diffY}px)` }, { opacity: 1, transform: `translate(0, 0)` } ], { duration: +this.props.duration, easing: 'ease' } ) task.onfinish = () => { <!--补间动画结束--> } } }
总结:MVVM框架代表的是一种数据驱动的思想,但是我们做flip动画时,是直接操作dom的,并没有把css的相关属性作为state去管理。
实现列表的增删移位
实现效果:在线预览

本来以为按弹窗的逻辑补充一下就行了,然而写着写着就发现问题并没有想象中的那么简单。梳理出的问题如下:
-
列表存在多个动画元素,如何保存未知数量的动画元素的状态,如何对元素进行标识
进行动画的元素,都需要绑定一个
key props作为唯一标识,一来遵从React的使用规则,保证在动画过程中,只要保证key没被清除,dom就不被重新生成,避免动画信息丢失;二来我们需要为每个运动元素添加ref属性,借助key的值来设置较为方便。因此通过key来保存和索引元素信息。cacheRect也可以放在componentWillReceiveProps中调用,但只在props变更时触发,可以根据编码实际情况选择。cacheRect () { this.state.cloneChildren.forEach(node => { this.cacheRectData[node.key] = ReactDOM.findDOMNode(this.refs[node.key]).getBoundingClientRect() }) } componentWillUpdate () { <!--state和props更新时都会触发--> this.cacheRect() } -
元素存活周期比较长,不仅有进入动画,离开动画,还有因元素彼此之间相对位置变化而产生换位等动画,如何管理一个元素从进入-移动-离开整个生命周期期间的位置尺寸信息
flip的难点就是first和last的获取:- 进入动画,f是个性化设置的,l是在插入文档后通过
getBoundingClientRect获取,再去执行动画; - 移动动画,代表dom一直在文档结构中,可能有多次的flip动画,所以每次flip开始前要拿最新的f信息,f是dom更新前的状态,一般可在componentWillReceiveProps,componentWillUpdate中获取,l是更新后的状态,在componentDidUpdate中获取,再根据先前记录的first信息计算动画参数
重点注意:上面的说法针对的是不在执行动画过程中的元素。对于运动中的元素,由于元素`key`不变,无法通过重新渲染它的最终状态,而 getBoundingClientRect 获取的始终是当下的状态,所以需要通过 offsetTop、offsetLeft来计算,得出的值是忽略了transform相关值的影响的
- 离开动画,f也是在dom更新前拿到最新的数据,l也是个性化设置的。
诀窍 :总的来说,一般利用
getBoundingClientRect来获取firstlast信息。在要执行补间动画的地方,如果当下能获取first的状态,就要在之前保存好last的状态。如果当下能获取last的状态,就要在之前保存好first的状态,如果getBoundingClientRect不满足要求,就想想其他计算办法。 - 进入动画,f是个性化设置的,l是在插入文档后通过
-
元素被删除,dom马上消失了,消失的dom我无法做动画
是的,数据驱动下,数据一更新,dom立即更新。所以我们需要设置一个子组件的
this.state.children去代理父组件this.props.children,把props.children中删除的dom先继续放在state.children中,补间动画完成后再真正从文档中删除。 -
在列表中,操作过快时会出现一个问题,一个运动元素的当前动画还没完成,
last状态就变了,列表里其他数据的增/删/换位都可能引发这个结果。面对这种情况,需要及时修正运动轨迹,需要重置flip动画,才能保证动画的连贯性。这基本就是在
MVVM中实践flip的重点难点,也还是first和last的获取和设置问题。例如,删除元素后在执行动画的那一段时间里,其实我们需要保留该元素,这会导致元素占位,后面的兄弟元素无法取缔它的位置,所以我们要把待删除元素设置成
position: absolute及设置合理的left、top值,让其他元素能合理地过渡。另外,在离开动画进行中的元素不应该被多次触发删除的补间动画,需要提供状态进行判断条件。诀窍 :多做运动分解,拆分成x轴、y轴方向上的分析会清晰简单一些,跟学物理一样。
FLIP 使用注意事项
- 动画元素本身不能有
transform属性,因为会带来冲突。 - 由于使用的原理还是基于
transform,所以应用场景的边界也是无法超过css3的,具体来说,就是位移、缩放、opacity。 - 动画冲突问题,一个在animation的元素,如果你要再次修改它的animation,有什么办法?答案当然是结束当前动画,再重新设置动画。可是这个在各种场景下实践起来并没那么容易。所以在用户可以自己随意频繁触发重置动画的场景下,不好处理。
- 其实我这个实现也还不完美,无法在任何场景下使用,只是提供一种思路罢了,有想法可以多交流嘞。