フレーマーの魔法のようなレイアウト アニメーションを再作成するためのガイド。
これまでのところ、Framer Motion で私のお気に入りの部分は、その魔法のようなレイアウト アニメーションです。モーション コンポーネントにレイアウト プロップを叩きつけ、そのコンポーネントがページのある部分から次の部分にシームレスに遷移するのを観察します。
<motion.div layout />
この記事では主に以下の内容を紹介します。
- レイアウトの変更: 変更の内容と変更のタイミング。
- CSS ベースのメソッドと、それが常に機能しない理由。
- FLIP: Framer Motion で使用されるテクノロジーです。
レイアウト変更
レイアウトの変更は、ページ上の 1 つの要素によって他の要素の位置が変更されるときに発生します。たとえば、要素の幅や高さを変更すると、要素の新しいサイズのためのスペースを空けるために隣接する要素を移動する必要があるため、レイアウトの変更になります。
同様に、要素のjustify-content
プロパティを変更すると、要素の子要素の位置が変更されるため、レイアウトの変更となります。
ただし、scale
属性などの変更はページ上の他の要素に影響を与えないため、レイアウトの変更ではありません。
CSSを使ったアニメーション
では、レイアウトの変更をアニメーション化するにはどうすればよいでしょうか? 1 つの方法は、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' : ''}`} />
}
スタイル.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でもアニメーションを実現できるようですが、主に次の2つのデメリットがあります。
- すべてをアニメーション化することはできません。たとえば、アニメーション化可能なプロパティではない
justify-content
ため、変更をアニメーション化することはできません。*パフォーマンスの問題。レイアウトの変更を伴う CSS アニメーションは、一般に変換ベースのアニメーションよりも高価であるため、ローエンド デバイスではアニメーションがスムーズではない場合があります。まずパフォーマンスの問題を見てみましょう。justify-content
パフォーマンス
- 事前最適化を行わないローエンド デバイスでパフォーマンスの問題が発生せず、CSS トランジションが機能する場合でも、心配する必要はありません。必要な場合にのみ最適化します。
レイアウトの変更を伴う CSS アニメーションは、周囲の他の要素に影響を与えるため、通常、他の CSS アニメーションよりも高価になります。これは、ブラウザがアニメーションのすべてのフレームでページのレイアウトを再計算する必要があるためです。60FPS アニメーションの場合、これは 1 秒あたり 60 回を意味します。
上のアニメーションを確認してください。青いボックスを遷移させただけですが、灰色のボックスもアニメーション化されているように見えることに注意してください。
これは、青いボックスのサイズが変更されるたびにブラウザが灰色のボックスの位置を再計算するために発生します。
transform
一方、 CSS プロパティはレイアウトに影響を与えないため、ブラウザーは CSS プロパティをより速くアニメーション化できます。
青いボックスが大きくなっても、灰色のボックスは変わらないことに注意してください。
では、アニメーション化する方が安価な場合、レイアウト変更の代わりにtransform
使用できるでしょうか?transform
はい、大丈夫です!
フリップ
FLIP (の略) は、などの「高速」 CSS プロパティを使用して「低速」のレイアウト変更をアニメーション化できるFirst, Last, Inverse, Play
手法です。transform
FLIP は、 などの「アニメーション化できない」プロパティをjustify-content
アニメーション化することもできます。Framer Motion はFLIP を使用してレイアウトをアニメーション化します。
名前が示すように、FLIP はブラウザーによって行われたレイアウトの変更を元に戻す 4 段階のテクニックです。justify-content
からflex-start
への変化をアニメーション化してflex-end
、これがどのように機能するかを理解してみましょう。
初め
Firstでは、レイアウトの変更が発生する前に、アニメーション化する要素の位置を測定します。
要素の位置を取得する 1 つの方法は、HTML 要素の.getBoundingClientRect()
メソッドを使用することです。
const Motion = (props) => {const ref = React.useRef();React.useLayoutEffect(() => {const { x, y } = ref.current.getBoundingClientRect();}, []);return <div ref={ref} {...props} />;
};
最後
このステップでは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 onClick={toggle}>Toggle</button><div id="wrapper" style={
{ justifyContent: toggled ? 'flex-end' : 'flex-start' }}><Motion /></div></div>)
}
モーション.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
、四角形の位置を変更して、まったく動いていないように見せます。これを行うには、行った 2 つの測定値を比較し、正方形に適用する変換を計算します。
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>)
}
モーション.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 positionconst deltaX = initialPositionRef.current.x - box.x;const deltaY = initialPositionRef.current.y - box.y;console.log(deltaX, deltaY);// apply the transform to the boxsquareRef.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 yetif (!initialBox || !finalBox) return false;const xMoved = initialBox.x !== finalBox.x;const yMoved = initialBox.y !== finalBox.y;return xMoved || yMoved;
}
遊ぶ
これまでのところ、正方形が適用されtransform
、トグルキーを押しても動かない正方形ができました。
FLIP の最後のステップであるPlayステップでは、この変換をゼロにアニメーション化し、正方形を最終位置にアニメーション化できるようにします。
このアニメーションを実現するには複数の方法がありますが、私は個人的に 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 positionconst deltaX = initialPositionRef.current.x - box.x;const deltaY = initialPositionRef.current.y - box.y;// inverse the change using a transformsquareRef.current.style.transform = `translate(${deltaX}px, ${deltaY}px)`;// animate back to the final positionanimate({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 yetif (!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,// ...
});
位置を使用してサイズを固定する
これまでのところ、FLIP を使用して位置とサイズの変化をアニメーション化することができました。サイズと位置の両方をアニメーション化しようとするとどうなるでしょうか?
何かがおかしいようです。何が起きてる?ターン ステップの前にアニメーションを一時停止すると、ターン ステップ中に問題が発生したことがplay
わかります。正方形が元の位置と正確に一致していません。inverse
変換の開始点を修正する
これを理解してみましょう。
位置とサイズの変更を組み合わせるときは、逆のステップで 2 つの独立した変換 (移動とスケーリング) を実行します。これらの変換を個別に見てみると、正方形が最終的にどのようになるかがわかります。
私たちのアルゴリズムは、まず最終位置の左上隅を元の位置の左上隅に揃えてから、初期サイズに縮小します。
ここではスケール変換が原因のようです。正方形の中心からスケール変換するため、正方形が間違った場所に配置されてしまいます。ここで、変換と一致するように変換の原点を左上隅に変更すると...
squareRef.current.style.transformOrigin = "top left";
正しい!今は正しいです
変換原点が変更されたらどうなるでしょうか?
もちろん、このソリューションの最大の問題は、変換元の値をハードコーディングしていることです。ユーザーが別の変換元を望んでいる場合はどうすればよいでしょうか? この場合、レイアウト アニメーションは引き続き機能するはずです。
コツは、逆ステップで 2 つの正方形の変換原点間の距離を確実に比較することです。つまり、測定された距離と変換原点との差が原因でエラーが発生します。getBoundingClientRect()
デフォルトでは、変換原点は要素の中心にあるのに対し、要素の左上隅が返されます。
左上の点間の距離と中心間の距離は、両方の正方形が同じサイズである場合にのみ等しくなります。
簡単にするために、ここでは水平方向の距離のみを比較します。垂直方向の距離を考慮する場合も同じ概念が当てはまります。
最終的な正方形が大きい場合、中心間の距離は左上の点間の距離よりも長くなります。同様に、最終的な正方形が小さい場合、中心間の距離は左上の点間の距離よりも小さくなります。
この洞察があれば、左上の点の代わりに中心間の距離を使用してこの問題を解決することもできます。
子要素の正しい変形
これまでのところ、サイズと位置の変更にシームレスに移行するレイアウトをアニメーション化することができました。次に、テストを追加しましょう。要素に子がある場合はどうなりますか?
上の画像でわかるように、文字サイズが変更されています。この問題はどうすれば解決できるでしょうか?
この問題の原因はやはり逆スケール変換です。小さい正方形に反転すると、正方形が縮小されるため、テキストが小さくなります。同様に、より大きな正方形に反転すると、正方形が拡大されるため、テキストも大きくなります。
反比例の式
1 つのアプローチは、子要素に別の変換を適用して、親要素の変換を「オフセット」することです。子要素の変換式:
childScale = 1 / parentScale
たとえば、親要素のサイズが 2 倍になった場合、同じサイズを維持するには、子要素のサイズを半分に減らす必要があります。下のスライダーを動かしてみると、正方形のサイズに関係なく、テキストが同じサイズのままであることに注目してください。
さて、これをレイアウト アニメーションとどのように組み合わせるのでしょうか?
試す
私が最初に試したのは、親要素がアニメーション化される前に一度逆スケールを計算し、それから子要素で別のアニメーションを実行することでした。
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 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>)
}
モーション.js
const changed = (initialBox, finalBox) => {// we just mounted, so we don't have complete data yetif (!initialBox || !finalBox) return false;// deep compare the two boxesreturn 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 内に 2 つの要素があり、ユーザー エクスペリエンスの観点から問題になる可能性があります。
- すべてのサブコンポーネントは比例的に修正されます。1 つのサブコンポーネントが修正され、別のサブコンポーネントが修正されないということはありません。
- サブコンポーネントもアニメーション化している場合、これは問題になる可能性があります。テストはしていませんが、サブコンポーネントの座標空間を歪ませるため、スケール補正によって問題が発生すると思います。
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 のソース コードを参照してください。Framer Motion は、「投影ノード」と呼ばれるものを使用して、独自の DOM に似たモーション コンポーネントのツリーを維持します。
やっと
最近、VUE のさまざまな知識をまとめた VUE ドキュメントを見つけ、「Vue 開発のために知っておくべき 36 のヒント」としてまとめました。内容は比較的詳しく、さまざまな知識の解説も充実しています。
必要な友達は下のカードをクリックして受け取り、無料で共有できます