7
5

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 5 years have passed since last update.

VueAdvent Calendar 2019

Day 10

縦にたたむtransitionを頑張って再利用しやすい形で作る

Last updated at Posted at 2019-12-10

tl;dr

これを作る
Dec-09-2019 04-21-00.gif

  • UIアニメーションはユーザに対して付属的な情報を効果的に伝えることができるので、ビシバシ使っていきたい
  • 特に頑張ってプレーンなCSSでアプリケーションを作っている人は、transitionを作るのが結構辛かったりする
  • transitionを気合い入れて再利用しやすい形で作ると、使うときの心理的障壁を下げることができて、アプリケーションの品質向上に寄与する
  • アニメーションによってはDOMのラッパーが必要な場合とかもあるけど、最悪VNode触ることでシンプルな使い勝手を確保できる

前提

この記事では縦に折りたたむtransitionをプレーンなCSS(ここではCSSプリプロセッサを使うかどうかは問わず、CSSフレームワークやUIライブラリをベースに構築しない方針を指します)で実装することを通して、再利用しやすいtransitionコンポーネントの作り方について述べていきます。

toB向け管理画面やMVPを作る場合はCSSフレームワークやUIライブラリを主軸にして、CSSをなるべく自分で書かないでアプリケーションを作っていく選択肢もあります。その場合はよく使われるtransitionはあらかじめ用意されていたり、コンポーネントに実装されていたりすると思います。
しかし、

  • アプリケーションのあしらいやビジュアル・ブランディングについての軸がある
  • 少しリッチめなユーザインタラクションを作っている・作る予定がある
  • 細かいインタラクションを調整する必要がある
  • 特定のCSSライブラリにフル依存することで学習コストが増す

などプレーンCSSでイチから作る選択肢を選ぶ状況などもあり、その場合は自らの手でtransitionを実装していく必要があります。
とはいえ、プレーンCSSではない技術スタックであっても、必要に応じて独自でtransitionを用意することはありえますし、再利用しやすいtransitionコンポーネントをきちんと作れることはどの環境でも有用なのではないかと思います。

UIアニメーションを実装することで提供できる価値

Apple Human Interface Guidelines先輩は

Beautiful, subtle animation throughout iOS builds a visual sense of connection between people and content onscreen. When used appropriately, animation can convey status, provide feedback, enhance the sense of direct manipulation, and help users visualize the results of their actions.

Animation - Visual Design - iOS - Human Interface Guidelines - Apple Developer

(意訳)

iOSのすみずみにある美しく控えめなアニメーションは画面上のコンテンツとユーザの間に視覚的な繋がりを構築するんやで。適切にアニメーションを使うと 状態を伝えたりフィードバックを与えたりインタラクティブUIの操作感覚を強化したりユーザインタラクションの結果を伝えるための補助ができるんやなあ。

とおっしゃっていますし、Material Design先輩はMotionのUsageに関する項

  • コンテンツの階層関係の説明
  • ユーザ操作のフィードバックやユーザ操作に対する状態の表示
  • 操作方法を示し、ユーザの学習を助ける

など、使いみちについてかなり具体的に例示しています。

実際、小難しい話をこねくり回す必要もなく、UIアニメーションを利用することで様々な情報を付加してユーザがアプリケーションを利用する手助けになることはとても理解しやすい話です。適切なUIアニメーションが適切に情報を画面表示することができれば、 ユーザの離脱を防いだり、アプリケーションに対するポジティブな感情を醸成して継続率を上げたりと、密かに数字につながっていたりということもあると思います。

それ以外にも特定のイージングやdurationを頻繁に利用することでアプリケーションのVisual Identityを構築する手助けをできれば、認知を強めたり、競合アプリケーションとの差別化を行う一助になるかもしれません。

UIアニメーションを実装するのは辛いし面倒くさい

とはいえUI実装者の皆様は思うところがあるのではないかと存じますが、UIアニメーションを作り込むのは非常に気だるい作業になることが多いと思います。

CSS TransitionやCSS Animationを利用して実装する際は、この書き方で動くだろう!と思って動かしたら、アニメーションが効いてなかったり、行きの動作はするが戻りの動作がしなかったり(なんか思たんとちゃう…)といった気持ちになる経験はありませんか?
そういったツラミな状態になったとしても、CSSではデバッグをするのも難しく、一度悩み始めると無限に時間が溶ける原因になりがちです。(console.logするとなぜか動くのに、消すと動かなくなったりすると終業意欲がMAXになったりしますよね)

