乐趣区

关于前端:这是一篇很好的互动式文章Framer-Motion-布局动画

微信搜寻【大迁世界】, 我会第一工夫和你分享前端行业趋势,学习路径等等。
本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试残缺考点、材料以及我的系列文章。

重现 framer 的神奇布局动画的指南。

到目前为止,我最喜爱 Framer Motion 的局部是它神奇的布局动画 – 将 layout prop 拍在任何静止组件上,看着该组件从页面的一个局部无缝过渡到下一个局部。

<motion.div layout />

在这篇文章中,咱们次要介绍:

  • 布局变动:它们是什么,何时产生。
  • 基于 CSS 的办法以及为什么它们并不总是无效。
  • FLIP:是 Framer Motion 应用的技术。

布局变动

当页面上的一个元素影响其余元素扭转地位时,就会产生布局变动。例如,扭转一个元素的宽度或高度就是一种布局变动,因为任何相邻的元素都必须挪动,以便为该元素的新尺寸腾出空间。

同样,扭转元素的 justify-content 属性也是一种布局变动,因为它导致该元素的子元素扭转地位。

不过,像 scale 属性的变动并不是布局的扭转,因为它的变动不影响页面上的其余元素。

用 CSS 做动画

那么,咱们如何将布局变动做成动画呢?一种办法是间接应用 CSS 过渡使属性产生动画:

.square {transition: width 0.2s ease-out;}

当初,当 square 扭转宽度时,它将在其大小之间无缝动画化:

// Motion.js
import React from 'react'
import './styles.css'

export default function Motion({toggled}) {return <div className={`active ${toggled ? 'toggled' : ''}`} />
}

style.css

.active {border: 1px solid hsl(208, 77.5%, 76.9%);
  background: hsl(209, 81.2%, 84.5%);
  width: 120px;
  height: 120px;
  border-radius: 8px;
  transition: width 0.5s ease-out;
}

.toggled {width: 200px;}

看上去,CSS 也能够做动画,但它有两个次要的毛病:

  • 不能把所有货色都做成动画 。例如,不能对justify-content 的变动制作动画,因为 justify-content 不是一个可动画的属性。
  • 性能问题。波及布局变动的 CSS 动画通常比基于 transform 的动画更低廉,所以你可能会发现你的动画在低端设施上不那么晦涩。

咱们先来看看性能问题。

性能

  • 不要事后优化 如果在低端设施上没有留神到任何性能问题,而且 CSS transition 对你无效,那么就不要放心!只有在须要时才进行优化。

波及布局变动的 CSS 动画通常比其余 CSS 动画更低廉,因为它影响到四周的其余元素。这是因为浏览器必须在动画的每一帧中从新计算页面的布局 – 对于一个 60FPS 的动画来说,这意味着每秒钟要计算 60 次!

回顾下面动画。留神到灰色的盒子看起来也在做动画,只管咱们只过渡了蓝色的盒子:

产生这种状况的起因是,每次蓝框的尺寸发生变化时,浏览器都会从新计算灰框的地位。

另一方面,浏览器能够更快地对 transform 等 CSS 属性进行动画解决,因为它们不影响布局。

留神,随着蓝色方框的增长,灰色方框保持原状!

所以,如果 transform 的动画老本更低,咱们是否能够用 transform 来代替布局变动?

是的,能够!

FLIP

FLIP 是 First, Last, Inverse, Play 的缩写,它是一种技术,能够让咱们应用 “ 疾速 ” 的 CSS 属性(如 transform)对 “slow” 的布局变动制作动画。FLIP 甚至能够对 “ 不可动画 ” 的属性(如justify-content)进行动画解决。Framer Motion 应用 FLIP 来实现其布局动画。

顾名思义,FLIP 是一种四步技术,它通过颠倒浏览器所做的任何布局变动来工作。咱们通过动画演示 justify-contentflex-startflex-end 的变动来弄清楚它是如何工作的。

First

First 中,在任何布局变动产生之前,测量咱们要做动画的元素的地位:

获取元素地位的一种办法是应用 HTML 元素的 .getBoundingClientRect() 办法:

const Motion = (props) => {const ref = React.useRef();
  React.useLayoutEffect(() => {const { x, y} = ref.current.getBoundingClientRect();}, []);
  return <div ref={ref} {...props} />;
};

Last

Last 这一步中,咱们测量布局变动后元素的地位:

