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

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 のクリップ プロパティやクリップパス プロパティを使用する

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)で始めるという合理的な仮定ができます。この自然スケールは展開された状態を表します。つまり、上記のようにスケールダウンされたバージョンからその自然なスケールまでアニメーション化する必要があります。

では、そうすればメニューの内容も拡大されますよね?このように

どうすればよいでしょうか?コンテンツには counter- 変換を適用できます。たとえば、コンテナを通常のサイズの 1/5 に縮小する場合、コンテンツを 5 倍にスケールアップして、コンテンツが押しつぶされるのを防ぐことができます。次の点に注目してください。

  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 で設定し、要素とそのコンテンツに必要なスケール値を計算します。これを文字列にまとめ、スタイル要素としてページに挿入できます。スタイルを注入すると、ページ上でスタイルの再計算がパスされます。これはブラウザが行う追加の作業ですが、この処理はコンポーネントの起動中に 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 アニメーションを更新して、順方向ではなく逆方向で実行するという 2 つの方法があります。これで問題ありませんが、アニメーションの「感触」が逆になるので、イーズアウト カーブを使用するとその逆がイーズインされて、緩やかに感じられます。より適切な解決策は、要素を折りたたむための 2 つ目のアニメーション ペアを作成することです。これらは、キーフレーム展開アニメーションとまったく同じ方法で作成できますが、開始値と終了値を入れ替えています。

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

より高度なバージョン: 円形リフォーム

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

原理は前のバージョンとほぼ同じです。ここでは、要素をスケールし、直接の子に対してカウンタ スケールを行います。この場合、スケールアップする要素の border-radius は 50% で円形になり、overflow: hidden を持つ別の要素でラップされます。つまり、要素の境界外に円が拡張することはありません。

このバリアントに関する警告です。Chrome では、テキストのスケールとカウンタ スケールが原因で丸め誤差があるため、低 DPI 画面でテキストが不鮮明になります。詳細にご興味がある場合は、スターを付けてフォローできるバグが報告されています

円形の展開効果のコードは、GitHub リポジトリにあります。

まとめ

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

また、このような効果にはウェブ アニメーションを使用すると便利です。ウェブ アニメーションには 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 リポジトリをご覧ください。また、以下のコメント欄に入力方法をお知らせください。