また、せっかくUIアニメーションをコンポーネント志向にパッケージングしたのに、使う際に特定のスタイルを付与する必要があったり、逆に特定のスタイルをつけてはならない、などの使い方の制約があると、忘れたり知らなかったりしたときにドツボにハマりがちです。

UIアニメーションを実装するときは再利用性が重要

こうしてUIアニメーション実装に対する心理的障壁が高くなると、

  • 部分的にUIからアニメーションが消失していく
  • 機能実装の見積もりや実績が膨らんでしまい、逆にアプリケーションのイテレーション計画を阻害する

など、UIアニメーションを実装するメリットを打ち消して有り余るデメリットが発生します。

UIアニメーションを実装する際に(おもたんとちゃう…)という気持ちになる状況は開発ツール側の進化がないと当分解決出来ないと思うので(いい方法あるよという方コメントで教えて下さい!)一旦あきらめるとして、いざ作ったアニメーションを再度使うときに(使い方わからん…)となるのは時間の無駄ですし、逆に再利用しやすいUIアニメーションのコンポーネントを用意できれば、効率的に各所でUIアニメーションを実装できますし、効率よく価値を提供することに繋がります。

なので、UIアニメーションを実装するときは実装されたアニメーションがイケてるかだけではなく、再利用するときに悩まずに使えるか・想像した通りに使えるかも重視すべきです。
Vueでは公式に <transition><transition-group> が提供されており、これらを使ってうまくパッケージングすることでこれを実現することができます。
次の項から具体的にtransitionコンポーネントを作っていきます。

本題

よくペラペラ説教をするおじさんみたいなことばかり喋ってしまってごめんなさい。作ります。

縦にたたむとはどういうことか

そもそもcss transitionで縦にたたむ動作をするためには、たたむ対象となるコンポーネントのstyleに

.target {
  overflow: hidden;
  transition: height;
}

を当てることでできそうだというのはすぐに思い浮かぶと思います1

しかし、たたむ対象のコンポーネントのoverflowを直接いじると、overflowを自身で設定しているコンポーネントで表示は壊れることが容易に想像できますし、heightをいじるとなれば、レイアウトを高さに対する相対指定で行っていたり、縦flex・gridを利用している場合に崩れるでしょう。

transitionを作ることに関しては、なるべく使うときに考えたり悩んだりしないで使えるものを作るという上記の指針があるので、今回は下図のように overflow: hidden; transition: height; だけの表示領域を区切るためだけのラッパーを用意し、縦にたたむスタイルを実装していきます。

IMG_5D9143A31CDA-1.jpeg

どういうものを作るか

Vueのtransitionはslotの中に入るコンポーネントが v-if or v-show が切り替わったときにtransitionが発生するというインターフェースになっています。今回作るtransitionもこのインターフェースに沿った形で、

<template>
  <vertical-collapse-transition>
    <p v-if="show">lorem ipsum...</p>
  </vertical-collapse-transition>
</template>

<!-- OR -->

<template>
  <vertical-collapse-transition>
    <p v-show="show">lorem ipsum...</p>
  </vertical-collapse-transition>
</template>

上記のような使い方ができるものを作ります。

内部的なラッパーをどうするか

疑似コードですが、

<template>
  <transition name="vertical-collapse">
    <div style="overflow: hidden; transition: height;">
      <slot />
    </div>
  </transition>
</template>

のようなtransitionコンポーネントを作ってしまうと、 slotv-ifv-show を変更しても、 transition から見て子コンポーネントの表示が変わっていないように見えるので、transitionが発火しません。

こういうときどうすればよいかというと、VNodeを直接触って slot の表示状態をラッパーにプロキシしてあげればよいです。

実際に表示状態で取りうるパターンとしては4種類あり、

  1. v-if="true"
  2. v-if="false"
  3. v-show="true"
  4. v-show="false"

のどれかになります。
これらの場合にどういったVNodeがslotsに入ってくるかの明確な仕様はドキュメントに特に明記されてなかったので、ぼんやりしたイメージはありましたが実際に console.log して中身を見ていきました。

