如果让你实现下面的这种动画效果你会怎么做?

FLIP动画实现思路_前端

可能很多人第一想法就是使用绝对定位进行布局,当顺序发生变化后,计算出变化后的位置,然后通过动画过渡到指定位置。这是一种很常见的实现方式,但存在几个问题:

  • 需要维护每个节点的位置信息
  • 顺序变化后,需要计算每个DOM的目标位置
  • 使用绝对定位的方式,每行显示的小方块个数是固定的,不能自适应容器的变化。

过程分析

无论多么复杂的动画,都可以拆解成多个动画的组合。对于上面的效果,就可以看成是每个小方块的变化,这里只涉及到了位置的变化,当然还可能存在大小、颜色等变化。

FLIP动画实现思路_前端_02

从整个动画过程提取几个最重要的信息:

  • 开始状态信息:黄绿色、宽50px、高50px、位置坐标0,0
  • 结束状态信息:橙色、宽50px、高50px、位置坐标100,0
  • 发生变化的属性:
  • 颜色:黄绿色=》橙色
  • 位置:left 0 => 100

要实现上面的过渡效果,我们通常可以使用 animationtransitionrequestAnimationFrame 来实现。下面就用代码来实现上面的效果。

animation
@keyframes identifier {
    from {
        left:100px;
        background: yellowgreen;
    }
    to {
        left:400px;
        background: rgb(255, 123, 0);
    }
}

.animation-dom {
    position: absolute;
    display: inline-block;
    height: 50px;
    width: 50px;
    background: yellowgreen;
    animation: identifier 3s infinite;
    -webkit-animation:identifier 3s infinite;
}
transition

使用 transition 设置属性变化时具有过渡效果,为开始和结束状态创建两个不同的类名,分别设置其状态的属性。通过改变类名来实现过渡效果。

.transition-dom {
    transition: all 1s;
    &.start {
        left: 0;
        background: yellowgreen;
    }
    &.end {
        left: 100px;
        background: rgb(255, 123, 0);
    }
}

requestAnimationFrame

多用于受控属性的控制,如位置、大小等信息。这种方式不太适用于一般的动画效果开发,需要自己去计算每个时刻的状态,并且对颜色这种过度无能为力。

FLIP

FLIP 分别是 FirstLastInvertPlay 四个单词的缩写;

First

元素的起始状态,例如位置、大小、形状、颜色等信息

Last

元素运动后的终止状态

Invert

元素的变化过程,也就是最终状态相对于其实状态,有哪些属性发生了改变。例如:位置向右移动了100px,颜色从黄色变为了橙色。将元素所有发生了变化的属性全部统计出来。

Play

执行动画过程,将所有发生变化的属性,从其实状态过渡到结束状态。可以设置过渡的时间、过渡方式等。可以通过上述的几种方式来实现这个过程。

实现思路

为了使得我们的动画灵活性更高,开发成本更低,首先就排除掉使用 绝对定位 的方式。如果不通过计算的方式,我们如何知道动画结束时元素的属性呢?

这里就要提出一个很重要的知识点:DOM元素的属性发生变化时,会被集中收集到浏览器的下一帧进行统一渲染。也就是说会存在这么一个时间段,DOM的元素属性已经发生改变,而浏览器还没来得及渲染,此时我们依然是可以拿到DOM更新后的属性的

知道了元素的终止状态,就可以来实现过渡动画了。最好是使用 animation 的方式来实现,好处是不会在DOM元素上添加任何的 CSS。

FLIP动画实现思路_css_03

具体实现

React 为例

// 以此列表进行循环渲染的数据源
const [dataList, setDataList] = useState<any[]>([0, 1, 2, 3, 4, 5]);
// 容器元素
const wrapperRef = useRef<HTMLDivElement>(null);

return (
    <div className='flip-demo'>
        <Space>
            <Button>
                新增
            </Button>
            <Button>
                乱序
            </Button>
        </Space>
        <div className='list' ref={wrapperRef}>
            {dataList.map(item => (
                <div
                    key={item}
                    className="item"
                    >
                    {item}
                </div>
            ))}
        </div>
    </div>
);
.list {
    display: flex;
    flex-wrap: wrap;
    width: 550px;
}

