Elmで要素の高さをアニメーションさせる

まずは[アコーディオンのサンプル](https://arowm.github.io/elm-accordion/index.html)をご覧ください。
3つ並んでいる "Accordion" と書いてある黒いバー（以降「ヘッダー」）の1つをクリックしてください。
なめらかなアニメーションで中身（以降「ボディ」）がでてきましたね？

本日のゴールは、任意の高さの要素に対して、このサンプルのように高さをいい感じにアニメーションで変化させることです。

## CSSのtransitionを使えばいいだけじゃないの？

そうですね！
変に Elm側で animationFrame とかを使ったアニメーションを作るよりも、CSS の transition / animation プロパティで実現したほうが、多くの場合容易に滑らかなアニメーションを作ることができます。
そのため CSS transition を使うこと自体は問題ないのですが、transition には[プロパティ値の `auto` が transition 可能ではない](https://developer.mozilla.org/ja/docs/Web/CSS/CSS_Transitions/Using_CSS_transitions#Which_CSS_properties_can_be_transitioned)という無視できない大きな制約があります。

[冒頭でお見せしたアコーディオンの例](https://arowm.github.io/elm-accordion/index.html)のように、ボディ部分内部の高さを任意にしようと思うと `height: auto` にしておかなければならず、上記の `auto` に関する制約によって、高さをアニメーションで変化させることができないのです。
実際にやってみるとアニメーションにはならず、いきなりガタっとあらわれたり消えたりしてしまいます。

## じゃあ高さを毎度確かめて固定値で設定したら良いんじゃないの？

この時代に需要があるのか知りませんが、全くレスポンシブではないウェブアプリを作るのならそれでも良いでしょう。
でも、レスポンシブにするのであれば、画面幅に応じてボディ部分の高さが大きく変化します。
固定値を設定するのは現実的ではありません。

## max-height を使うのは？

`height` をアニメーションさせるかわりに

* アコーディオンがひらいている状態では十分に大きな `max-height` を設定
* アコーディオンが閉じている状態では `max-height` を `0` に設定

することで、擬似的にボディの高さをアニメーションさせる手法があります。

ただ、「十分に大きな `max-height`」が厄介で

* 小さくしすぎると、実際のボディの高さよりも小さくなり、ボディが全て表示されない
* 大きくしすぎると、めちゃくちゃ速い速度でひらいたり、実際に閉じ始めるまでにめっちゃ時間がかかる

    (なんでそうなるかわからない方は実際に極端に大きな値を設定して試してみればすぐにわかります)

という問題があり、意図通りのアニメーションを実現するのが難しいです。

## transform をつかうのは？

* 閉じている時は `transform: scaleY(0)`
* ひらいている時は `transform: scaleY(1)`

と切り替え、アニメーションさせることもできます。
`height` や `max-height` をアニメーションさせるよりも `transform` プロパティを変えるほうが描画負荷が少ないという話もありますし、これはこれで良い方法です。

しかし、厳密にはこれでは冒頭の例のような動きになりません。
下の画像のように、ボディ全体がグニュッと押しつぶされた形になる上に、もともとのひらいた状態のスペースを取り続けるため、下に大きなスペースが空いてしまっています。
![Firefox_Screenshot_2018-12-16T12-57-05.268Z.png](https://qiita-image-store.s3.amazonaws.com/0/16345/8ae81617-9b05-813c-e139-bc00ee9fa17b.png)
これではアコーディオンの目的である「画面領域の節約」を果たせません。

## じゃあどうするの？

ここから今回の提案手法の話です。
今回の提案手法では、アコーディオンのヘッダー部分をクリックしたときに、以下の処理を行います。

1. クリックイベントの `.target` からヘッダーの DOM オブジェクトを取得
1. `parentElement` 経由でボディの DOM オブジェクトを取得
1. ボディの DOM オブジェクトから `scrollHeight` を取得
1. ボディにインラインスタイルで `max-height` として上記の `scrollHeight` 値を設定
1. ボディの開閉状態を切り替える

実際のコードを見てみましょう。

まずはModelです。

```elm
type alias Model =
    { isOpen : Bool
    , maxHeight : Maybe Int
    }
```

アコーディオンの開閉状態を保持する `isOpen` と、ボディにインラインスタイルでつける `max-height` の値を持ちます。

これを使ってViewを下記のように定義しています。

```elm
view : String -> Model -> Html Msg
view content model =
    div
        [ class "wrapper"
        ]
        [ div
            [ class "title"
            -- タイトルをクリックしたときのハンドラ
            -- `maxHeight` がデコーダー
            , Events.on "click" (Decode.map ClickTitle maxHeight)
            ]
            [ text "Accordion"
            ]
        , div
            [ class "body"
            -- ここでインラインスタイルで `max-height` をセット
            , style "max-height" <|
                case model.maxHeight of
                    Nothing ->
                        ""

                    Just n ->
                        String.fromInt n ++ "px"
            -- `aria-hidden="true"` の時はボディを閉じる
            , attribute "aria-hidden" <|
                if model.isOpen then
                    "false"

                else
                    "true"
            ]
            [ div
                [ class "content"
                ]
                [ text content
                ]
            ]
        ]
```

`maxHeight` デコーダーは下記の通り定義してあります。

```elm
maxHeight : Decoder Int
maxHeight =
    Decode.at
        [ "target"
        , "parentElement"
        , "children"
        , "1"
        , "scrollHeight"
        ]
        Decode.int
```

これで `ClickTitle` にボディの `scrollHeight` が付与されて `update` に送られるので、それにしたがってモデルを更新するだけです。

```elm
type Msg
    = ClickTitle Int
```

```elm
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ClickTitle height ->
            ( { isOpen = not model.isOpen
              , maxHeight = Just height
              }
            , Cmd.none
            )
```

CSS (SCSS) でボディ部分の開閉時の見た目と、アニメーションを定義したら完成です！

```scss
.body {
  overflow-y: hidden;

  &[aria-hidden="true"] {
    /* インラインスタイルの `max-height` に打ち勝つために !important が必要 */
    max-height: 0 !important;
    transition: max-height 300ms ease-in;
  }

  &:not([aria-hidden="true"]) {
    transition: max-height 300ms ease-out;
  }
}
```

全体のコードは[arowM/elm-accordion](https://github.com/arowM/elm-accordion/)をご覧ください。

## scrollHeight の取得方法について

@akira_ さんにコメントでいいことを教えてもらいました。
[Browser.Dom.getViewportOf](https://package.elm-lang.org/packages/elm/browser/latest/Browser-Dom#getViewportOf) でボディ要素の `Element` を取得すると、`Element` の `scene.width` に `scrollHeight` が入っているそうです。

メリットは

* ヘッダーのDOMオブジェクトからボディのDOMオブジェクトをたどるのが大変な構造でも簡単
* 任意のタイミングで取得できる

デメリットは

* Reusable View などでもユニークなIDを常につける必要がある
* 何かのイベントを契機にして取得する場合は2回updateを呼ぶことになりそう

という特徴があるので、用途に応じてイベントをデコードする方法と使い分けると良いと思います。

## 免責事項

今回の手法では、ボディのコンテンツが動的に変化する場合に対応できません。
たとえば、アコーディオンが開かれた状態でコンテンツの量が増えて高さが増してしまった場合、ボディにインラインスタイルでついている `max-height` のせいでコンテンツが一部隠れた表示になってしまいます。
そういった使い方をする場合は別途工夫が必要ですが、そもそもアコーディオンの中身を動的に変えるなんてUIは正気ではない気がします。

以上、簡単そうで実は奥深い、要素の高さをアニメーションで変える方法でした！


[さくらちゃんにご飯をあげる](https://www.amazon.co.jp/gp/registry/wishlist/20ZV2VC57615B/ref=nav_wishlist_lists_1)
[さくらちゃんをもっと見る](https://twitter.com/hashtag/%E3%81%95%E3%81%8F%E3%82%89%E3%81%A1%E3%82%83%E3%82%93%E6%97%A5%E8%A8%98?src=hash)
[他の記事を見る](https://qiita.com/arowM)
![P_20181127_133228_vHDR_Auto.jpg](https://qiita-image-store.s3.amazonaws.com/0/16345/190ce80b-8c1e-cb14-b41b-2f6449d7cb97.jpeg)