Vue.js の transition で `height: auto` な開閉アニメーションを実装する

"アコーディオン" というような 呼ばれ方をするアニメーションを Vue.js で実装しようとしたときに ちょっとハマったので備忘録がてら記録しておこうと思います。
CSSの transition アニメーションは、例えば height などの値が `auto` の状態から `100px` へ、というようなアニメーションは出来ません。`0px` から `100px` へといったような、"数値から数値" というようなアニメーションしかできません。その解決方法として `max-height` の値を変化させてアニメーションさせる記事などを見ますが、いまいち理想の形とは言えない実装と感じました。
そこで、私なりに解決策を模索した結果と考察を紹介させていただきます。

💁結論に至ったもの

🤖説明

アニメーション自体は CSS のtransitionでアニメーションしています。

.content-enter-active,
.content-leave-active {
  transition: height 400ms ease;
}

このアニメーションを実現するポイントは `nextFrame` という関数になります。

↓JSの冒頭で宣言した関数です

function nextFrame(fn) {
  window.requestAnimationFrame(() => window.requestAnimationFrame(fn))
}

引数に関数を取り、requestAnimationFrame の中の requestAnimationFrame に引数で受け取った関数を渡しています。

🤔なぜ nextFrame という関数が必要なの?

nextFrame を使わずに実装してみたものを用意したので、こちらを見てみてください。

enter の際はスムーズにアニメーションしますが、leaveのときに、アニメーションせずにパッと見えなくなってしまいます。

冒頭でも説明したように、 `auto` の状態からアニメーションさせることは出来ません。
特に指定などしていない場合、height の初期値は `auto` です。

ですので、一度、 `auto` の状態のものを数値に変換させてから、その高さの数値にしてあげる必要があります。
enter の場合は 0 → その高さ、leave の場合は その高さ → 0 といった感じです。
`nextFrame` を使用していないコードの enter、leaveの実装部分の中身は↓こちらになります。

enter(el) {
  el.style.overflow = 'hidden'
  el.style.height = '0'
  el.style.height = `${el.scrollHeight}px`
},
leave(el) {
  el.style.overflow = 'hidden'
  el.style.height = `${el.scrollHeight}px`
  el.style.height = '0'
}

どちらも最初に overflow の値を `hidden` にしています。
これをしないと アニメーション中にコンテンツの中身がはみ出してるように見えてしまいます。
余談ですが、現在JSでスタイルを付与していますが、以下のようにCSSで設定しても問題なく動作します。

.content-enter-active,
.content-leave-active {
  overflow: hidden;
  transition: height 400ms ease;
}

そして、enter の場合は0、leave の場合は その高さ を `height` に設定し、次に 最終的な値である数値、つまり enter の場合は その高さ、leave の場合は 0 を指定しています。
処理の流れとしては この通りなのですが、 leave の際に意図した通りにアニメーションしません。
これはどういうことかというと、JSの処理が そのままブラウザ側で検知できる ということでは無い ということが考えられます。
ですので、(少なくとも leave のときに)初期の値を設定したら、それがブラウザで検知できるようになってから 最終的な値を設定する必要があります。
これを実現するために `nextFrame` という関数を利用しています。

この考えは以下の記事を参考にさせていただきました。

この記事の冒頭で説明しているものを簡単に言うと

requestAnimationFrame に渡された(コールバック)関数は 同じフレームで実行され、requestAnimationFrame の中の requestAnimationFrame に渡された(コールバック)関数は 次のフレームで実行される

ということになります。

`nextFrame` を使用していない コードでは enter、leave とも同じ関数の中でheight の値を初期値と最終的な値に設定しています。
これは 同じフレームで実行されることとなり、結局は最終的な値を設定してるに過ぎないということになりそうです。
つまり leave の場合、height が `auto` の状態から 0 へとアニメーションさせようとするので、意図しない動作になったと考えられます。

🤔なぜ enter は nextFrame を使用しなくともアニメーションしたのか?

これは憶測でしか無いのですが、enter 時には ブラウザの `paint` が多く発生するので、ブラウザ側が検知できたのではないか、と考えています。
というのも、enter 時には display の値が `none` の状態から その要素のデフォルトの値(今回は div なので `block`)になります。
これは enter しようとする要素の下にある要素の位置などに影響を与えるため、発生します。
上記で紹介した記事によると、 `paint` が多く発生するということは、それだけブラウザ側も検知できるタイミングがあるので、 enter の関数実行時にも検知できたのかな、と考えています。

…自信はない

確実にアニメーションさせるため、enter 時にも nextFrame を使用しています。

🤔height を `auto` にする必要があるの?

冒頭に上げたコードでは アニメーション終了後(after-enter、after-leave時)に JSでスタイルを付与した height と overflow の値を デフォルトに戻す処理を実行しています。

この処理が必要かどうかは、場合による と思います。
ただ、多くの場合 height を `auto` にする必要があると思います。

なぜかと言うと、まずは アニメーション終了後に height を `auto` にせず、nextFrame も使用していない例を御覧ください。

一見、問題無いように思います。
ただ、この CodePen をブラウザで開き、どれかを開いた状態で、ブラウザ幅を小さくしてみてください。

画像1

height の値を決めてしまっているため、コンテンツがはみ出して表示されると思います。
このように height の値を決めてしまって問題ないようなものであれば、これで良いのですが、特にこのような アコーディオンと呼ばれるようなものは これだと 問題になりそうです。

🤔before-enter、 before-leave との組み合わせではダメなの?

before-enter、before-leave 時に初期の値を設定して、enter、leave 時に最終的な値を設定しては動作しないかと思い、これもやってみました。

nextFrame を使用せずに enter、leave に初期値と最終的な値を設定したときと同じように leave 時に 意図した通りにアニメーションしません。
これも 同様に JSの実行と同時にブラウザが `paint` を行わないことに由来していると考えられます。

🤔$nextTick じゃだめなの?

nextFrame ではなく、Vue.js の $nextTick でやれば 大丈夫かと思い、これも実装してみました。

結局コレも、同様に leave 時に意図しない動作となります。
これについては Vue.js の作者である Evan氏が以下のようにコメントしています。

$nextTick は nextFrame ではない、とのことから、これも同様に同じフレーム内で、初期値と最終的な値を設定したことになるようです。

🐷まとめ

「アコーディオンといえば、jQuery」ってくらい、個人的にこういったアニメーションは jQuery で実装してきましたし、同じように jQuery で実装してきた方も多いと思います。
実際、Vue.js が必要ではない案件であれば、jQueryで実装するのも良い選択肢になると思います。
"たかだかアコーディオン" と侮ってしまって、実装に結構苦労してしまいましたが、意外と多くを学ぶことが出来たので良かったです。

今回 調べていく中で解決・解説している記事も見つけたので紹介します。

これも `auto` のものから どのようにアニメーションするかを解説している記事になります。
この記事の Technique 3: JavaScript の部分に今回説明したことが詳しく記載されているので、こちらも是非読んでみてください。

また、Vue のこういったコンポーネントとか無いのかなー、といろいろ探してたところありました。

フロントエンド界で Vue.js をしていて
ktsnを知らない奴はいなかったよ…

って 小暮さん(メガネ君)が言いそうな感じの御方が作ってました。
しかも2年くらい前に。

キサマ等のいる場所は既に我々が2000年前に通過した場所だッッッ

って 烈海王さんばりに言われそうな感じです。

しかも、window オブジェクトの有無を判定した心憎い実装となってるので、SSR時に window オブジェクトが無いって怒られずに済みそうです。

🙌すごい🙌

この記事が気に入ったらサポートをしてみませんか?