【需要了解的FLIP】FLIP动画在 react中的应用

本文介绍了FLIP动画在React中的应用,探讨了如何利用动画提升用户体验,并详细阐述了FLIP动画的工作流程,包括first和Last阶段、Invert和Play步骤。文章通过代码实例解析了如何利用requestAnimationFrame和useLayoutEffect实现平滑过渡,同时指出了快速添加项时可能出现的问题和优化方向。
摘要由CSDN通过智能技术生成

本文翻译自 FLIP animation in react

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-a4YHrSjn-1604052655260)(https://github.com/aholachek/react-flip-toolkit/raw/master/example-assets/listanimations.gif)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-21L53RZZ-1604052655262)(https://github.com/aholachek/react-flip-toolkit/raw/master/example-assets/list-transition.gif)]

背景

动画将用户界面带入生活。有意设计动画也有助于可用性、减少了用户的认知负荷,并引导用户关注重要的东西。

问题与解决方案

你可能已经尝试过了改变元素的 height、width、top、left 或者除也 transform 和 opacity 之外的其他属性来实现一个动画效果。或许你已经在以往的经历(做的动效)中发现自己的实现的动效有点粗糙甚至卡顿,其实这是有一定原因的。任何触发布局变化的属性(比如 height),浏览器都会递归检查布局中的其他元素是否也因此改变,这一个过程花销是很贵的,如果这个计算比一个动画帧(16.7ms)更长,那么动画就会丢帧。

我们需要在更改 DOM 元素位置和尺寸后浏览器渲染预前计算绘制需要发生的转换,然后让它平稳运行。为了实现上述过渡,我们需要通过boundingClintRect获取到元素的初始和最终状态,并使用 CSS transform让过渡

步骤

对上述过程进行简短说明:

first and Last

Fist是我们转换之前的元素,Last是我们转换之后想要得到的。

我们需要存储元素的getboundingClientRect并调整大小并其重新放置到目标位置。

Invert

这个阶段在我们需要在浏览器布局之后,浏览器绘制之前。我们可以根据getboundingClientRect计算出差值。

Play

最后,我们让元素移动到 0,0位置即可。 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hqxqBt6E-1604052655268)(https://makersden.io/d6ac541c409723ebb01771cd8ca95d57/play.gif)]

此时我们可以采用如下技术实现: - 使用 CSS transition - 使用 CSS keyframes + animation - 使用 Web Animation API - 使用 Anime.js

代码实现

live demo

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-z7C04Z8W-1604052655269)(https://makersden.io/c02746907fe0da4cdb1836084899fbaf/nolib.gif)]

整体代码如下:

import React, { useLayoutEffect, useRef, useState } from 'react';

interface ViewProps {
  items: any[];
}

function List({ items }: ViewProps) {
  const listRef = React.createRef<HTMLUListElement>();
  useFlip({
    root: listRef,
    invert,
    play,
  });
  return (
    <ul ref={listRef}>
      {items.map((item) => (
        <li data-key={item} key={item}>
          {item}
        </li>
      ))}
    </ul>
  );
}

interface FlipProps {
  root: React.RefObject<HTMLUListElement>;
  invert: any;
  play: any;
}

interface Rect {
  top: number;
  left: number;
  width: number;
  height: number;
}

const useFlip = ({ root, invert, play }: FlipProps) => {
  const origins = useRef<{ [key: string]: ClientRect }>({});
  const firstRun = useRef(true);
  useLayoutEffect(() => {
    if (root.current === null) return;
    const list = root.current;
    const children: HTMLElement[] = Array.prototype.slice.call(list.children);

    for (const child of children) {
      const key = child.dataset.key!;

      const next = child.getBoundingClientRect();
      if (!firstRun.current) {
        if (key in origins.current) {
          const previous = origins.current[key];
          const delta = getDelta(previous, next);
          if (!isZero(delta)) {
            invertAndPlay(delta, child);
            // invert(delta, child);
            // requestAnimationFrame(() => {
            //     play(child)
            // })
          }
        }
      }
      origins.current[key] = next;
    }
    firstRun.current = false;
  }, [root, invert, play]);
};

const getDelta = (start: Rect, target: Rect) => ({
  top: start.top - target.top,
  left: start.left - target.left,
  width: start.width / target.width,
  height: start.height / target.height,
});

const isZero = (delta: Rect) =>
  delta.left === 0 && delta.top === 0 && delta.width === 1 && delta.height === 1;

const invert = (delta: ClientRect, elem: HTMLElement) => {
  elem.style.transform = `translate(${delta.left}px,${delta.top}px)`;
  elem.style.transition = `transform 0s`;
};

const play = (elem: HTMLElement) => {
  elem.style.transform = ``;
  elem.style.transition = `transform 300ms ease`;
};

const invertAndPlay = (delta: Rect, elem: HTMLElement) => {
  elem.animate(
    [
      {
        transform: `translate(${delta.left}px,${delta.top}px)`,
      },
      {
        transform: 'none',
      },
    ],
    { duration: 300 },
  );
};

use List

import React, { useLayoutEffect, useRef, useState } from 'react';
import Mock from 'mockjs';
import { Button, Space } from 'antd';

function shuffle([...arr]: any[]) {
  let m = arr.length;
  while (m) {
    const i = Math.floor(Math.random() * m);
    m -= 1;
    [arr[m], arr[i]] = [arr[i], arr[m]];
  }
  return arr;
}

export default () => {
  const [items, setItems] = useState<string[]>([]);
  const handleAddClick = () => {
    setItems((items) => [Mock.mock('@name'), ...items]);
  };
  const handleRemoveClick = () => {
    setItems((items) => items.slice(1));
  };
  const handleShuffleClick = () => {
    setItems((items) => shuffle(items));
  };
  return (
    <>
      <Button type="primary" onClick={handleAddClick}>
        add Item
      </Button>{' '}
      <Space size="middle" />
      <Button type="primary" onClick={handleRemoveClick}>
        remove Item
      </Button> <Space size="middle" />
      <Button type="primary" onClick={handleShuffleClick}>
        shuffle Item
      </Button>
      <List items={items} />
    </>
  );
};

代码分解

First

负责初始化,当第一次运行时 origins记录 list每一项。

const origins = useRef<{ [key: string]: ClientRect }>({});

const list = root.current;
const children: HTMLElement[] = Array.protoype.slice.call(list.children);

for (const child of children) {
  const key = child.dataset.key!;
  const next = child.getBoundingClientRect();
  origins.current[child.dataset.key!] = next;
}

我依靠元素data-key属性标识。

<li data-key={item} key={item}>
  {item}
</li>

这里使用了 useRef是为了记录行项目更新的同时,自身是不变的这样。这样当生命周期更新时不会重新创建。 文档描述:

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变。

Last

负责计算最后元素位置属性

for (const child of children) {
  const next = child.getBoundingClientRect();
}

Invert

负责计算反演的代码。

for (const child of children) {
  const key = child.dataset.key!;
  const next = child.getBoundingClientRect();

  if (key in origins.current) {
    const previous = origins.current[key];
    const delta = getDelta(previous, next);
    if (!isZero(delta)) {
      invert(delta, child);
    }
  }
}

计算差值

const getDelta = (start: Rect, target: Rect) => ({
  top: start.top - target.top,
  left: start.left - target.left,
  width: start.width / target.width,
  height: start.height / target.height,
});

const isZero = (delta: Rect) =>
  delta.left === 0 && delta.top === 0 && delta.width === 1 && delta.height === 1;

对于这个案例 这里使用 csstranform来进行动画。

const invert = (delta: ClientRect, elem: HTMLElement) => {
  elem.style.transform = `translate(${delta.left}px,${delta.top}px)`;
  elem.style.transition = `transform 0s`;
};

Play

最后的最后让我们启动

requestAnimationFrame(() => {
  play(child);
});

使用 CSS transform 反转到初始位置。

const play = (elem: HTMLElement) => {
  elem.style.transform = ``;
  elem.style.transition = `transform 300ms ease`;
};

技术讲解

requestAnimationFrame

注意 play 步骤的 requestAnimationFrame调用 - 我们需要这样做,以便浏览器可以处理应用于 DOM 的反转。如果你使用 CSS transition技术则需要它。

如果我们使用 Web Animation API 则不需要它,例如这样:

const invertAndPlay = (delta: Rect, elem: HTMLElement) => {
  elem.animate(
    [
      {
        transform: `translate(${delta.left}px,${delta.top}px)`,
      },
      {
        transform: 'none',
      },
    ],
    { duration: 300 },
  );
};

useLayoutEffect

React hooks documentation:

其函数签名与 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。尽可能使用标准的 useEffect 以避免阻塞视觉更新

因为我们需要在浏览器绘制之前,读取到 DOM 布局后的信息,所以使用它再合适不过了。

避免重复渲染

这取决与个人喜好,但通常我不喜欢在第一次渲染时进行过渡。对我来说,动画是增强体验的,而不是放慢你去获取到想要的信息。除非你需要一个漂亮的开场动画(登录页面)

为了避免首次渲染,使用 ref作为开关。

let firstRun = useRef(true);

useLayoutEffect(() => {
  const children: HTMLElement[] = Array.protoype.slice.call(list.children);

  for (const child of children) {
    if (!firstRun.current) {
      /* do the flips */
    }
  }

  firstRun.current = false;
}, [root, invert, play]);

代码问题

当我们快速添加项时,会出现卡顿乱序情况。如果想让他可靠,那我们我们还需要做一些处理。但是这样会复杂很多… [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AxTaff4T-1604052655271)(https://makersden.io/1a5f4a2314234f7241cfb7d3e5b57ca4/nolib-fast.gif)]

结论

如果我们想要探究技术,这个 demo 可以带你了解技术本身。如果想要生产中使用那还是直接使用react-flip-toolkit或者 react-flip-move

link

FLIP your animations 怎样才能实现更改的过渡? 👍

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值