まずはアコーディオンのサンプルをご覧ください。
3つ並んでいる "Accordion" と書いてある黒いバー(以降「ヘッダー」)の1つをクリックしてください。
なめらかなアニメーションで中身(以降「ボディ」)がでてきましたね?
本日のゴールは、任意の高さの要素に対して、このサンプルのように高さをいい感じにアニメーションで変化させることです。
CSSのtransitionを使えばいいだけじゃないの?
そうですね!
変に Elm側で animationFrame とかを使ったアニメーションを作るよりも、CSS の transition / animation プロパティで実現したほうが、多くの場合容易に滑らかなアニメーションを作ることができます。
そのため CSS transition を使うこと自体は問題ないのですが、transition にはプロパティ値の auto
が transition 可能ではないという無視できない大きな制約があります。
冒頭でお見せしたアコーディオンの例のように、ボディ部分内部の高さを任意にしようと思うと height: auto
にしておかなければならず、上記の auto
に関する制約によって、高さをアニメーションで変化させることができないのです。
実際にやってみるとアニメーションにはならず、いきなりガタっとあらわれたり消えたりしてしまいます。
じゃあ高さを毎度確かめて固定値で設定したら良いんじゃないの?
この時代に需要があるのか知りませんが、全くレスポンシブではないウェブアプリを作るのならそれでも良いでしょう。
でも、レスポンシブにするのであれば、画面幅に応じてボディ部分の高さが大きく変化します。
固定値を設定するのは現実的ではありません。
max-height を使うのは?
height
をアニメーションさせるかわりに
- アコーディオンがひらいている状態では十分に大きな
max-height
を設定 - アコーディオンが閉じている状態では
max-height
を0
に設定
することで、擬似的にボディの高さをアニメーションさせる手法があります。
ただ、「十分に大きな max-height
」が厄介で
-
小さくしすぎると、実際のボディの高さよりも小さくなり、ボディが全て表示されない
-
大きくしすぎると、めちゃくちゃ速い速度でひらいたり、実際に閉じ始めるまでにめっちゃ時間がかかる
(なんでそうなるかわからない方は実際に極端に大きな値を設定して試してみればすぐにわかります)
という問題があり、意図通りのアニメーションを実現するのが難しいです。
transform をつかうのは?
- 閉じている時は
transform: scaleY(0)
- ひらいている時は
transform: scaleY(1)
と切り替え、アニメーションさせることもできます。
height
や max-height
をアニメーションさせるよりも transform
プロパティを変えるほうが描画負荷が少ないという話もありますし、これはこれで良い方法です。
しかし、厳密にはこれでは冒頭の例のような動きになりません。
下の画像のように、ボディ全体がグニュッと押しつぶされた形になる上に、もともとのひらいた状態のスペースを取り続けるため、下に大きなスペースが空いてしまっています。
これではアコーディオンの目的である「画面領域の節約」を果たせません。
じゃあどうするの?
ここから今回の提案手法の話です。
今回の提案手法では、アコーディオンのヘッダー部分をクリックしたときに、以下の処理を行います。
- クリックイベントの
.target
からヘッダーの DOM オブジェクトを取得 -
parentElement
経由でボディの DOM オブジェクトを取得 - ボディの DOM オブジェクトから
scrollHeight
を取得 - ボディにインラインスタイルで
max-height
として上記のscrollHeight
値を設定 - ボディの開閉状態を切り替える
実際のコードを見てみましょう。
まずはModelです。
type alias Model =
{ isOpen : Bool
, maxHeight : Maybe Int
}
アコーディオンの開閉状態を保持する isOpen
と、ボディにインラインスタイルでつける max-height
の値を持ちます。
これを使ってViewを下記のように定義しています。
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
デコーダーは下記の通り定義してあります。
maxHeight : Decoder Int
maxHeight =
Decode.at
[ "target"
, "parentElement"
, "children"
, "1"
, "scrollHeight"
]
Decode.int
これで ClickTitle
にボディの scrollHeight
が付与されて update
に送られるので、それにしたがってモデルを更新するだけです。
type Msg
= ClickTitle Int
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) でボディ部分の開閉時の見た目と、アニメーションを定義したら完成です!
.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をご覧ください。
scrollHeight の取得方法について
@akira_ さんにコメントでいいことを教えてもらいました。
Browser.Dom.getViewportOf でボディ要素の Element
を取得すると、Element
の scene.width
に scrollHeight
が入っているそうです。
メリットは
- ヘッダーのDOMオブジェクトからボディのDOMオブジェクトをたどるのが大変な構造でも簡単
- 任意のタイミングで取得できる
デメリットは
- Reusable View などでもユニークなIDを常につける必要がある
- 何かのイベントを契機にして取得する場合は2回updateを呼ぶことになりそう
という特徴があるので、用途に応じてイベントをデコードする方法と使い分けると良いと思います。
免責事項
今回の手法では、ボディのコンテンツが動的に変化する場合に対応できません。
たとえば、アコーディオンが開かれた状態でコンテンツの量が増えて高さが増してしまった場合、ボディにインラインスタイルでついている max-height
のせいでコンテンツが一部隠れた表示になってしまいます。
そういった使い方をする場合は別途工夫が必要ですが、そもそもアコーディオンの中身を動的に変えるなんてUIは正気ではない気がします。
以上、簡単そうで実は奥深い、要素の高さをアニメーションで変える方法でした!