パフォーマンスに優れた展開と折りたたみのアニメーションを作成する

Stephen McGruer
Stephen McGruer

要約

クリップをアニメーション化する場合は、スケール変換を使用します。子要素を反対方向にスケーリングすることで、アニメーション中に子要素が伸びたり歪んだりすることを防ぐことができます。

以前、パフォーマンスの高いパララックス エフェクト無限スクロールを作成する方法に関する最新情報を投稿しました。この記事では、パフォーマンスの高いクリップ アニメーションを実現するために必要なことを説明します。デモを確認するには、UI 要素のサンプル GitHub リポジトリをご覧ください。

たとえば、展開するメニューの場合:

ビルド方法によっては、パフォーマンスが他の方法よりも優れているものもあります。

不適切: コンテナ要素の幅と高さをアニメーション化する

CSS を使用して、コンテナ要素の幅と高さをアニメーション化できます。

.menu {
  overflow: hidden;
  width: 350px;
  height: 600px;
  transition: width 600ms ease-out, height 600ms ease-out;
}

.menu--collapsed {
  width: 200px;
  height: 60px;
}

このアプローチの直面する問題は、widthheight をアニメーション化する必要があることにあります。これらのプロパティでは、レイアウトの計算が必要になり、アニメーションの各フレームに結果をペイントします。これは非常にコストがかかり、通常は 60 fps を達成できなくなります。レンダリング プロセスについて詳しくは、レンダリングのパフォーマンスに関するガイドをご覧ください。

不適切: CSS の clip プロパティまたは clip-path プロパティを使用する

widthheight をアニメーション化する代わりに、(非推奨の)clip プロパティを使用して、展開と閉じをアニメーション化することもできます。必要に応じて、代わりに clip-path を使用することもできます。ただし、clip-path の使用は clip よりもサポートが限定的です。ただし、clip は非推奨です。正解です。ご安心ください。これは望ましい解決策ではありません。

.menu {
  position: absolute;
  clip: rect(0px 112px 175px 0px);
  transition: clip 600ms ease-out;
}

.menu--collapsed {
  clip: rect(0px 70px 34px 0px);
}

メニュー要素の widthheight をアニメーション化するよりも優れていますが、このアプローチの欠点は、ペイントが引き続きトリガーされることです。また、clip プロパティを使用する場合は、操作対象の要素が絶対位置または固定位置である必要があります。このため、追加の調整が必要になる場合があります。

適切: スケールのアニメーション

このエフェクトでは、何かが拡大または縮小されるため、スケール変換を使用できます。これは朗報です。変換の変更はレイアウトやペイントを必要とせず、ブラウザが GPU に引き渡すことができるものです。つまり、効果が高速化され、60 fps に到達する可能性が大幅に高まります。

レンダリング パフォーマンスのほとんどの場合と同様に、このアプローチの欠点は、少し設定が必要になることです。でも、その価値は十分にあります。

ステップ 1: 開始状態と終了状態を計算する

スケール アニメーションを使用するアプローチでは、まず、メニューが閉じたときと開いたときの両方で必要なサイズを示す要素を読み取ります。状況によっては、これらの情報の両方を一度に取得できない場合があります。たとえば、コンポーネントのさまざまな状態を読み取るために、クラスを切り替える必要がある場合があります。ただし、その必要がある場合は注意が必要です。getBoundingClientRect()(または offsetWidthoffsetHeight)は、スタイルが最後に実行されてから変更されている場合、ブラウザにスタイルとレイアウト パスを強制的に実行させます。

function calculateCollapsedScale () {
    // The menu title can act as the marker for the collapsed state.
    const collapsed = menuTitle.getBoundingClientRect();

    // Whereas the menu as a whole (title plus items) can act as
    // a proxy for the expanded state.
    const expanded = menu.getBoundingClientRect();
    return {
    x: collapsed.width / expanded.width,
    y: collapsed.height / expanded.height
    };
}

メニューなどの場合は、自然なスケール(1, 1)で開始すると合理的に想定できます。この自然なスケールは拡大状態を表します。つまり、縮小されたバージョン(上記で計算されたもの)からその自然なスケールに戻すようにアニメーション化する必要があります。

では、メニューの内容も拡大されるはずですよね?下記のとおり、可能です。

では、この問題を解決するにはどうすればよいでしょうか。コンテンツに反転変換を適用できます。たとえば、コンテナが通常のサイズの 1/5 にスケールダウンされている場合は、コンテンツを 5 倍に拡大して、コンテンツが圧縮されないようにできます。これに関連して、次の 2 つの点に注意してください。

  1. 反転変換もスケール演算です。これは、コンテナのアニメーションと同様に高速化できるため優れています。アニメーション化される要素に独自のコンポジタ レイヤを取得させる(GPU を有効にする)必要がある場合があります。そのためには、要素に will-change: transform を追加するか、古いブラウザをサポートする必要がある場合は backface-visiblity: hidden を追加します。

  2. カウンタ変換はフレームごとに計算する必要があります。ここでは、アニメーションが CSS にあり、イージング関数を使用していると仮定すると、カウンタ変換をアニメーション化するときにイージング自体をカウンタする必要があります。ただし、cubic-bezier(0, 0, 0.3, 1) の逆曲線の計算は、それほど簡単ではありません。

そのため、JavaScript を使用して効果をアニメーション化することを検討するかもしれません。結局のところ、減衰方程式を使用して、フレームごとのスケールとカウンタスケールの値を計算できます。JavaScript ベースのアニメーションの欠点は、メインスレッド(JavaScript が実行されるスレッド)が他のタスクでビジー状態になっている場合です。簡単に言うと、アニメーションが途切れたり、完全に停止したりする可能性があるため、UX に悪影響を及ぼします。

