🛝

HTML/CSS/JavaScript で、スムーズにテキスト要素がスライドインしてくる仕掛けを作る (Blazor, React)

に公開

はじめに

前回、こんな記事を書きました。

https://zenn.dev/j_sakamoto/articles/a774e70143cf12

上記記事内にて、自作した Web アプリ (翻訳字幕システム) の "こだわりポイント" として以下のように言及しておりました。

しかしながら、せっかく (?) 自作でやりきるとした以上、一点だけこだわったところがありました。

それは逐次の翻訳結果の出現を、しっかりアニメーション表示するようにしたところです。

音声認識されて返ってきた翻訳結果テキストを、何も考えずに (?) 表示エリアにただ追加してしまうと、既存の前回までの翻訳結果テキストが、 ガクッと下にスクロールします。これだと、まだその前回翻訳テキストを目で追っていたときに、読んでいた箇所を見失いがちで非常にストレスです。とくに翻訳字幕システムの字幕領域の幅が狭いこともあって (主画面は、プレゼンターの PC 画面ですから)、翻訳結果テキストが折り返した複数行ぶんで挿入されることも多く、この "いきなりのジャンプ" に拍車をかけます。

そこで今回の自作実装では、新たな翻訳結果テキストが発生したときは、にゅーっと滑り込むようにそのテキスト要素を出現させるようにし、既存の翻訳結果テキスト要素はそれにあわせて下にスムーススクロールしていくようにしました。このようにすることで、視線はスムーススクロールについていくことができ、それまで読んでいた箇所を見失うことがなくなったのではないか、と思います。

今回この記事では、上記で言及していた "こだわりポイント"、HTML/CSS/JavaScript で、スムーズにテキスト要素がスライドインしてくる仕組みをどう実装したかを解説します。

ライブデモ & サンプルコード

こちら、https://sample-by-jsakamoto.github.io/Blazor-SlidingTextBox/ にて、スライドインアニメーションで出現するテキストボックスのライブデモを試せます。下図はこのライブデモページを開いて操作してみている様子です。

上図にてわかるとおり、

  • 左側には普通にテキストが div 要素で上に追加されていくだけの実装、
  • 右側が、今回解説する、スライドインアニメーションで出現するように仕込みをしたコンポーネント

となっています。新たなテキストブロックが出現したときに、既存のテキストブロックをまだ読んでいたとして、左右のどちらが読みやすいか、比べてみてください。

上記ライブデモは、Blazor WebAssembly で実装されています (但し、後述しますが、中核部分は JavaScript を使用しています)。ソースコードは下記で公開しています。

https://github.com/sample-by-jsakamoto/Blazor-SlidingTextBox

React 版もあります。ソースコードは下記で公開しています。

https://github.com/sample-by-jsakamoto/React-SlidingTextBox

実装

基本的なコンセプトは下図のとおりです。小さくて見にくいかと思いますが、下図をクリックすると別タブで画像だけが開きますので、そちらでご確認ください。

上図のとおり、この翻訳字幕システムでは、

  • 「テキストブロックの新規追加時」と
  • 「既存ブロックへのテキスト追加時」

の 2 つのシナリオを考える必要があります。以下にそれぞれのシナリオごとに、実装の詳細について解説します。

テキストブロックの新規追加時

そもそもの DOM 構造ですが、ひとつのテキストブロック (音声ディクテーション & 翻訳の単位) を、

  • その本文テキストを収めた .content-box div 要素と、
  • それを包んだ .container-box div 要素

の、2つの要素で実現しています。

まず、外側の .container-box ですが、CSS の overflowhidden に設定し、この .container-box よりはみ出した .content-box は見えないようにしておきます。また、このあと、内側の .content-box を絶対座標位置で配置するので、その基準点となるように、positionrelative を設定しておきます。そしてスライドインアニメーション効果のために CSS の transitionmin-height 0.5s linear というように min-height に対するトランジション効果を設定しておき、CSS の min-height0 に初期設定しておきます。

いっぽうの内側の .content-box 要素は、CSS の position プロパティを absolute とし、さらに bottom0 に設定することで、外側の .container-box 要素と下辺がそろうようにします。