.item {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 80px;
    height: 80px;
    border: 1px solid #eee;
}

第一步:记录元素的起始状态

将每个元素的起始状态进行记录保存,这里建议使用 Map 来进行存储,有两个好处:

  • 方便取值,不像数组那样必须要保证顺序。
  • 可以使用 DOM 节点作为 KEY 值,即使 DOM 的属性发生了变化,DOM 的引用是不会变的。
// 用于存储最后一次状态
const lastRectRef = useRef<Map<HTMLElement, DOMRect>>(new Map());


// 将容器下面所有的子元素存储到MAP中
function createChildrenElementMap(wrapperNode: HTMLElement | null) {
    if (!wrapperNode) {
        return new Map();
    }
    // 获取到所有的子元素
    const childNodes = Array.from(wrapperNode.childNodes) as HTMLElement[];
    // 原元素作为KEY,其属性值作为VALUE
    const result = new Map(childNodes.map(node => [node, node.getBoundingClientRect()]));
    return result;
}

// 只要 dataList 发生了变化,就需要更新状态
useEffect(() => {
    const currentRectMap = createChildrenElementMap(wrapperRef.current);
	lastRectRef.current = currentRectMap;
}, [dataList]);

知识点: 一个n * 2 的二位数组,将其转为 Map 时,数组的 arr[n][0] 会作为Map 的key , arr[n][1] 为相应的值。

第二步:实现新增和乱序功能

新增和乱序比较简单,就是改变数据源而已。

import { shuffle } from 'lodash';

// 乱序,使用lodash的shuffle方法
function handleShuffle() {
    setDataList(shuffle);
}

// 添加
function addItem() {
    setDataList((list) => [list.length, ...list]);
}

第三步:获取最终状态,计算变化属性值,执行动画

本案例中我们明确知道只有元素的位置信息发生了变化。

useLayoutEffect(() => {
    // 这里获取到的是最新的状态信息,也就是最终状态
    const currentRectMap = createChildrenElementMap(wrapperRef.current);
    // 对保存的上次状态的元素进行遍历
    lastRectRef.current.forEach((prevNode, node) => {
        // 由于DOM的属性变化后其引用是不会发生改变的,因此可以在currentRectMap中获取到其最终状态
        const currentRect = currentRectMap.get(node);
        // 计算位置信息的变化
        const invert = {
            left: prevNode.left - currentRect?.left,
            top: prevNode.top - currentRect?.top,
        };
        // 设置动画过程
        const keyframes = [
            {
                transform: `translate(${invert.left}px, ${invert.top}px)`
            },
            {
                transform: `translate(0, 0)`
            }
        ];
        // 执行动画
        node.animate(keyframes, {
            duration: 800,
            easing: 'cubic-bezier(0.25, 0.8, 0.25, 1)',
        })
    });
    lastRectRef.current = currentRectMap;
}, [dataList]);

动画调试

FLIP动画实现思路_前端_04

我们在开发动画时,为了追求更好的过渡效果,就需要更加深入的分析动画的整个过程。但往往动画的过程都是比较快的,肉眼很难看到细节。

既然太快了,我们就把动画的时间调慢些,慢到我们可以看到为止。这种方式的确很直接很实用,但是需要手动的去修改代码,调试完成后还需要手动改回来。

浏览器的开发者工具支持动画的调试,具体位置如下图:

FLIP动画实现思路_javascript_05

点击 10% 可以将动画的过程放慢10倍,并且可以在下方看到所有发生了动画的元素。

FLIP动画实现思路_javascript_06

FLIP动画实现思路_javascript_07

点击快照列表可以重复执行动画,在界面中也会重新执行。

FLIP动画实现思路_css3_08

你甚至可以拖动下面的时间线,来单独控制某个元素的执行时间:

FLIP动画实现思路_css_09

FLIP动画实现思路_css3_10

下载此文:https://www.dengzhanyong.com/resource
个人网站:www.dengzhanyong.com
关注公众号【前端筱园】,不错过每一篇文章