Vueで親コンポーネントを跨いだTransitionを実現する

# 想定する使い道
たとえばVueでカードゲームを実装する時、

- 山札置き場
- 手札
- カード

とCompnentを定義するとします。

そして今「カード」は「山札置き場」の子で、「山札置き場」から「手札」に移動するとします。
この時カードは位置アニメーションして欲しいわけですが、vueの[transition](https://jp.vuejs.org/v2/guide/transitioning-state.html)や[transition group](https://jp.vuejs.org/v2/guide/transitions.html#%E3%83%AA%E3%82%B9%E3%83%88%E3%83%88%E3%83%A9%E3%83%B3%E3%82%B8%E3%82%B7%E3%83%A7%E3%83%B3)では実現できません。
なぜなら親Componentが山札から手札へ変わるから[^1]です。

[^1]: 「カード」をフラットに並べて自分で座標管理すれば`transition`でも良いですが、それは面倒くさいからやりたくない、という背景です


# デモ
具体的にはこういうことです。
四分割された地が親Component、数字が書かれた四角が子Componentで、クリックしたら別の親Componentに移動します。
[こちらで実際に試せます](https://inamori.github.io/vue-parent-change-transition/)

![vue-parent-change-transition.gif](https://qiita-image-store.s3.amazonaws.com/0/17941/79d61efa-c5f5-5512-d737-7d207ca74c3c.gif)


# 実装
Vueのtransition-groupでも使われている[FLIP](https://aerotwist.com/blog/flip-your-animations/)というアイデアを使っています。
基本は
- `before-leave`で現在の位置を覚えて
- `after-enter`で`現在の位置 - 直前の位置`だけtranslateし、その差分だけ移動アニメーション
という感じです。


``` parent-change-transition.vue
<template>
  <transition v-on:before-enter="beforeEnter" v-on:after-enter="afterEnter" v-on:before-leave="beforeLeave">
    <slot/>
  </transition>
</template>

<script>
//...

  export default {
    name: "parent-change-transition",
    methods: {
      beforeLeave (el) {
        previousPosition[this.childId] = el.getBoundingClientRect()
      },
      beforeEnter (el) {
        el.hidden = true
      },
      afterEnter (el) {
        el.hidden = false
        if (!previousPosition[this.childId]) return
        // 現在位置と直前位置の差だけtranslateし、tweenさせる
      }
    }
  }
</script>

```

しかしこれだけだとアニメーションするのは対象の子だけです。
つまり対象の子が移動した後、残された他の子が一瞬で移動してしまう為、何が起きたの分かりにくくなってしまいます。

最初はこの部分にだけ[リストトランジション](https://jp.vuejs.org/v2/guide/transitions.html#%E3%83%AA%E3%82%B9%E3%83%88%E3%83%88%E3%83%A9%E3%83%B3%E3%82%B8%E3%82%B7%E3%83%A7%E3%83%B3)を使えば良いのでは？と思いましたが、親に`<transition-group>`を使うと子の`<transition>`が無視される為上手くいきません。

そこで全ての子の位置を覚えておいて、前フレームと位置が変わったらアニメーションさせるという泥臭い実装が必要になります。

``` parent-change-transition.vue
<template>
  <transition v-on:before-enter="beforeEnter" v-on:after-enter="afterEnter" v-on:before-leave="beforeLeave">
    <slot/>
  </transition>
</template>

<script>
  let previousPosition = {}

  export default {
    name: "parent-change-transition",
    props: {
      idPropertyName: { type:String, default: 'id' },
      duration: { type:Number, default: 300 }
    },
    data () {
      return { intervalIndex: null }
    },
    mounted () {
      this.startPositionInspection()
    },
    computed: {
      childId () {
        return this[[this.idPropertyName]]
      }
    },
    methods: {
      startPositionInspection () {
        let p = this.$el.getBoundingClientRect()
        this.intervalIndex = setInterval(() => {
          let current = this.$el.getBoundingClientRect()
          if (current.x === p.x && current.y === p.y) return
          this.move(p, current)
        }, 10)
      },
      move (previous, current) {
        clearInterval(this.intervalIndex)
        this.$el.animate([
          { transform: `translate(${previous.x - current.x}px, ${previous.y - current.y}px)` },
          { transform: 'translate(0, 0)' }
        ], { duration: this.duration }).addEventListener('finish', () => this.startPositionInspection())
      },
      beforeLeave (el) {
        previousPosition[this.childId] = el.getBoundingClientRect()
      },
      beforeEnter (el) {
        el.hidden = true
      },
      afterEnter (el) {
        el.hidden = false
        if (!previousPosition[this.childId]) return
        this.move(previousPosition[this.childId], el.getBoundingClientRect())
      }
    }
  }
</script>
```
使用例のコードなど: [https://github.com/inamori/vue-parent-change-transition](https://github.com/inamori/vue-parent-change-transition)

# npm
[https://www.npmjs.com/package/vue-parent-change-transition](https://www.npmjs.com/package/vue-parent-change-transition)
せっかくなので登録しました。