これで初期状態では、外側の .container-box は高さ 0 に潰れており、内側の .content-box は外側の .container-box と下辺が揃うように配置されているため、外側の .container-box より上側にはみ出している格好となります。しかし外側の .container-box には overflow: hidden が適用されていますので、見切れている内側の .content-box は見えない状態となっています。

ここで、上記 DOM 構造を描画完了後に、JavaScript で ResizeObserver オブジェクトを使って、内側の .content-box のサイズ変更を監視 (Observe) します。

https://developer.mozilla.org/ja/docs/Web/API/ResizeObserver

そして、内側の .content-box のサイズ変更を検知したら、外側の .container-box の高さ (min-height) を、内側の .content-box のサイズに一致するように更新する処理を実装します。具体的には以下の JavaScript (TypeScript) コードのとおりです (関数 initialize の引数には、外側の .container-box 要素への参照が渡されます)。

export const initialize = (containerBox: HTMLElement) => {

    const contentBox = containerBox.querySelector<HTMLElement>(".content-box");

    const resizeObserver = new ResizeObserver(_ => {
        containerBox.style.minHeight = contentBox?.scrollHeight + "px";
    });

    if (contentBox) resizeObserver.observe(contentBox);
    ...

監視を開始した時点で、いったん、サイズ変更検知時のコールバック関数が呼び出されます。このコールバック関数内の処理によって、当初 0 に設定されていた .container-boxmin-height が、内側の .content-box の高さに変更されます。ここで、min-height に対するトランジション効果が設定されているので、外側の .container-box の高さが、いきなり内側の .content-box と同じになるのではなく、0.5 秒かけてスムーズに変化します。そして内側の .content-box は、外側の .container-box と下辺が揃うように bottom: 0 に設定されているので、これで上から下へ差し込まれるようなスライドインアニメーションが実現されます。

既存ブロックへのテキスト追加時

さて、新たな翻訳結果テキストのブロックが出現した場合は以上でよいのですが、この翻訳字幕システムは、すぐに翻訳結果が確定するのではなく、一定の区切りに達するまで、音声のディクテーションと翻訳が継続し、それからようやく最終結果として確定されます。つまり、翻訳結果テキストのブロックが出現後、しばらくの間、その翻訳結果テキストが徐々に伸びていくことになります。言い換えると、内側の .content-box 内のテキストが、変更・追加されることになります。

このシナリオの場合、内側の .content-box 内にテキストが追加され高さが変わると、先に設置しておいた ResizeObserver によってそのサイズ変更が検知され、再び、外側の .container-box の高さ (min-height) に反映されます。この動作はまさしく ResizeObserver を使うことで狙った動作です。

しかし、初期状態のままだと、内側の .content-boxbottom: 0 と設定されていることにより、テキストが追加された時にいったん上にぴょんと伸びて見切れてしまい、それから再びゆっくり下におりてくるという、ヘンな動きになってしまいます。

ということで、初回描画後は、内側の .content-box に対する bottom: 0 の指定は外れるように実装しておきます。

具体的には、Blazor であれば OnAfterRenderAsync ライフサイクルメソッド、React であれば useEffect フックを利用して、初回描画完了のタイミングを検知し、初回描画済みか否かのフラグを更新するようにします。そうしてこの初回描画済みのフラグを参照して、外側の .content-box に対し、初回描画時のみ initial という CSS クラスが追加されるようにしています。つまり、CSS クラス initial が付いている場合にのみ、内側の .content-box 要素に bottom: 0 が適用されるように CSS スタイルシートを定義します。

これで、既存ブロックへのテキスト追加時は、内側の .content-box は外側の .container-box と上辺が揃うようになっており、結果、追加されたテキストはいったん下側にはみ出して見切れるように描画され、それからトランジション効果によって徐々に下側が見えてくる、といったアニメーション効果が実現されます。

まとめ

翻訳字幕システムの、翻訳結果テキストの表示は、以上のような 2 つの要素のレイアウト組み合わせと、CSS のトランジション効果、そして ResizeObserver による要素のサイズ変更検知によって実装していました。

自分の知識と経験では上記のとおりの実装としましたが、他のやり方もきっとあると思います。ぜひ違ったやり方も共有頂けるとうれしいです。

Discussion