JavaScriptで操作するCSS TransitionとCSSOMの関係
リードUI開発者 宇賀みなさんこんにちは!UI開発者の宇賀です🍣🍵
あっという間に時間が過ぎて、気がつけばもう第1クオーターも終わりを迎えます。梅雨入りから低気圧が続いて嫌になっちゃいますね...😔🌧。
さて、さっそく本題です。
まずは今回のテーマで記事を書くことになったきっかけについてご紹介したいと思います。
かつて「jQuery無しでイージングアニメーションを伴う機能をつくろう!」というシリーズの中でディスクロージャーウィジェットを取り上げた記事を公開しましたが、この記事に関して個人的にお便りをいただきました📧。
jQuery無しでイージングアニメーションを伴う機能をつくろう!その2(トグル編)の「CSS Transition を考慮したコードに修正してみる」のセクションで書かれている内容について質問です。
何度か検証してみたのですが、このコードの
if
文の中の処理が走ることがなさそうでした。
にもかかわらず、if
文を削除してしまうと正しく処理が走りません。これはどういう状態でしょうか?var open = function () { if (content.offsetHeight !== 0) { requestAnimationFrame(open); return; } content.style.height = height + 'px'; // コンテンツの高さに取得しておいた値を指定する }; var close = function () { if (content.offsetHeight !== height) { requestAnimationFrame(close); return; } content.style.height = 0; // コンテンツの高さに取得しておいた値を指定する };
ご質問ありがとうございます!さっそく回答していきたいと思います。
※ 本記事は、基本的に私なりの調査結果です。実際に想定されている仕様とは異なる可能性があります。
前提
この記事で取り扱っていたディスクロージャーウィジェットは、隠された要素を表示する際にdisplay: none;
の状態からblock
へ変化させた後に、height
プロパティを0からauto
と同値までTransitionさせることを想定して書かれたものです。
CSS Transitionは、計算済みの値から計算済みの値までの数値的な変化をアニメーションさせます。高さを指定せず成り行きになっているheight
プロパティも、auto
から計測した値を実数で指定しなければなりませんから、実数を設定した後に要素のCSS layout boxが計算されるまでrequestAnimationFrame
で待とう、という考えのもとこういった処理になっています。display
プロパティもnone
からblock
に変化した後に実数値が計算されますから、同様の理由と手段で待機時間を設けようと考えていました。
非表示からの表示を1度だけ行えればいいケースならCSS Animationで実現できますが、今回は折り畳みのアニメーションも必要だったり、始まりと終わりが絶対値ではない(静的に@keyframes
を用意できない)ことからCSS Transitionを用いています。
ちなみに、JavaScriptとCSS Transitionの組み合わせについてMDNでは次のように書かれています。
次のような場合の直後にトランジションを使用する場合は注意してください。
.appendChild()
を使用して DOM に要素を追加したとき- 要素の
display: none;
プロパティを外したときこの場合、初期の状態が発生せず、要素が常に最後の状態であるかのように扱われます。この制限を解決する簡単な方法は、トランジションを行いたい CSS プロパティを変更する前に、数ミリ秒の
CSS トランジションの使用 - CSS: カスケーディングスタイルシート | MDNwindow.setTimeout()
を適用することです。
https://developer.mozilla.org/ja/docs/Web/CSS/CSS_Transitions/Using_CSS_transitions
setTimeout
を推奨しているようですが「数ミリ秒」という曖昧な値よりも、当時の私はrequestAnimationFrame
による必要最低限の待機時間で解決するほうがスマートだと考えていました。
しかし質問内容に書かれていた通り、当該のコードでは1度たりともrequestAnimationFrame
は呼び出されていませんでした。
なぜ条件式がtrue
になることがないにもかかわらず、当該のif
文を削ると動作しなくなるのか
執筆した当時は認識していなかったのですが、このコードが想定通り動作している要因はif
文の中にあるrequestAnimationFrame
ではなく、条件式の中でoffsetHeight
が参照されていることにあります。
そのため、if
文自体を削除してしまうとうまく動作しなくなってしまっていました。
実際の挙動を確認するために、3つのサンプルを用意しました(WAI-ARIAは省略しています)。display
プロパティがnone
から切り替わった後の処理がそれぞれ次のように異なります。
requestAnimationFrame
で1フレームだけずらしたものsetTimeout
でdisplay
プロパティの値がnone
から変化した後、30ms待ったものoffsetHeight
をただ1度参照しただけのもの
なお、対象ブラウザはWindow10環境に置いて現時点での最新版であるGoogle Chrome、Firefox、Edge、IE11とします。
<!-- 共通マークアップ -->
<style>
#hook {
background: #ebebeb;
color: #000;
font-weight: bold;
padding: 10px;
width: 100%;
}
#panel {
transition: .2s height ease-out; /* アニメーションの設定 */
overflow: hidden;
}
#panel .inner {
padding: 10px;
border: 2px solid #ebebeb;
}
</style>
<button id="hook">開く</button>
<div id="panel" hidden>
<div class="inner">
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ipsa dicta, sapiente id voluptatem officia molestias eum corrupti sunt perspiciatis, temporibus, magni voluptates impedit aperiam praesentium culpa ut minima nulla delectus.</p>
<!-- /.inner --></div>
<!-- /#panel --></div>
(function () {
'use strict';
const button = document.getElementById('hook');
const panel = document.getElementById('panel');
let height = 0;
let isClose = panel.hidden;
let isSliding = false; // 連打対策
button.addEventListener('click', function () {
if (isSliding) {
return;
}
isSliding = true;
isClose = panel.hidden;
// 閉じている場合
if (isClose) {
button.textContent = '閉じる';
panel.hidden = false;
height = panel.offsetHeight;
panel.style.height = 0;
requestAnimationFrame(function () {
panel.style.height = height + 'px';
});
return;
}
// 開いている場合
button.textContent = '開く';
height = panel.offsetHeight;
panel.style.height = height + 'px';
requestAnimationFrame(function () {
panel.style.height = 0;
});
});
panel.addEventListener('transitionend', function () {
if (!isClose) {
panel.hidden = true;
}
panel.style.height = '';
isSliding = false;
});
button.textContent = panel.hidden ? '開く' : '閉じる';
}());
(function () {
'use strict';
const button = document.getElementById('hook');
const panel = document.getElementById('panel');
let height = 0;
let isClose = panel.hidden;
let isSliding = false; // 連打対策
button.addEventListener('click', function () {
if (isSliding) {
return;
}
isSliding = true;
isClose = panel.hidden;
// 閉じている場合
if (isClose) {
button.textContent = '閉じる';
panel.hidden = false;
height = panel.offsetHeight;
panel.style.height = 0;
setTimeout(function () {
panel.style.height = height + 'px';
}, 30);
return;
}
// 開いている場合
button.textContent = '開く';
height = panel.offsetHeight;
panel.style.height = height + 'px';
setTimeout(function () {
panel.style.height = 0;
}, 30);
});
panel.addEventListener('transitionend', function () {
if (!isClose) {
panel.hidden = true;
}
panel.style.height = '';
isSliding = false;
});
button.textContent = panel.hidden ? '開く' : '閉じる';
}());
(function () {
'use strict';
const button = document.getElementById('hook');
const panel = document.getElementById('panel');
let height = 0;
let isClose = panel.hidden;
let isSliding = false; // 連打対策
button.addEventListener('click', function () {
if (isSliding) {
return;
}
isSliding = true;
isClose = panel.hidden;
// 閉じている場合
if (isClose) {
button.textContent = '閉じる';
panel.hidden = false;
height = panel.offsetHeight;
panel.style.height = 0;
// CSSOMへの問い合わせ
panel.offsetHeight; // eslint-disable-line
panel.style.height = height + 'px';
return;
}
// 開いている場合
button.textContent = '開く';
height = panel.offsetHeight;
panel.style.height = height + 'px';
// CSSOMへの問い合わせ
panel.offsetHeight; // eslint-disable-line
panel.style.height = 0;
});
panel.addEventListener('transitionend', function () {
if (!isClose) {
panel.hidden = true;
}
panel.style.height = '';
isSliding = false;
});
button.textContent = panel.hidden ? '開く' : '閉じる';
}());
いかがでしょうか?現時点ではどのブラウザでもoffsetHeight
の参照をするだけでCSS Transitionが動作していることが確認できるかと思います。
なぜ、要素のoffsetHeight
の参照をするだけでTransitionが動作するのでしょうか。
HTMLElement interface 'offsetHeight'
offsetHeight
の振る舞いを確認してみると、次のように書かれています。
CSSOM View Module
- If the element does not have any associated CSS layout box return zero and terminate this algorithm.
- Return the border edge height of the first CSS layout box associated with the element, ignoring any transforms that apply to the element and its ancestors.
https://www.w3.org/TR/cssom-view/#dom-htmlelement-offsetheight
要素がCSSレイアウトボックスを持っていなければ0を返し、持っていればCSSレイアウトボックスの高さを返すとあります。
そもそもouterHeight
はCSSOM View Moduleの仕様の1つですから、それを参照するということはJavaScriptによってCSSOMへ問い合わせが発生するということなのでしょう。問い合わせの結果、実際の高さが計算されるためtransition
のbefore-change style
が判明し、無事Transitionが行われるという結果につながったように感じます。
その他のinterfaceでの動作
CSSOM View Moduleで定義されているElement及びHTMLElementのinterfaceは次の通りです。
getClientRects()
getBoundingClientRect()
scrollIntoView()
scroll()
(Edge、IE非対応)scrollBy()
(Edge、IE非対応)scrollTo()
(Edge、IE非対応)scrollTop
scrollLeft
scrollHeight
clientTop
clientLeft
clientWidth
clientHeight
offsetParent
offsetTop
offsetLeft
offsetWidth
offsetHeight
いずれのプロパティもCSS layout boxを確認して計算する仕様のため、display
プロパティの値をnone
から切り替えた後にどれか1つを1度参照(または実行)するだけでTransitionが働くことを確認できました。
Element系のinterfaceだけでなく、document.elementFromPoint(0, 0)
を1度実行するだけも同じ結果が得られたため、とにかくCSSOMへの問い合わせが1度でも行われればTransitionが動作するようです。
まとめ
requestAnimationFrame
で正常に動作が行われるかと思っていましたが、CSS layout boxが解釈されるまでの時間とFPSは実際のところ関係がないため、誤解をしていたようです。。。
結果的に私が見た範囲では、非表示の状態からCSS Transitionを動作させる方法として特別何かが紹介されている節を見つけ出すことができませんでした。
とはいえ今回確認できた振る舞いから分かった通り、事実上display
プロパティがnone
以外に変わった後、CSSOM View Moduleで定義されている仕様の中から、CSSOMに問い合わせが発生する手段を1つとるだけでTransitionを動作させることはできるようです。
しかしながら参照するだけではエンジンによる最適化でCSSOMへの問い合わせが発生しなくなる可能性もありますし、明確にそのような仕様になっているという一文を見つけることもできなかったことから、現状では実際の実装がそのようになっているからと言って、手放しにこの方法でTransitionを実現することはあまりお勧めできないと考えます。偶然そのような振る舞いにそろったのか、あるいはあらゆるドキュメントを網羅的に読めば分かる当然の実装なのか...まだまだ学ぶことは多いですね。
確実なのはsetTimeout
で一定のミリ秒待機することかもしれませんが、待機するべき秒数も明確ではないためこちらも不安が残ります。
これ以外にもスライドアップ、スライドダウンを実装するには冒頭でご紹介した記事に掲載している手段以外にも様々な方法がありますから、他の選択肢も含めて学びを続けていきたいと思います。
(記事を読んでお問い合わせくださった方、ありがとうございました...!)