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

## はじめに

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

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

## ドロップダウン

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

https://tailwindui.com/components/application-ui/elements/dropdowns

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

![Screen Shot 2020-08-29 at 18.16.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13655/3f71ec83-737c-c2a8-ee2c-acd30bd01850.png)



## Elmへの移植

先のサンプルコードをVSCodeのプラグイン[HTML to Elm](https://github.com/rubymaniac/vscode-html-to-elm)で変換して微調整し、Ellieの初期サンプル上でviewの部分を差し替えて表示できるようようにしたものが下記になります。

https://ellie-app.com/9PY7HktbX6pa1

tailwindcssのサンプルではSVGアイコンをインラインで多用しており、実際にElmで使う場合は名前の衝突を避けるためにSVG部分は別のモジュールに分けたほうが良さそうです。この段階ではドロップダウン開閉の制御が実装されていませんので、ドロップダウンパネルが開いた状態が表示されます。

## ドロップダウンの開閉

開閉の制御は難しくないとは思いますが実装した例が下記になります。

https://ellie-app.com/9PYqXcSDcDLa1

Modelでドロップダウンの開閉状態を保持します。

```elm
type alias Model =
    { isOpen : Bool }
```

この状態に応じてtailwindcssのクラスを付け替えるためにヘルパー関数を用意します。

```elm
classIf : Bool -> String -> String -> Html.Attribute msg
classIf cond open closed =
    if cond then
        class open

    else
        class closed
```

ドロップダウンパネル部分にこの関数を使用して表示を`opacity`と`visibility`を切り替えます[^2]。

```elm
                , classIf model.isOpen "opacity-100 visible" "opacity-0 invisible"
```

## アニメーション

アニメーションがなくても機能はするので、必要がなければここで終了です…

tailwinduiのサンプルコード中でアニメーションが必要な部分には下記のようなコメントが記載されています。

```html
  <!--
    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の[ドキュメント](https://tailwindui.com/documentation#integrating-with-javascript-frameworks)を見ると、いくつかのメジャーなフレームワークに対応した例が載っていますが、いずれもtransitionのために準備されたコンポーネントを使用しています。

最近になって、Reactに対応したtailwindui用に開発された[コンポーネント](https://github.com/tailwindlabs/tailwindui-react)が発表されましたが、そのコードを見てみるとけっこう複雑で、そのままElmに移植するというのも自分の技量では無理そうです…

## elm-animator

前置きが長くなってしましましたがここからが本題。こちらも比較的最近なのですがelm-uiの作者である[@mdgriffith](https://github.com/mdgriffith)さんが発表したelm-animatorという[パッケージ](https://package.elm-lang.org/packages/mdgriffith/elm-animator/latest/)があります。これを利用するとElmの流儀で同じことが実現できそうです（elm-animatorのコンセプトや詳細を説明しろと言われると自分には無理そうなので、どうやって今回の問題を解決方法だけを記載します。いずれどなたかが詳説してくださることを期待してw）。

下記ができあがったコードになります。

https://ellie-app.com/9Q57JcHzn6ga1

Modelではアニメーションのトリガとなる状態`isOpen`を、その変化を管理するための`Timeline`という型に変更します。状態とはいってもここではBoolなので、とり得る値はTrueまたはFalseとなりますが、時間の経過とともにどのように値が切り替わるかを管理してくれるイメージかと思います。

```elm
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`関数に教えてあげます。

```elm
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`によってその反対の状態を指定します。

```elm
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`](https://package.elm-lang.org/packages/mdgriffith/elm-animator/latest/Animator#arriveEarly)で25%早く変化が終了するよう指定します
- `Animator.leaveSmoothly 1`および`Animator.arriveSmoothly 1`を指定するとそれぞれCSSの`ease-in`および`ease-out`に相当する変化になるそうです

```elm
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]。


```elm
            , 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13655/d8e7d61c-d77e-8b4e-f0f0-d854e3bbfd1a.gif)

## 終わりに

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

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

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

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

[^1]: tailwindのサイトは[Alpine.js](https://github.com/alpinejs/alpine/blob/HEAD/README.ja.md)というフレームワークを使ってJS部分の制御を行っています。
[^2]: 切り替えを`display: none`と`display: block`で行うとアニメーションが思うように動作しません。詳しい説明は[こちら](https://nansystem.com/opacity-0-vs-visibility-hidden-vs-display-none/)が参考になります。
[^3]: `Animator.previous`が間違った状態を返すとの未解決の[issue](https://github.com/mdgriffith/elm-animator/issues/16)がありましたが、この辺が関係しているのかもしれません。