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

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 に設定する必要があります。設定しないと、各キーフレーム間でイージングが行われ、非常に奇妙な外観になります。

要素を折りたたんで元に戻す場合の選択肢は 2 つあります。1 つは、順方向ではなく逆方向で実行されるように CSS アニメーションを更新する方法です。これは問題なく機能しますが、アニメーションの「雰囲気」が逆になるため、イーズアウト カーブを使用すると、イーズアウト カーブを使用するとイーズアウトしたように感じられ、動作が遅く感じられます。より適切な解決策は、要素を閉じるアニメーションの第 2 のペアを作成することです。これらは、展開キーフレーム アニメーションとまったく同じ方法で作成できますが、開始値と終了値を入れ替えます。

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

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

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

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

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

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

まとめ

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

このような効果には Web Animations を使用するのも便利です。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 リポジトリをご覧ください。また、これまでのように、以下のコメント欄でご意見やご感想をお寄せください。