1. v-if="true"

v-ifはそもそもVNodeを仮想DOMツリーに入れるか入れないかを制御するディレクティブなので、 v-if="true"の場合はシンプルにslotのVNodeが入ってきます。

{
  render(h) {
    const child = this.$slots.default && this.$slots.default[0];
    
    console.log(child);
    // VNode { tag: "div", ...}
  }
}

2. v-if="false"

一を聞いて十を知る皆さんであれば、仮想DOMツリーに入れないので undefined とか null 的なやつが来るのではないかと想像がついていると思います。正解です。

{
  render(h) {
    const child = this.$slots.default && this.$slots.default[0];
    
    console.log(child);
    // undefined
  }
}

ですが、template compiler次第なのか、下記のようなパターンもありました。2

{
  render(h) {
    const child = this.$slots.default && this.$slots.default[0];
    
    console.log(child);
    // VNode { tag: undefined, ...}
  }
}

2019/12/12 追記
空白しかないtext nodeの可能性が高いです。

3. v-show="true"

ディレクティブに関する情報 vnode.data.directives に入ってきます。

{
  render(h) {
    const child = this.$slots.default && this.$slots.default[0];
    
    console.log(child);
    // VNode { tag: "div", data: { directives: [{
    //         name: 'show',
    //         value: true,
    //         ...
    //       }] }, ...}
  }
}

4. v-show="false"

もちろん3と同じ構造で、valueが false になっているだけです。

{
  render(h) {
    const child = this.$slots.default && this.$slots.default[0];
    
    console.log(child);
    // VNode { tag: "div", data: { directives: [{
    //         name: 'show',
    //         value: false,
    //         ...
    //       }] }, ...}
  }
}

条件分岐

以上のVNodeの状態をそれぞれ区別して条件分岐しつつ、適切なラッパーを返してあげるとなると、下記のような感じになります。

{
  render(h) {
    // transitionはもともとdefault slotの一個目の要素しか見ないインターフェースなので、ほかは捨ててよい
    const child = this.$slots.default && this.$slots.default[0];
    
    // TODO: あとで中を実装する
    const generateTransitionComponent = (children) => h('transition', {}, children);

    const isEmpty = !child || child.tag === undefined;

    if (isEmpty) {
      // --------------
      // v-if="false"
      // --------------

      return generateTransitionComponent([]); // transitionの中を空で返す
    }

    const vShowDirective =
      child.data.directives &&
      child.data.directives.find(directive => directive.name === 'show');
    const isHidden = vShowDirective && !vShowDirective.value;

    if (isHidden) {
      // --------------
      // v-show="false"
      // --------------
      child.data.directives = [
        // 子のv-showディレクティブを消す
        ...child.data.directives.filter(
          directive => directive.name !== 'show'
        ),
      ];

      return generateTransitionComponent([
        h(
          'div', // h('div') でラッパーを作る
          {
            // ラッパーにv-showディレクティブを移植する
            directives: [
              {
                name: 'show',
                value: false,
              },
            ],
          },
          [child]
        ),
      ]);
    }

    if (vShowDirective && !isHidden) {
      // --------------
      // v-show="true"
      // --------------

      return generateTransitionComponent([
        h(
          'div',
          {
            // ラッパーにv-show="true"をプロキシする
            directives: [
              {
                name: 'show',
                value: true,
              },
            ],
          },
          [child]
        ),
      ]);
    }

    // --------------
    // v-if="true"
    // --------------
    // 特に何もせず、ラッパーに子をそのまま入れて返す
    return generateTransitionComponent([h('div', {}, [child])]);
  }
}

縦にたたむ

jsを使ってtransitionを作る場合は、cssのトランジションクラス(v-enterなどのスタイルを定義するやり方)を利用するよりも、jsフックを使ったほうが圧倒的に自由度が高くておすすめです。とくに、使いやすいtransitionを作るには timing functiondurationもpropsで渡せたほうが汎用的でベターですが、その場合はjsフックだとなおさら書きやすいです。

transitionおよびjsフックの詳しい仕様はドキュメントを参照してください。
(宣伝:Vue.jsのドキュメントは有志のメンバーがとてもマメに翻訳を更新しているのでぜひ公式ドキュメントを読んでください!)
https://jp.vuejs.org/v2/guide/transitions.html

