《不怎么样的造轮子—FLIP动画》—新春限定

1,582 阅读10分钟

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

项目源码Git地址

原理

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)

实现效果:

把上面的代码总结一下:

  1. 运动元素——模态框的初始尺寸获取的是按钮的尺寸,不一定是弹窗原本的css尺寸。可以看得出flip真的是可以为所欲为,设置了什么初始状态,动画的初始状态就是什么。
  2. 获取last状态,就是直接通过css把模态框显示出来,通过getBoundingClientRect来获取位置尺寸信息。
  3. invert阶段,因为在第二步中,元素当前已经是最终状态,而我们做动画的需求原本是让元素从初始状态动画过渡到最终状态,所以我们期望的是在第三步中把元素还原回初始状态,再在第下一步中触发动画。用 first - last ,得出差值,把差值应用到元素上,来让元素回到初始状态。举个🌰,初始状态left为20px,最终状态left为100px 。x轴位移变化量为20-100=-80px,此时我们设置transform: translateX(-80px)就能够让元素回到初始状态了。
  4. 触发补间动画,设置终态为transform: translateX(0),由css3管理动画执行。

提出疑问

  • 一套F-L-I-P流程做动画带来什么优势?

    答: 说白了就是css3本身的优势。一是使用了浏览器本身的功能,只需要确定动画的开始和结束节点的位置尺寸信息,由渲染引擎自动完成补间动画。二是transformopacity属性本身就能触发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更新前的位置尺寸信息一般可以在 componentWillReceivePropscomponentWillUpdate这些生命周期函数里获取
  • L —— 如何获取 last 的位置?对应的是dom更新后的钩子函数里,即componentDidMountcomponentDidUpdatesetStat的回调
  • I —— 通过 FL 来计算差值。dom多次变化时,上一次的last状态就是下一次的first状态了。所以需要记录每一次的dom变动,需要维护一个状态管理器。
  • P —— 调用css3补间动画,参数来源于 I 步骤所得

实现按钮弹窗

不妨以上一个例子——按钮弹窗场景来小试牛刀 在线预览。从复用的角度来看,我们希望把动画的逻辑封装成单独的一个Component,这里我们定义为了Flipper,相关的flip逻辑将会放在这里面去。

根据flip法则:

  • first —— 按钮的位置 firstRect,由父元素传递给Flipper
    class 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的难点就是firstlast的获取:

    • 进入动画,f是个性化设置的,l是在插入文档后通过getBoundingClientRect获取,再去执行动画;
    • 移动动画,代表dom一直在文档结构中,可能有多次的flip动画,所以每次flip开始前要拿最新的f信息,f是dom更新前的状态,一般可在componentWillReceiveProps,componentWillUpdate中获取,l是更新后的状态,在componentDidUpdate中获取,再根据先前记录的first信息计算动画参数

      重点注意:上面的说法针对的是不在执行动画过程中的元素。对于运动中的元素,由于元素`key`不变,无法通过重新渲染它的最终状态,而 getBoundingClientRect 获取的始终是当下的状态,所以需要通过 offsetTop、offsetLeft来计算,得出的值是忽略了transform相关值的影响的

    • 离开动画,f也是在dom更新前拿到最新的数据,l也是个性化设置的。

    诀窍 :总的来说,一般利用getBoundingClientRect来获取first last信息。在要执行补间动画的地方,如果当下能获取first的状态,就要在之前保存好last的状态。如果当下能获取last的状态,就要在之前保存好first的状态,如果getBoundingClientRect不满足要求,就想想其他计算办法。

  • 元素被删除,dom马上消失了,消失的dom我无法做动画

    是的,数据驱动下,数据一更新,dom立即更新。所以我们需要设置一个子组件的this.state.children去代理父组件this.props.children,把props.children中删除的dom先继续放在state.children中,补间动画完成后再真正从文档中删除。

  • 在列表中,操作过快时会出现一个问题,一个运动元素的当前动画还没完成,last状态就变了,列表里其他数据的增/删/换位都可能引发这个结果。面对这种情况,需要及时修正运动轨迹,需要重置flip动画,才能保证动画的连贯性。

    这基本就是在MVVM中实践flip的重点难点,也还是firstlast的获取和设置问题。

    例如,删除元素后在执行动画的那一段时间里,其实我们需要保留该元素,这会导致元素占位,后面的兄弟元素无法取缔它的位置,所以我们要把待删除元素设置成position: absolute及设置合理的lefttop值,让其他元素能合理地过渡。另外,在离开动画进行中的元素不应该被多次触发删除的补间动画,需要提供状态进行判断条件。

    诀窍 :多做运动分解,拆分成x轴、y轴方向上的分析会清晰简单一些,跟学物理一样。

FLIP 使用注意事项

  • 动画元素本身不能有transform属性,因为会带来冲突。
  • 由于使用的原理还是基于transform,所以应用场景的边界也是无法超过css3的,具体来说,就是位移缩放opacity
  • 动画冲突问题,一个在animation的元素,如果你要再次修改它的animation,有什么办法?答案当然是结束当前动画,再重新设置动画。可是这个在各种场景下实践起来并没那么容易。所以在用户可以自己随意频繁触发重置动画的场景下,不好处理。
  • 其实我这个实现也还不完美,无法在任何场景下使用,只是提供一种思路罢了,有想法可以多交流嘞。

参考