ステップ 2: 動的に CSS アニメーションを作成する

最初は奇妙に見えるかもしれませんが、独自の減衰関数を使用してキーフレーム アニメーションを動的に作成し、メニューで使用できるようにページに挿入するのが解決策です。(この点について指摘してくださった Chrome エンジニアの Robert Flack 様に感謝いたします)これの主なメリットは、変換を変更するキーフレーム アニメーションをコンポーザで実行できることです。つまり、メインスレッドのタスクの影響を受けません。

キーフレーム アニメーションを作成するには、0 ~ 100 のステップで要素とその内容に必要なスケール値を計算します。これらは文字列にまとめられ、スタイル要素としてページに挿入できます。スタイルを挿入すると、ページでスタイルの再計算パスが発生します。これはブラウザが行う追加作業ですが、コンポーネントの起動時に 1 回だけ行われます。

function createKeyframeAnimation () {
    // Figure out the size of the element when collapsed.
    let {x, y} = calculateCollapsedScale();
    let animation = '';
    let inverseAnimation = '';

    for (let step = 0; step <= 100; step++) {
    // Remap the step value to an eased one.
    let easedStep = ease(step / 100);

    // Calculate the scale of the element.
    const xScale = x + (1 - x) * easedStep;
    const yScale = y + (1 - y) * easedStep;

    animation += `${step}% {
        transform: scale(${xScale}, ${yScale});
    }`;

    // And now the inverse for the contents.
    const invXScale = 1 / xScale;
    const invYScale = 1 / yScale;
    inverseAnimation += `${step}% {
        transform: scale(${invXScale}, ${invYScale});
    }`;

    }

    return `
    @keyframes menuAnimation {
    ${animation}
    }

    @keyframes menuContentsAnimation {
    ${inverseAnimation}
    }`;
}

好奇心旺盛な方は、for ループ内の ease() 関数について疑問に思うかもしれません。次のような関数を使用して、0 ~ 1 の値を緩和された同等の値にマッピングできます。

function ease (v, pow=4) {
  return 1 - Math.pow(1 - v, pow);
}

Google 検索でその様子を確認することもできます。便利ですね。他のイージング方程式が必要な場合は、Soledad Penadés による Tween.js をご覧ください。さまざまなイージング方程式が含まれています。

ステップ 3: CSS アニメーションを有効にする

これらのアニメーションを作成して JavaScript でページに焼き込み、最後のステップとして、アニメーションを有効にするクラスを切り替えます。

.menu--expanded {
  animation-name: menuAnimation;
  animation-duration: 0.2s;
  animation-timing-function: linear;
}

.menu__contents--expanded {
  animation-name: menuContentsAnimation;
  animation-duration: 0.2s;
  animation-timing-function: linear;
}

これにより、前の手順で作成したアニメーションが実行されます。ベイクされたアニメーションはすでにイージンされているため、タイミング関数を linear に設定する必要があります。設定しないと、各キーフレーム間でイージンされ、非常に奇妙な外観になります。

要素を閉じるには、CSS アニメーションを更新して、前方ではなく後方に実行する方法と、これは問題なく機能しますが、アニメーションの「感覚」が逆になります。ease-out カーブを使用した場合、逆は ease-in に感じられ、動きが遅く感じられます。より適切な解決策は、要素を閉じるアニメーションの第 2 のペアを作成することです。これらは、展開キーフレーム アニメーションとまったく同じ方法で作成できますが、開始値と終了値を入れ替えます。

const xScale = 1 + (x - 1) * easedStep;
const yScale = 1 + (y - 1) * easedStep;

より高度なバージョン: 円形の出現

この手法を使用して、円形の拡大と縮小のアニメーションを作成することもできます。

原則は、要素をスケーリングし、その直下の子を反対方向にスケーリングする、以前のバージョンとほぼ同じです。この場合、拡大する要素の border-radius は 50% で、円形になっています。また、overflow: hidden を持つ別の要素でラップされているため、円が要素の境界外に拡大されることはありません。

このバリエーションについて注意点があります。テキストのスケールとカウンタースケールによる丸め誤差が原因で、低 DPI 画面ではアニメーション中に Chrome のテキストがぼやけます。詳細については、スターを付けフォローできるバグが報告されています

円形展開エフェクトのコードは、GitHub リポジトリにあります。

まとめ

以上が、スケール変換を使用してパフォーマンスの高いクリップ アニメーションを行う方法です。理想的には、クリップのアニメーションを高速化できるとよいのですが(Jake Archibald が作成した Chromium のバグがあります)、それまでは、clip または clip-path をアニメーション化する際には注意し、width または height をアニメーション化することは絶対に避けてください。

このような効果には Web Animations を使用するのも便利です。JavaScript API を備えており、transformopacity のみをアニメーション化する場合、コンポジタ スレッドで実行できるためです。残念ながら、ウェブ アニメーションのサポートは十分ではありませんが、利用可能な場合は、プログレッシブ エンハンスメントを使用してウェブ アニメーションを使用できます。

if ('animate' in HTMLElement.prototype) {
    // Animate with Web Animations.
} else {
    // Fall back to generated CSS Animations or JS.
}

状況が改善されるまでは、JavaScript ベースのライブラリを使用してアニメーションを作成することはできますが、CSS アニメーションをベイクして代わりに使用することで、より信頼性の高いパフォーマンスが得られる場合があります。同様に、アプリですでにアニメーションに JavaScript を使用している場合は、少なくとも既存のコードベースと整合させるほうがよい場合があります。

このエフェクトのコードを確認するには、UI 要素サンプルの GitHub リポジトリをご覧ください。いつものように、ご不明な点がございましたら、以下のコメント欄でお知らせください。