2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Elmでtailwinduiのドロップダウンを実装してみる

Posted at

はじめに

ここ数年tailwindcssというCSSフレームワークが、特に海外では流行っているようです。詳細はこちらの記事(最近は本家の更新が頻繁にあるので最新の内容と異なる部分もあります)がとても参考になります。

このフレームワークをベースにし、多くのコンポーネントを有料で公開しているtailwinduiというプラグインがあります。その一部はサンプルとして一般にも公開されていますが、ライセンスを購入するとすべてのコンポーネントを利用することができます(ただし、まだ未完成のコンポーネントも多くあります)。パッケージプランにもよりますがライセンス料がUSD149またはUSD249と結構高いので躊躇してしまいます。個人的には応援とドネーションのつもりでUSD249のライセンスを購入しました:relaxed:

ドロップダウン

一般公開されているコンポーネントの一部に次のドロップダウンがあります。

Previewの部分には動作確認のためのデモが用意されていてます。ドロップダウンが開いた状態になっていますが、Optionsをクリックすると開閉の動作を試すことができます。Codeの部分をクリックするとHTMLのサンプルコードが表示されます。また、タイトルの右にREQUIRES JSと表示がありますが、ドロップダウンの開閉を制御するためにJSコードが別途必要になることを示しています1

Screen Shot 2020-08-29 at 18.16.39.png

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

ドロップダウンパネル部分にこの関数を使用して表示をopacityvisibilityを切り替えます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"
                ]
                [ ... ]

animation.gif

終わりに

elm-animatorのお試しの意味も含めてこの記事を書いてみました。実装自体は慣れれば大した苦労ではなさそうです。しかしこの程度のアニメーションではちょっと大げさな気もします。より単純な手法があれば教えていただきたいです。きっとこの記事の方法でも同じことが可能だと思うのですが、できれば生のCSSは書きたくないですw。

elm-animatorを使うとより複雑なアニメーションがCSSよりも容易に実現できそうに思いますが、足りないのは自分のセンス…

アニメーションの部分だけtailwindcssが定義したクラスが使えず、一貫性が失われてしまうことはちょっとマイナスな気分です。

ここではいきなりAnimator.Cssを使いましたが、Animator.Inlineというモジュールもあります。後者は60FPSでイベントが走るので、おそらくゲームのような細かく変化をコントロールしたいときに良さそうな気がします。

  1. tailwindのサイトはAlpine.jsというフレームワークを使ってJS部分の制御を行っています。

  2. 切り替えをdisplay: nonedisplay: blockで行うとアニメーションが思うように動作しません。詳しい説明はこちらが参考になります。

  3. Animator.previousが間違った状態を返すとの未解決のissueがありましたが、この辺が関係しているのかもしれません。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?