這是一篇很好的互動式文章,Framer Motion 佈局動畫

前端小智發表於2023-03-31
微信搜尋 【大遷世界】, 我會第一時間和你分享前端行業趨勢,學習途徑等等。
本文 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 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

相關文章