はじめに
ここ数年tailwindcssというCSSフレームワークが、特に海外では流行っているようです。詳細はこちらの記事(最近は本家の更新が頻繁にあるので最新の内容と異なる部分もあります)がとても参考になります。
このフレームワークをベースにし、多くのコンポーネントを有料で公開しているtailwinduiというプラグインがあります。その一部はサンプルとして一般にも公開されていますが、ライセンスを購入するとすべてのコンポーネントを利用することができます(ただし、まだ未完成のコンポーネントも多くあります)。パッケージプランにもよりますがライセンス料がUSD149またはUSD249と結構高いので躊躇してしまいます。個人的には応援とドネーションのつもりでUSD249のライセンスを購入しました
ドロップダウン
一般公開されているコンポーネントの一部に次のドロップダウンがあります。
Previewの部分には動作確認のためのデモが用意されていてます。ドロップダウンが開いた状態になっていますが、Optionsをクリックすると開閉の動作を試すことができます。Codeの部分をクリックするとHTMLのサンプルコードが表示されます。また、タイトルの右にREQUIRES JSと表示がありますが、ドロップダウンの開閉を制御するためにJSコードが別途必要になることを示しています1。
Elmへの移植
先のサンプルコードをVSCodeのプラグインHTML to Elmで変換して微調整し、Ellieの初期サンプル上でviewの部分を差し替えて表示できるようようにしたものが下記になります。
tailwindcssのサンプルではSVGアイコンをインラインで多用しており、実際にElmで使う場合は名前の衝突を避けるためにSVG部分は別のモジュールに分けたほうが良さそうです。この段階ではドロップダウン開閉の制御が実装されていませんので、ドロップダウンパネルが開いた状態が表示されます。
ドロップダウンの開閉
開閉の制御は難しくないとは思いますが実装した例が下記になります。
Modelでドロップダウンの開閉状態を保持します。
type alias Model =
{ isOpen : Bool }
この状態に応じてtailwindcssのクラスを付け替えるためにヘルパー関数を用意します。
classIf : Bool -> String -> String -> Html.Attribute msg
classIf cond open closed =
if cond then
class open
else
class closed
ドロップダウンパネル部分にこの関数を使用して表示をopacity
とvisibility
を切り替えます2。
, classIf model.isOpen "opacity-100 visible" "opacity-0 invisible"
アニメーション
アニメーションがなくても機能はするので、必要がなければここで終了です…
tailwinduiのサンプルコード中でアニメーションが必要な部分には下記のようなコメントが記載されています。
<!--
Dropdown panel, show/hide based on dropdown state.
Entering: "transition ease-out duration-100"
From: "transform opacity-0 scale-95"
To: "transform opacity-100 scale-100"
Leaving: "transition ease-in duration-75"
From: "transform opacity-100 scale-100"
To: "transform opacity-0 scale-95"
-->
サクッと書いてあるので簡単な生JSのサンプルを探してElmに移植すれば良いかなと思いましたが見つかりません…tailwinduiのドキュメントを見ると、いくつかのメジャーなフレームワークに対応した例が載っていますが、いずれもtransitionのために準備されたコンポーネントを使用しています。
最近になって、Reactに対応したtailwindui用に開発されたコンポーネントが発表されましたが、そのコードを見てみるとけっこう複雑で、そのままElmに移植するというのも自分の技量では無理そうです…
elm-animator
前置きが長くなってしましましたがここからが本題。こちらも比較的最近なのですがelm-uiの作者である@mdgriffithさんが発表したelm-animatorというパッケージがあります。これを利用するとElmの流儀で同じことが実現できそうです(elm-animatorのコンセプトや詳細を説明しろと言われると自分には無理そうなので、どうやって今回の問題を解決方法だけを記載します。いずれどなたかが詳説してくださることを期待してw)。
下記ができあがったコードになります。
Modelではアニメーションのトリガとなる状態isOpen
を、その変化を管理するためのTimeline
という型に変更します。状態とはいってもここではBoolなので、とり得る値はTrueまたはFalseとなりますが、時間の経過とともにどのように値が切り替わるかを管理してくれるイメージかと思います。
type alias Model =
{ isOpen : Timeline Bool }
initialModel : () -> ( Model, Cmd Msg )
initialModel flags =
( { isOpen = Animator.init False }, Cmd.none )
次のanimator
ではModel
からTimeline
を取り出す方法とModel
内のTimeline
の値を更新する方法をAnimator.Css.watching
関数に教えてあげます。
animator : Animator.Animator Model
animator =
Animator.animator
|> Animator.Css.watching .isOpen (\newState model -> { model | isOpen = newState })
このanimator
を使って、Animatorモジュールの内部状態をupdate
およびsubscriptions
で更新しますが、この部分はお決まりのパターンのようです。
ボタンが押されると、Animator.go
によってTimeline
が変化します。最初の引数はアニメーションを実行する期間です。ここでは効果がわかりやすいように2秒間としていますが、tailwinduiでは100msとなります。二番目の引数は次に取るべき状態となります。ここではAnimator.current
によって現在の状態を取得し、not
によってその反対の状態を指定します。
type Msg
= ClickedButton
| Tick Time.Posix
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ClickedButton ->
( { model | isOpen = Animator.go (Animator.millis 2000) (not (Animator.current model.isOpen)) model.isOpen }, Cmd.none )
Tick posix ->
( Animator.update posix animator model, Cmd.none )
subscriptions : Model -> Sub Msg
subscriptions =
\model -> Sub.batch [ animator |> Animator.toSubscription Tick model ]
実際にどのようなアニメーションを実行したいかをanimation
で記述しています。ここでは先程のtailwinduiのコメントにできるだけ合わせてみました。
- ボタンがクリックされてドロップダウンパネルが表示されるときは
opacity
を0から1に変化させ、scale
を0.95から1に変化させます - ボタンがクリックされてドロップダウンパネルが非表示となるときは
opacity
を1から0に変化させ、scale
を1から0.95に変化させ、同時に75msで消えるようにAnimator.arriveEarly
で25%早く変化が終了するよう指定します -
Animator.leaveSmoothly 1
およびAnimator.arriveSmoothly 1
を指定するとそれぞれCSSのease-in
およびease-out
に相当する変化になるそうです
animation : List (Animator.Css.Attribute Bool)
animation =
[ Animator.Css.opacity <|
\current ->
if current then
Animator.at 1
|> Animator.leaveSmoothly 1
else
Animator.at 0
|> Animator.arriveSmoothly 1
|> Animator.arriveEarly 0.25
, Animator.Css.transform <|
\current ->
if current then
Animator.Css.scale 1
else
Animator.Css.scale 0.95
]
最後にviewではアニメーションを実行したい部分の親となる要素をAnimator.Css.div
で置き換えます。最初の引数にはModel
から対象となるTimeline
を渡し、二番目の引数には上記のanimation
を指定します。
visibility
はアニメーションの対象とならないため、現在の状態に応じて変更する必要がありますが、Animator.current
を使って条件判断をするとisOpen
がFalseになった瞬間にinvisible
になってしまうためアニメーション効果が得られません。試行錯誤した結果、下記のように記述すると期待する動作となりました。ちょっとおかしい気がするのですが、より深くは調べていません3。
, Animator.Css.div model.isOpen
animation
[ class "origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg"
, if Animator.previous model.isOpen then
class "hidden"
else
class "visible"
]
[ ... ]
終わりに
elm-animatorのお試しの意味も含めてこの記事を書いてみました。実装自体は慣れれば大した苦労ではなさそうです。しかしこの程度のアニメーションではちょっと大げさな気もします。より単純な手法があれば教えていただきたいです。きっとこの記事の方法でも同じことが可能だと思うのですが、できれば生のCSSは書きたくないですw。
elm-animatorを使うとより複雑なアニメーションがCSSよりも容易に実現できそうに思いますが、足りないのは自分のセンス…
アニメーションの部分だけtailwindcssが定義したクラスが使えず、一貫性が失われてしまうことはちょっとマイナスな気分です。
ここではいきなりAnimator.Css
を使いましたが、Animator.Inline
というモジュールもあります。後者は60FPSでイベントが走るので、おそらくゲームのような細かく変化をコントロールしたいときに良さそうな気がします。