🫥

ちらつきを抑えるワンランク上のローディングアニメーション

に公開

はじめに

ローディングアニメーションにこだわる人は少ないかも知れません。ただ、ここを改善するとUIはぐっと引き締まります。

ちらつき例のGIFアニメーション

例えば、上記のローディングは150msだけ表示されており、ユーザーにとってはちらつきに見え、洗練されていない印象になります。

2つのちらつきの抑え方

ローディングによるちらつきを抑えるには、2つのアプローチがあります。

  • ローディングを表示しない
    • 表示するまでに 遅延(Delay) をいれる。遅延時間内に処理が終われば、ローディングは表示されない
  • 一度出したローディングはすぐに消さない
    • ローディング表示が開始したら 最低表示時間 (Min Duration) 経つまではローディングを消さない

下のアニメーションはこの2点を踏まえたローディング表示です。ボタンの上の数字はローディング処理にかかる時間を表しています。

各時間のローディング表示

1列目の300ms以下では、ローディングアニメーションは表示していません。300ms以下ぐらいの一瞬であれば、空白があっても不自然ではないでしょう。

2列目の400ms〜950msでは、処理時間が異なるにもかかわらず、すべて同じタイミングでアニメーションが終了しています。これは処理が終わっても最低表示時間になるまであえて、待機しているからです。

3列目の1000ms以上では普通に処理が終わったあとにすぐにアニメーションを終了しています。

このローディングで適用している設定値は以下のとおりです。

  • 遅延:350ms
  • 最低表示時間:600ms

こちらは私のおすすめの設定値です。根拠が気になる方は「設定値の根拠」の章をご覧ください。

挙動をまとめると下記のようになります。

処理時間 ローディングの最終的な挙動
~ 350ms ローディングを表示しない
350ms 〜 950ms 350ms経ってからローディングが表示され始め、950msまで表示が強制維持される
950ms 〜 350ms経ってからローディングが表示され、処理が終わるまで表示される

設定値を調整してローディング表示を試せるシミュレーターも作りました。

https://loading.rsasage.com/?x=2

ぜひ、触って体感してみてください。

実装

この挙動をWebで実現するためのコードを紹介します。

遅延はCSSを使って実装しています。

loading.html
<svg class="loading hidden" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
  <circle fill="none" stroke-width="4" cx="20" cy="20" r="18"></circle>
</svg>
loading.css
:root {
  /* 遅延時間の設定 */
  --loading-delay: 350ms;
  --loading-color: #2860d8;
  --primary-color: #2860d8;
}
.loading {
  width: 40px;
  height: 40px;
  animation: rotator 1.6s linear var(--loading-delay) infinite;
}

@keyframes rotator {
  0% {
    transform: rotate(-90deg);
  }

  100% {
    transform: rotate(180deg);
  }
}

.loading circle {
  stroke-dasharray: 114;
  stroke-dashoffset: 0;
  transform-origin: center;
  stroke: var(--loading-color);
  animation:
    spin-hidden var(--loading-delay),
    spin-fade-in 0.15s ease-out var(--loading-delay) forwards,
    spin-dash 1.6s ease-in-out var(--loading-delay) infinite;
}
/* 遅延を実現するための何も表示しないアニメーション */
@keyframes spin-hidden {
  0% {
    opacity: 0;
  }

  100% {
    opacity: 0;
  }
}
@keyframes spin-fade-in {
  0% {
    opacity: 0;
  }

  100% {
    opacity: 1;
  }
}
/* スピナーの円弧の長さを伸び縮みさせるアニメーション */
@keyframes spin-dash {
  0% {
    stroke-dashoffset: 111;
  }

  50% {
    stroke-dashoffset: 28.5;
    transform: rotate(135deg);
  }

  100% {
    stroke-dashoffset: 111;
    transform: rotate(450deg);
  }
}

下記は最低表示時間分、処理を遅延させるためのユーティリティ関数です。

waitLoadingAnimation.js
const waitLoadingAnimation = async (callback, opts) => {
  const delay = opts?.delay ?? 350; // ドハティ閾値(400ms) - バッファ(50ms)
  const minDuration = opts?.minDuration ?? 600; // 最低表示時間
  // Delayを超えた場合に強制される合計の最低待ち時間
  const requiredWaitTime = delay + minDuration; // 950ms

  const startTime = Date.now();

  // 1. コールバック関数を即座に実行
  const callbackPromise = callback();

  try {
    // 2. 処理の完了を待機
    const result = await callbackPromise;
    const finishedTime = Date.now();
    const actualTime = finishedTime - startTime;

    // 3. Min Durationの適用判定
    if (actualTime < delay) {
      // delay以内に完了した場合は即座に結果を返す (ノイズレス)
      return result;
    }
    // 処理時間がDelayを超えた場合、ローディングが表示されたとみなし、
    // Min Durationのルールを適用する。
    const remainingTime = Math.max(requiredWaitTime - actualTime, 0);

    // 4. 不足している時間分だけ人工的に待機
    if (remainingTime > 0) {
      // console.log("Waiting for min duration:", remainingTime);
      // Promiseが解決したにも関わらず、ローディング表示を維持する
      await new Promise((resolve) => {
        setTimeout(resolve, remainingTime);
      });
    }
    return result;
  } catch (error) {
    // エラー時も同様にMin Durationのルールを適用し、チラつきを防ぐ
    const errorTime = Date.now();
    const actualErrorTime = errorTime - startTime;

    if (actualErrorTime > delay) {
      const remainingTime = Math.max(requiredWaitTime - actualErrorTime, 0);
      if (remainingTime > 0) {
        // console.log("Waiting for min duration:", remainingTime);
        await new Promise((resolve) => {
          setTimeout(resolve, remainingTime);
        });
      }
    }
    // 待機完了後にエラーをスロー
    throw error;
  }
};

