教你怎么更优雅的去实现交互过渡动效

941 阅读6分钟

前言

对我们前端来说,用户体验是我们在开发面向客户的应用时必须做好的一点,怎样实现良好的交互过渡特效也是我们需要掌握的一个技术。笔者用过vuereact,这两个框架都为我们开发应用时处理UI的过渡动画提供了组件,我们不需要关心底层的实现,只需要简单配置组件的api就能解决绝大部分的过渡场景。但是我最近在开发一个组件时,需要一些特别的过渡动画,这些组件功能就有点局限,所以我学习实践了一下上述组件使用到的过渡技术,FLIP

为啥要用FLIP

举个例子,假如现在页面上存在4个标签[tag1, tag2, tag3, tag4],我现在需要把新增一个标签tag5,会变成[tag5, tag1, tag2, tag3, tag4],我想要让一开始存在的4个标签可以有整体向右移动的过渡动画,你有什么思路?

如果是没有学过FLIP之前的我,我会考虑给这4个标签都加一个过渡的css,然后改变transform让这些标签向右移动一段距离,或者使用js去移动这些标签向右移动。

这种方式存在几个比较麻烦的问题,向右移动一段距离,如果每个元素的宽高都一致,计算这个距离还好,但是如果每个元素的宽高都不一致,那这个距离怎么计算?如果一行排满了,还需要换行,高度怎么计算?还有一个问题,我们现在用的前端框架都是使用数据来控制视图,什么时候去把这个新的tag1加入到数据中?

先加数据,再控制标签移动,页面会重新渲染,标签的位置会变化,这时候再控制标签向右移动其实位置已经错误了。

先移动,再加数据的话,如果是css控制的移动,按过渡时间来,经常会因为js的定时不精准出现跳帧的现象。如果是js控制的移动,一般需要等待所有元素的移动回调完成再去加数据。

FLIP实现原理

FLIP其实是四个单词的缩写,FirstLastInvertPlay。我不太喜欢在掘文里写具体的概念,大家来看博客都是为了学技术,具体概念啥的有兴趣的就自行去了解一下吧,我在这里主要介绍一下FLIP的实现的思路。

FLIP其实是一个反向的实现过渡思路,一般的过渡思路是我需要把一个元素从A点移动到B点,我就需要一点点修改这个元素的transform,让它到达B点。而FLIP是直接让这个元素到达B点的位置,然后计算A点和B点的坐标的距离,设置transform,让它从A点移动到translate(0px, 0px),也就是自身的位置。

FLIP的大体流程是

  • 1.记录原来的dom节点的坐标
  • 2.数据更改,修改dom页面(新增,删除,重新排序)
  • 3.记录新的dom点的坐标
  • 4.根据新和老的坐标,得出两个坐标之间的距离
  • 5.给已经重新渲染后的dom添加初始样式,让它有类似过渡的动画

预览

我仿照vue官网的过渡组件中的例子,写了一个示例的页面。因为FLIP只是一种实现动画的思想,跟框架无关,所以我在demo页面中是使用的原生js

123.gif

DEMO页面

实现细节

这里介绍我写的这个示例页面的实现过程以及细节。首先是布局,布局很简单,就是一个flex容器。

.item_box {
    display: flex;
    flex-wrap: wrap;
    ...
}
.item {
    ...
}

// 数据列表,每次修改页面元素,都会同步修改这个数据
const itemList = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
// 初始化,渲染item列表
const itemInit = () => {
  const fragment = document.createDocumentFragment();
  for (let i = 0; i < itemList.length; i ++) {
    const dom = document.createElement('div');
    // 这里的后一个item是为了后续找到相应的dom元素
    dom.className = `item item${itemList[i]}`;
    dom.innerHTML = itemList[i];
    fragment.appendChild(dom);
  }
  document.querySelector('.item_box').appendChild(fragment);
};

itemInit();

我们拿新增元素来举例,先实现FLIP的第一步,记录原来的dom节点的坐标

const getLeftOrTops = () => {
  const rectList = [];
  // 遍历数据列表
  for (let i = 0; i < itemList.length; i ++) {
    // 计算这些节点的left和top数据
    const { left, top } = document.querySelector(`.item${itemList[i]}`).getBoundingClientRect()
    rectList.push({ left, top });
  }
  return rectList;
};

然后进行第二步,数据更改,修改dom页面,以及第三步,记录新的dom点的坐标。

let count = 10;
// 新增dom节点的方法
const itemAdd = () => {
  // 这是记录原来的dom节点坐标的方法 
  const oldRects = getLeftOrTops();
  const curIndex = count++;
  // 给数据列表新推入一个元素
  itemList.unshift(curIndex);
  // 在页面中插入这个新节点,修改dom页面
  $box.insertBefore(createItem(curIndex), $box.childNodes[0]);
  // 第三步,记录新的dom点的坐标
  // 获取新的数据列表中dom节点的坐标
  // 新加入的dom节点不需要添加过渡条件,其他新旧dom节点需要计算新旧坐标的差
  const newRects = getLeftOrTops().slice(1);
};

这里有一个比较关键的点,如果在vuereact中使用需要注意,就是页面dom修改和获取新的dom节点的坐标的顺序。如果在vue中,修改dom页面其实就是修改数据,vue会把这个修改页面dom的操作存入nextTick,等到当前宏任务运行完,再去微任务中运行。所以,获取新的dom节点的坐标的方法,也必须写在nextTick中,保证会在dom元素修改完成之后再去获取(获取时页面其实还未重新渲染,但是dom节点已经被修改)。在react中推荐使用useLayoutEffect在数据变更,dom修改之后去获取新的dom节点的坐标。

然后我们继续FLIP的流程

const itemAdd = () => {
   // 上面的内容省略
   ...
    for (let i = 0; i < oldRects.length; i ++) {
        // 根据新和老的坐标,得出两个坐标之间的距离
        const left = oldRects[i].left - newRects[i].left;
        const top = oldRects[i].top - newRects[i].top;
        const move = [
          { transform: `translate(${left}px, ${top}px)` },
          { transform: "translate(0)" },
        ];
        const dom = document.querySelector(`.item${oldRects[i].key}`);
        // 这里使用web api的animate方法,让元素移动
        // animate的兼容可以使用polyfill,或者使用其它的js动画库
        // 给已经重新渲染后的dom添加初始样式,让它有类似过渡的动画
        dom && dom.animate(move, {
          duration: 300,
          easing: "cubic-bezier(0,0,0.4,1)",
        });
      }
};

就这样,我们就是完成了FLIP的整个流程,你的新增元素已经有了好看的过渡动画。

新增,删除,重新排列的完整代码都在上面的示例页面中,大家有兴趣可以细看。

总结

FLIP只是一种实现过渡动画的思路,不但在上文这种文档流场景下可以使用,它在绝对定位的布局中依然可以用。而且它是一种非常灵活的方法,并不是只局限于本文中的内容,在实现一些交互动效的时候,可以多思考看看能否使用FLIP来更方便跟好的帮助你解决问题。

感谢

如果本文对你有所帮助,请帮忙点个赞,感谢大家!