为了在代码中实现这一点,咱们首先假如布局的扭转意味着组件刚刚从新渲染了。所以咱们先从 useEffect 钩子中删除依赖数组,使钩子每次渲染都能运行。

试着触发几次布局变动,查看控制台,看看显示的 xy值是什么。

App.js

import React from 'react'
import Motion from './Motion'
import './styles.css'

export default function App() {const [toggled, toggle] = React.useReducer(state => !state, false)

  return (
    <div id="main">
      <button onClick={toggle}>Toggle</button>
      <div id="wrapper" style={{justifyContent: toggled ? 'flex-end' : 'flex-start'}}>
        <Motion />
      </div>
    </div>
  )
}

Motion.js

import React from 'react'

export default function Motion() {const squareRef = React.useRef()

  React.useLayoutEffect(() => {const box = squareRef.current?.getBoundingClientRect()
    if (box) {console.log(box.x, box.y) }
  })

  return <div id="motion" ref={squareRef} />
}

Inverse

inverse 阶段,咱们批改正方形的地位,使其看起来像是基本没有挪动过。要做到这一点,咱们要比拟咱们所做的两个测量,并计算出一个 transform,而后利用到正方形上。

应用 React 实现的代码:

App.js

import React from 'react'
import Motion from './Motion'
import './styles.css'

export default function App() {const [toggled, toggle] = React.useReducer(state => !state, false)

  return (
    <div id="main">
      <button onClick={toggle}>Toggle</button>
      <div id="wrapper" style={{justifyContent: toggled ? 'flex-end' : 'flex-start'}}>
        <Motion />
      </div>
    </div>
  )
}

Motion.js

import React from 'react'

export default function Motion() {const squareRef = React.useRef();
  const initialPositionRef = React.useRef();

  React.useLayoutEffect(() => {const box = squareRef.current?.getBoundingClientRect();
    if (moved(initialPositionRef.current, box)) {
      // get the difference in position
      const deltaX = initialPositionRef.current.x - box.x;
      const deltaY = initialPositionRef.current.y - box.y;
      console.log(deltaX, deltaY);

      // apply the transform to the box
      squareRef.current.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
    }
    initialPositionRef.current = box;
  });
  
  return <div id="motion" ref={squareRef} />;
}

const moved = (initialBox, finalBox) => {
  // we just mounted, so we don't have complete data yet
  if (!initialBox || !finalBox) return false;

  const xMoved = initialBox.x !== finalBox.x;
  const yMoved = initialBox.y !== finalBox.y;

  return xMoved || yMoved;
}

Play

到目前为止,咱们有一个正方形,它被施加了一个 transform,在按下切换键后没有挪动。

在 FLIP 的最初一步,即 Play 步骤中,咱们将这个 transform 动画化为零,让正方形动画化到它的最终地位。

有多种办法能够实现这个动画;我集体抉择应用 Popmotion 的 animate 函数。

import React from 'react'
import {animate} from 'popmotion'

export default function Motion() {const squareRef = React.useRef();
  const initialPositionRef = React.useRef();

  React.useLayoutEffect(() => {const box = squareRef.current?.getBoundingClientRect();
    if (moved(initialPositionRef.current, box)) {
      // get the difference in position
      const deltaX = initialPositionRef.current.x - box.x;
      const deltaY = initialPositionRef.current.y - box.y;

      // inverse the change using a transform
      squareRef.current.style.transform = `translate(${deltaX}px, ${deltaY}px)`;

      // animate back to the final position
      animate({
        from: 1,
        to: 0,
        duration: 2000,
        onUpdate: progress => {
          squareRef.current.style.transform = 
            `translate(${deltaX * progress}px, ${deltaY * progress}px)`;
        }
      })
    }
    initialPositionRef.current = box;
  });
  
  return <div id="motion" ref={squareRef} />;
}

const moved = (initialBox, finalBox) => {
  // we just mounted, so we don't have complete data yet
  if (!initialBox || !finalBox) return false;

  const xMoved = initialBox.x !== finalBox.x;
  const yMoved = initialBox.y !== finalBox.y;

  return xMoved || yMoved;
}

把所有货色放在一起

把所有步骤做起来,咱们失去:

动画的大小

到目前为止,咱们只用 FLIP 来制作地位变动的动画。但对于大小来说,咱们能够用同样的办法吗咱们试着复制上面的动画,在这个动画中,正方形被拉伸到充斥整个容器。

测量尺寸变动