方針としては

  • 非表示 → 表示
    • beforeEnterフックでラッパーのheight0にする
    • enterフックでラッパーのheightを子のscrollHeightにする
  • 表示 → 非表示
    • beforeLeaveフックでラッパーのheightを子のscrollHeightにする
    • enterフックでラッパーのheight0にする

というシンプルなものです。

完成形

実際に上記を実装するとこんな感じになるかと思います。

components/transitions/VerticalCollapseTransition.vue
<script>
import Vue, { VNode } from 'vue';

export default {
  props: {
    duration: {
      type: Number,
      default: 100,
    },
    easing: {
      type: String,
      default: 'ease-out',
    },
  },
  render(h) {
    // transitionはもともとdefault slotの一個目の要素しか見ないインターフェースなので、ほかは捨ててよい
    const child = this.$slots.default && this.$slots.default[0];
    
    // transition要素を作る関数
    const generateTransitionComponent = childrenOfTransition =>
      h(
        'transition',
        {
          on: {
            beforeEnter(el) {              
              el.style.height = '0';
            },
            enter(el) {
              el.style.height = `${el.scrollHeight}px`;
            },
            beforeLeave(el) {
              el.style.height = `${el.scrollHeight}px`;
            },
            leave(el) {
              // このif文がないと初回の非表示にtransitionがかからない(謎)
              // タイミング問題だと思われる
              if (el.scrollHeight !== 0) {
                el.style.height = '0';
              }
            },
          },
        },
        childrenOfTransition
      );

    const wrapperData = {
      // ラッパーに常時ついているスタイル
      style: {
        overflow: 'hidden',
        transition: `height ${this.easing} ${this.duration}ms`,
      },
    };

    const isEmpty = !child || child.tag === undefined;
    
    if (isEmpty) {
      // --------------
      // v-if="false"
      // --------------

      return generateTransitionComponent([]); // transitionの中を空で返す
    }

    const vShowDirective =
      child.data.directives &&
      child.data.directives.find(directive => directive.name === 'show');
    const isHidden = vShowDirective && !vShowDirective.value;

    if (isHidden) {
      // --------------
      // v-show="false"
      // --------------
      child.data.directives = [
        // 子のv-showディレクティブを消す
        ...child.data.directives.filter(
          directive => directive.name !== 'show'
        ),
      ];

      return generateTransitionComponent([
        h(
          'div', // h('div') でラッパーを作る
          {
            ...wrapperData,
            // ラッパーにv-showディレクティブを移植する
            directives: [
              {
                name: 'show',
                value: false,
              },
            ],
          },
          [child]
        ),
      ]);
    }

    if (vShowDirective && !isHidden) {
      // --------------
      // v-show="true"
      // --------------

      return generateTransitionComponent([
        h(
          'div',
          {
            ...wrapperData,
            // ラッパーにv-show="true"をプロキシする
            directives: [
              {
                name: 'show',
                value: true,
              },
            ],
          },
          [child]
        ),
      ]);
    }

    // --------------
    // v-if="true"
    // --------------
    // 特に何もせず、ラッパーに子をそのまま入れて返す
    return generateTransitionComponent([h('div', wrapperData, [child])]);
  },
};
</script>

エッジケースの処理はまだ足りていないかもしれませんが、自分のプロジェクトではこの実装が活躍してくれています。

まとめ

今回は弊リポジトリの中でも一番複雑なトランジションコンポーネントを開陳してみました。他にもフェードしたりスライドしたりと多種多様なコンポーネントがありますが、どれももっとシンプルな実装になっています。

皆様もトランジションコンポーネントを美しく共通化して、きれいなソースコードのまま、素敵なアニメーションのアプリケーションを実装していけることをお祈り申し上げます。

ぼやき

transition-group のコンポーネント化はマジでつらい

  1. css単体ではなくてjsのパワーを存分に使えるので max-height をtransitionさせるハックなどはする必要がない。最高。

  2. vueのソースを少し追ってみたのですが、v-ifをどこでハンドリングしているかわからなかったので、有識者の方いらっしゃったらコメントで教えて下さい!

7
5
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
7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?