ほぼ同じですが、TypeScript版も作りました。

TypeScript版
waitLoadingAnimation.ts
const waitLoadingAnimation = async <T>(
  callback: () => Promise<T>,
  {
    delay = 350, // delay: ドハティ閾値(400ms) - バッファ(50ms)
    minDuration = 600 // minDuration: 最低表示時間
  } = {} 
) => {
  // Delayを超えた場合に強制される合計の最低待ち時間
  const requiredWaitTime = delay + minDuration; // 950ms

  const startTime = Date.now();

  // 1. コールバック関数を即座に実行
  const callbackPromise = callback();

  try {
    // 2. 処理の完了を待機
    const result = await callbackPromise;
    const finishedTime = Date.now();
    const actualTime = finishedTime - startTime;

    // 3. Min Durationの適用判定
    if (actualTime < delay) {
      // delay以内に完了した場合は即座に結果を返す (ノイズレス)
      return result;
    }
    // 処理時間がDelayを超えた場合、ローディングが表示されたとみなし、
    // Min Durationのルールを適用する。
    const remainingTime = Math.max(requiredWaitTime - actualTime, 0);

    // 4. 不足している時間分だけ人工的に待機
    if (remainingTime > 0) {
      // console.log("Waiting for min duration:", remainingTime);
      // Promiseが解決したにも関わらず、ローディング表示を維持する
      await new Promise((resolve) => {
        setTimeout(resolve, remainingTime);
      });
    }
    return result;
  } catch (error) {
    // エラー時も同様にMin Durationのルールを適用し、チラつきを防ぐ
    const errorTime = Date.now();
    const actualErrorTime = errorTime - startTime;

    if (actualErrorTime > delay) {
      const remainingTime = Math.max(requiredWaitTime - actualErrorTime, 0);
      if (remainingTime > 0) {
        // console.log("Waiting for min duration:", remainingTime);
        await new Promise((resolve) => {
          setTimeout(resolve, remainingTime);
        });
      }
    }
    // 待機完了後にエラーをスロー
    throw error;
  }
};

下記のような形でローディング処理を実行するメソッドを引数に入れるような形で使えます。

sample.js
async function doSomething() {
   // 何らかのローディング処理を実行
}

nextButton.addEventListener("click", async () => {
   setState("loading");
   try {
     await waitLoadingAnimation(() => doSomething());
     setState("loaded");
   } catch (error) {
     setState("error");
   }
});

GitHubにもコードを上げているので、気になる方はご覧ください。

https://github.com/Arahabica/good-timing-loading/blob/main/random.html

設定値の根拠

今回、遅延時間を350ms、最低表示時間を600msとしました。

これは個人的な体感だけではなく、ある程度根拠がある数字になっています。

遅延: 350ms

そもそもローディング表示が必要なのはシステムがちゃんと処理をしているのをユーザーに知らせるためです。
システムが一定時間以上反応がないとユーザーはストレスを感じ始めます。
この「一定時間」は研究されており、400msが閾値とされています(ドハティの法則)。

逆にいうと400ms未満であれば無反応でも許容されるので、400ms以内に終わる処理ではローディングは表示しないのが良いUIと言えるでしょう。

350msは、この400msに少しバッファを設けた値です。

最低表示時間: 600ms

350ms経ってからローディングを始めても、450msで処理が終わったら、ローディングを表示する時間は100msになってしまい、結局ちらつきになってしまいます。

このような場合にはちらつきを防ぐために500ms〜600msほどの最低表示時間を設けるようにしましょう。

ユーザビリティ研究のヤコブ・ニールセンによるとユーザーは1000msを超えると思考が途切れ始めるとのことです。表示を遅らせても、遅延と最低表示時間の合計が1000msの閾値を超えていなければ、集中力をとぎらせることはないので安心して良いかと思います。

600msというのは350msと足して1000msにならないように調整した値になっています。

ユーザーに表示する準備が整っているのに、あえて表示を遅らせるのは、パフォーマンス命のエンジニアにとっては受け入れ難いかもしれません。
ただ、UIの観点に立つと、ちらつきが発生するよりは少し待たせる方がユーザー体験は向上します。どうしてもスピードを求めたい方は350ms以内を目指すべきでしょう。

おわりに

ローディングの細かい挙動まで指示があることは少ないからこそ、エンジニアの判断でUIに差がつくポイントです。

ローディングにも気を遣って、ワンランク上のフロントエンドエンジニアになりましょう!

ボイスアップラボ

Discussion