咱们首先要测量布局扭转前后的正方形的大小。碰巧是提,咱们用来测量正方形的 .getBoundingClientRect() 办法也刚好返回元素的 widthheight:

const {width, height} = squareRef.current.getBoundingClientRect();

反转尺寸变动

为了反转尺寸变动,咱们将用最终尺寸除以初始尺寸:

const deltaWidth = box.width / initialBoxRef.current.width;

失去一个比例后,咱们能够将其传递给 scale 属性:

squareRef.current.style.transform = `scaleX(${deltaWidth})`;

咱们不会像 position 那样将比例动画到0,而是将比例动画到1(如果咱们将比例动画到 0,元素将齐全隐没):

animate({
  from: deltaWidth,
  to: 1,
  // ...
});

应用 position 固定大小

到目前为止,咱们曾经可能应用 FLIP 为地位和大小的变动制作动画。当咱们试图将大小和地位都做成动画时会产生什么?

嗯,这看起来有点不对劲。这里产生了什么?如果咱们在 play 步骤之前暂停动画,咱们能够看到在 inverse 转步骤中出了问题 – 正方形没有齐全与它的原始地位对齐:

修复转换的终点

咱们试着搞清楚这个问题。

当咱们把地位和大小的变动联合起来时,咱们在逆向步骤中进行了两个独立的变换 – 平移和缩放。如果咱们独自看一下这些变换,咱们就能够晓得这个正方形是如何完结的:

咱们的算法首先将最终地位的左上角与原始地位的左上角对齐,而后将其放大到初始尺寸。

缩放变换仿佛是这里的罪魁祸首 – 它从正方形的核心开始缩放,导致正方形最终呈现在谬误的地位。当初,如果咱们把变换的原点改为左上角,使其与平移相一致 ……

squareRef.current.style.transformOrigin = "top left";

对了!这就对了

如果 Transform Origin 发生变化怎么办?

当然,这个解决方案的最大问题是,咱们曾经硬编码了 transform origin 的值。如果用户想要一个不同的变换原点呢?在这种状况下,布局动画应该依然无效。

窍门在于确保 inverse 步骤比拟了两个方块的变换原点之间的间隔。换句话说,这个谬误的产生是因为测量的间隔和变换原点之间的差别:getBoundingClientRect()返回元素的左上角,而变换原点默认是在元素的核心。

只有当两个正方形的大小雷同时,左上角的点之间的间隔和核心之间的间隔才是相等的。

为了简略起见,我在这里只比拟程度间隔 – 如果咱们思考到垂直距离,同样的概念也实用。

当最终的正方形较大时,核心之间的间隔大于左上角各点之间的间隔。同样,当最终的正方形较小时,核心之间的间隔小于左上角各点之间的间隔。

有了这个见解,咱们也能够通过应用核心之间的间隔而不是左上角的点来解决这个问题。

纠正子元素的变形

到目前为止,咱们曾经可能制作一个布局动画,能够无缝过渡到大小和地位的变动。当初让咱们减少一个测试 – 如果咱们的元素有子元素会怎么?

如上图能够看到文字大小被改了。咱们怎样才能解决这个问题呢?

导致该问题的起因还 是 inverse 比例变换。当咱们反转到一个较小的正方形时,文本最终会变小,因为正方形被按比例放大。同样地,当咱们反转到一个较大的正方形时,文本最终会变大,因为正方形被按比例放大了。

反比例公式

一种办法是在子元素上利用另一种变换,” 对消 ” 父元素的变换。子元素的变换公式:

childScale = 1 / parentScale

例如:父元素变大两倍,那么子方须要将其尺寸减半,能力放弃雷同的尺寸。试着挪动上面的滑块,留神文字是如何放弃雷同大小的,而不论广场的大小如何。

当初,如何将其与咱们的布局动画相结合呢?

尝试

我尝试的第一件事是,在父元素要做动画之前,先计算一次反比例,而后在子元素上独自运行一个动画。

const inverseTransform = {
  scaleX: 1 / parentTransform.scaleX,
  scaleY: 1 / parentTransform.scaleY,
};
play({
  from: inverseTransform,
  to: {scaleX: 1, scaleY: 1},
});

例如,如果父元素动画从 scaleX: 2scaleX: 1,那么子代将 从 scaleX: 1 / 2scaleX:1,只有比例校对的工夫与父元素动画雷同,这种办法应该是可行的。

然而,运行起来成果却是谬误的:

