重現framer的神奇布局動畫的指南。
到目前為止,我最喜歡 Framer Motion 的部分是它神奇的布局動畫--將 layout prop 拍在任何運動組件上,看著該組件從頁面的一個部分無縫過渡到下一個部分。

在這篇文章中,我們主要介紹:
- 布局變化:它們是什么,何時發生。
- 基于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-content從flex-start到flex-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鉤子中刪除依賴數組,使鉤子每次渲染都能運行。
試著觸發幾次布局變化,檢查控制臺,看看顯示的x和y值是什么。
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 notallow={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 notallow={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()?方法也剛好返回元素的 width? 和 height:
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: 2到scaleX: 1?,那么子代將從scaleX: 1 / 2到scaleX: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 notallow={toggle}>Toggle</button>
<label>
<input type="checkbox" checked={corrected} notallow={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