在整个动画过程中,文字显著地在扭转。

正确的缩放工夫

这里的问题就在于这个假如:

只有比例校对的工夫与父动画雷同,这种办法应该是无效的。

失常状况下,” 正确 ” 反转比例不会以与父动画雷同的形式变动,它有点像做本人的事件。

在下面的例子中,蓝线示意父方的比例,而黄线示意子方的比例。请留神,蓝线是一条直线,而黄线则有点像曲线。这通知咱们,反比例的工夫与父比例的工夫是不一样的!

为了解决这个问题,咱们能够这么做:

  • 提前计算出正确的工夫
  • 每当父元素比例发生变化时,计算反比例。

(2)恰好比 (1) 简略得多,而且还容许咱们在父元素上解决各种不同的时序。这也是 Framer Motion 应用的办法。

animate({
  from: inverseTransform,
  to: {
    x: 0,
    y: 0,
    scaleX: 1,
    scaleY: 1,
  },
  onUpdate: ({x, y, scaleX, scaleY}) => {
    parentRef.style.transform = `...`;
    const inverseScaleX = 1 / scaleX;
    const inverseScaleY = 1 / scaleY;
    childRef.style.transform = `scaleX(${inverseScaleX}) scaleY(${inverseScaleY}) ...`;
  },
});

App.js

import React from 'react'
import Motion from './Motion'
import './styles.css'

export default function App() {const [toggled, toggle] = React.useReducer(state => !state, false)
  const [corrected, toggleCorrected] = React.useReducer(state => !state, false)

  return (
    <div id="main">
      <div>
        <button onClick={toggle}>Toggle</button>
        <label>
          <input type="checkbox" checked={corrected} onChange={toggleCorrected} />
          Corrected
        </label>
      </div>
      <div id="wrapper" style={{justifyContent: 'center'}}>
        <Motion toggled={toggled} corrected={corrected}>Hello!</Motion>
      </div>
    </div>
  )
}

Motion.js

const changed = (initialBox, finalBox) => {
  // we just mounted, so we don't have complete data yet
  if (!initialBox || !finalBox) return false;

  // deep compare the two boxes
  return JSON.stringify(initialBox) !== JSON.stringify(finalBox);
}

const invert = (el, from, to) => {const { x: fromX, y: fromY, width: fromWidth, height: fromHeight} = from;
  const {x, y, width, height} = to;

  const transform = {x: x - fromX - (fromWidth - width) / 2,
    y: y - fromY - (fromHeight - height) / 2,
    scaleX: width / fromWidth,
    scaleY: height / fromHeight,
  };

  el.style.transform = `translate(${transform.x}px, ${transform.y}px) scaleX(${transform.scaleX}) scaleY(${transform.scaleY})`;

  return transform;
}

其实不是这样的?

在这种状况下,使比例校对工作的形式是通过将子元素包裹在 <div> 中,并将比例校对利用于 <div> 中,这会有一些问题:

  • 一个静止组件在 DOM 中有两个元素,从用户体验的角度来看,这可能是个问题
  • 所有子组件都进行了比例校对,不可能一个子组件被校对而另一个子组件不被校对
  • 如果子组件也在做动画,可能会有问题 – 我没有测试过,但我认为比例校对会导致问题,因为咱们扭曲了子组件的坐标空间

Framer Motion 的做法有点不同,咱们必须让子组件成为布局组件来抉择退出比例校对。

<motion.article layout>
  <motion.h1 layout>Hello!</motion.h1> <-- is scale corrected
  <p>World!</p> <-- is not scale corrected
</motion.article>

这个 API 意味着子组件须要可能 “ 钩住 “ 父组件的动画,这让实现变得更加简单。

我抉择不以这种形式实现,因为我不想脱离外围的比例校对概念。如果你有趣味,能够看看 Framer Motion 源代码,他们应用一种叫做 “ 投影节点(“projection nodes”)” 的货色来保护本人的相似 DOM 的静止组件树。

明天的内容就到这里,感激大家的浏览。

起源:https://www.nan.fyi/magic-motion

编辑中可能存在的 bug 没法实时晓得,预先为了解决这些 bug, 花了大量的工夫进行 log 调试,这边顺便给大家举荐一个好用的 BUG 监控工具 Fundebug。

交换

有幻想,有干货,微信搜寻 【大迁世界】 关注这个在凌晨还在刷碗的刷碗智。

本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试残缺考点、材料以及我的系列文章。

退出移动版